diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/IInteractionTrackerInertiaHandler.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/IInteractionTrackerInertiaHandler.cs new file mode 100644 index 000000000000..84149d98fe15 --- /dev/null +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/IInteractionTrackerInertiaHandler.cs @@ -0,0 +1,14 @@ +using System.Numerics; + +namespace Microsoft.UI.Composition.Interactions; + +internal interface IInteractionTrackerInertiaHandler +{ + Vector3 InitialVelocity { get; } + Vector3 FinalPosition { get; } + Vector3 FinalModifiedPosition { get; } + float FinalScale { get; } + + void Start(); + void Stop(); +} diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTracker.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTracker.cs index 7fd16408f7ca..ccad53857a3b 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTracker.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTracker.cs @@ -98,6 +98,16 @@ internal void ReceiveInertiaStarting(Point linearVelocity) _state.ReceiveInertiaStarting(-linearVelocity); } + internal void ReceivePointerWheel(int mouseWheelTicks, bool isHorizontal) + { + // On WinUI, this depends on mouse setting "how many lines to scroll each time" + // The default Windows setting is 3 lines, and each line is 16px. + // Note: the value for each line may vary depending on scaling. + // For now, we just use 16*3=48. + var delta = mouseWheelTicks * 48; + _state.ReceivePointerWheel(-delta, isHorizontal); + } + public int TryUpdatePosition(Vector3 value) => TryUpdatePosition(value, InteractionTrackerClampingOption.Auto); diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaHandler.AxisHelper.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerActiveInputInertiaHandler.AxisHelper.cs similarity index 94% rename from src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaHandler.AxisHelper.cs rename to src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerActiveInputInertiaHandler.AxisHelper.cs index 71e0d3a247e5..ea45baefdb4e 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaHandler.AxisHelper.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerActiveInputInertiaHandler.AxisHelper.cs @@ -5,7 +5,7 @@ namespace Microsoft.UI.Composition.Interactions; -internal sealed partial class InteractionTrackerInertiaHandler +internal sealed partial class InteractionTrackerActiveInputInertiaHandler { private enum Axis { @@ -19,7 +19,7 @@ private sealed class AxisHelper private float? _dampingStateTimeInSeconds; private float? _dampingStatePosition; - internal InteractionTrackerInertiaHandler Handler { get; } + internal InteractionTrackerActiveInputInertiaHandler Handler { get; } internal float DecayRate { get; } internal float InitialVelocity { get; } internal float InitialValue { get; } @@ -30,7 +30,7 @@ private sealed class AxisHelper internal bool HasCompleted { get; private set; } - public AxisHelper(InteractionTrackerInertiaHandler handler, Vector3 velocities, Axis axis) + public AxisHelper(InteractionTrackerActiveInputInertiaHandler handler, Vector3 velocities, Axis axis) { Axis = axis; Handler = handler; diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaHandler.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerActiveInputInertiaHandler.cs similarity index 91% rename from src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaHandler.cs rename to src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerActiveInputInertiaHandler.cs index 61b3a0b89399..4cbb57230441 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaHandler.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerActiveInputInertiaHandler.cs @@ -7,7 +7,7 @@ namespace Microsoft.UI.Composition.Interactions; -internal sealed partial class InteractionTrackerInertiaHandler +internal sealed partial class InteractionTrackerActiveInputInertiaHandler : IInteractionTrackerInertiaHandler { private readonly InteractionTracker _interactionTracker; private readonly AxisHelper _xHelper; @@ -28,7 +28,7 @@ internal sealed partial class InteractionTrackerInertiaHandler public Vector3 FinalModifiedPosition => new Vector3(_xHelper.FinalModifiedValue, _yHelper.FinalModifiedValue, _zHelper.FinalModifiedValue); public float FinalScale => _interactionTracker.Scale; // TODO: Scale not yet implemented - public InteractionTrackerInertiaHandler(InteractionTracker interactionTracker, Vector3 translationVelocities, int requestId) + public InteractionTrackerActiveInputInertiaHandler(InteractionTracker interactionTracker, Vector3 translationVelocities, int requestId) { _interactionTracker = interactionTracker; _xHelper = new AxisHelper(this, translationVelocities, Axis.X); diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerCustomAnimationState.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerCustomAnimationState.cs index 777395deaa6e..8a68c62f9739 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerCustomAnimationState.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerCustomAnimationState.cs @@ -34,13 +34,17 @@ internal override void ReceiveInertiaStarting(Point linearVelocity) { } + internal override void ReceivePointerWheel(int delta, bool isHorizontal) + { + } + internal override void TryUpdatePositionWithAdditionalVelocity(Vector3 velocityInPixelsPerSecond, int requestId) { // TODO: Stop current animation. Currently, the TryUpdate[Position|Scale]WithAnimation methods are not implemented. // State changes to inertia with inertia modifiers evaluated using requested velocity as initial velocity. // TODO: inertia modifiers not yet implemented. - _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, velocityInPixelsPerSecond, requestId)); + _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, velocityInPixelsPerSecond, requestId, isFromPointerWheel: false)); } internal override void TryUpdatePosition(Vector3 value, InteractionTrackerClampingOption option, int requestId) diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerIdleState.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerIdleState.cs index da82844153a0..9bb20f9a0b73 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerIdleState.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerIdleState.cs @@ -41,11 +41,19 @@ internal override void ReceiveInertiaStarting(Point linearVelocity) { } + internal override void ReceivePointerWheel(int delta, bool isHorizontal) + { + // Constant velocity for 250ms + var velocityValue = delta / 0.25f; + Vector3 velocity = isHorizontal ? new Vector3(velocityValue, 0, 0) : new Vector3(0, velocityValue, 0); + _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, velocity, requestId: 0, isFromPointerWheel: true)); + } + internal override void TryUpdatePositionWithAdditionalVelocity(Vector3 velocityInPixelsPerSecond, int requestId) { // State changes to inertia and inertia modifiers are evaluated with requested velocity as initial velocity // TODO: inertia modifiers not yet implemented. - _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, velocityInPixelsPerSecond, requestId)); + _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, velocityInPixelsPerSecond, requestId, isFromPointerWheel: false)); } internal override void TryUpdatePosition(Vector3 value, InteractionTrackerClampingOption option, int requestId) diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaState.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaState.cs index 58dc30a955f9..03e49e0048d6 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaState.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInertiaState.cs @@ -8,13 +8,15 @@ namespace Microsoft.UI.Composition.Interactions; internal sealed class InteractionTrackerInertiaState : InteractionTrackerState { - private readonly InteractionTrackerInertiaHandler _handler; + private readonly IInteractionTrackerInertiaHandler _handler; private readonly int _requestId; - public InteractionTrackerInertiaState(InteractionTracker interactionTracker, Vector3 translationVelocities, int requestId) : base(interactionTracker) + public InteractionTrackerInertiaState(InteractionTracker interactionTracker, Vector3 translationVelocities, int requestId, bool isFromPointerWheel) : base(interactionTracker) { _requestId = requestId; - _handler = new InteractionTrackerInertiaHandler(interactionTracker, translationVelocities, _requestId); + _handler = isFromPointerWheel + ? new InteractionTrackerPointerWheelInertiaHandler(interactionTracker, translationVelocities) + : new InteractionTrackerActiveInputInertiaHandler(interactionTracker, translationVelocities, _requestId); } protected override void EnterState(IInteractionTrackerOwner? owner) @@ -69,10 +71,19 @@ internal override void ReceiveInertiaStarting(Point linearVelocity) { } + internal override void ReceivePointerWheel(int delta, bool isHorizontal) + { + var newDelta = isHorizontal ? new Vector3(delta, 0, 0) : new Vector3(0, delta, 0); + var totalDelta = (_handler.FinalModifiedPosition - _interactionTracker.Position) + newDelta; + // Constant velocity for 250ms + var velocity = totalDelta / 0.25f; + _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, velocity, requestId: 0, isFromPointerWheel: true)); + } + internal override void TryUpdatePositionWithAdditionalVelocity(Vector3 velocityInPixelsPerSecond, int requestId) { // Inertia is restarted (state re-enters inertia) and inertia modifiers are evaluated with requested velocity added to current velocity - _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, _handler.InitialVelocity + velocityInPixelsPerSecond, requestId)); + _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, _handler.InitialVelocity + velocityInPixelsPerSecond, requestId, isFromPointerWheel: false)); } internal override void TryUpdatePosition(Vector3 value, InteractionTrackerClampingOption option, int requestId) diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInteractingState.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInteractingState.cs index 49833d7b184e..ee11e25523ef 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInteractingState.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerInteractingState.cs @@ -37,7 +37,7 @@ internal override void CompleteUserManipulation(Vector3 linearVelocity) this.Log().Error("Unexpected CompleteUserManipulation while in interacting state"); } - _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, linearVelocity, requestId: 0)); + _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, linearVelocity, requestId: 0, isFromPointerWheel: false)); } internal override void ReceiveManipulationDelta(Point translationDelta) @@ -47,7 +47,11 @@ internal override void ReceiveManipulationDelta(Point translationDelta) internal override void ReceiveInertiaStarting(Point linearVelocity) { - _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, new Vector3((float)linearVelocity.X, (float)linearVelocity.Y, 0), requestId: 0)); + _interactionTracker.ChangeState(new InteractionTrackerInertiaState(_interactionTracker, new Vector3((float)linearVelocity.X, (float)linearVelocity.Y, 0), requestId: 0, isFromPointerWheel: false)); + } + + internal override void ReceivePointerWheel(int delta, bool isHorizontal) + { } internal override void TryUpdatePositionWithAdditionalVelocity(Vector3 velocityInPixelsPerSecond, int requestId) diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerPointerWheelInertiaHandler.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerPointerWheelInertiaHandler.cs new file mode 100644 index 000000000000..81ae7c28a45d --- /dev/null +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerPointerWheelInertiaHandler.cs @@ -0,0 +1,89 @@ +#nullable enable + +using System; +using System.Diagnostics; +using System.Numerics; +using System.Threading; + +namespace Microsoft.UI.Composition.Interactions; + +internal class InteractionTrackerPointerWheelInertiaHandler : IInteractionTrackerInertiaHandler +{ + // InteractionTracker works at 60 FPS, per documentation + // https://learn.microsoft.com/en-us/windows/uwp/composition/interaction-tracker-manipulations#why-use-interactiontracker + // > InteractionTracker was built to utilize the new Animation engine that operates on an independent thread at 60 FPS,resulting in smooth motion. + private const int IntervalInMilliseconds = 17; // Ceiling of 1000/60 + + private Timer? _timer; + private Stopwatch? _stopwatch; + + private readonly InteractionTracker _interactionTracker; + private readonly Vector3 _minPosition; + private readonly Vector3 _maxPosition; + private readonly Vector3 _initialPosition; + private readonly Vector3 _calculatedFinalPosition; + + public InteractionTrackerPointerWheelInertiaHandler(InteractionTracker interactionTracker, Vector3 translationVelocities) + { + _interactionTracker = interactionTracker; + _minPosition = interactionTracker.MinPosition; + _maxPosition = interactionTracker.MaxPosition; + _initialPosition = _interactionTracker.Position; + + InitialVelocity = translationVelocities; + + // This handler works with constant velocity for 0.25 second. + _calculatedFinalPosition = interactionTracker.Position + InitialVelocity * 0.25f; + } + + public Vector3 InitialVelocity { get; } + + public Vector3 FinalPosition => Vector3.Clamp(_calculatedFinalPosition, _minPosition, _maxPosition); + + public Vector3 FinalModifiedPosition => FinalPosition; + + public float FinalScale => _interactionTracker.Scale; // TODO: Scale not yet implemented + + public void Start() + { + if (_timer is not null) + { + throw new InvalidOperationException("Cannot start inertia timer twice."); + } + + _stopwatch = Stopwatch.StartNew(); + _timer = new Timer(OnTick, null, 0, IntervalInMilliseconds); + } + + public void Stop() + { + _timer?.Dispose(); + _stopwatch?.Stop(); + } + + private void OnTick(object? state) + { + var currentElapsed = _stopwatch!.ElapsedMilliseconds; + + if (currentElapsed >= 250) + { + _interactionTracker.SetPosition(FinalModifiedPosition, requestId: 0); + _interactionTracker.ChangeState(new InteractionTrackerIdleState(_interactionTracker, requestId: 0)); + _timer!.Dispose(); + _stopwatch!.Stop(); + return; + } + + var newPosition = _initialPosition + (currentElapsed / 1000.0f) * InitialVelocity; + var clampedNewPosition = Vector3.Clamp(newPosition, _minPosition, _maxPosition); + + _interactionTracker.SetPosition(clampedNewPosition, requestId: 0); + + if (clampedNewPosition.Equals(FinalModifiedPosition)) + { + _interactionTracker.ChangeState(new InteractionTrackerIdleState(_interactionTracker, requestId: 0)); + _timer!.Dispose(); + _stopwatch!.Stop(); + } + } +} diff --git a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerState.cs b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerState.cs index 370e37344aae..5fff8259c609 100644 --- a/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerState.cs +++ b/src/Uno.UI.Composition/Composition/InteractionTracker/InteractionTrackerState.cs @@ -23,6 +23,7 @@ public InteractionTrackerState(InteractionTracker interactionTracker) internal abstract void CompleteUserManipulation(Vector3 linearVelocity); internal abstract void ReceiveManipulationDelta(Point translationDelta); internal abstract void ReceiveInertiaStarting(Point linearVelocity); + internal abstract void ReceivePointerWheel(int delta, bool isHorizontal); internal abstract void TryUpdatePositionWithAdditionalVelocity(Vector3 velocityInPixelsPerSecond, int requestId); internal abstract void TryUpdatePosition(Vector3 value, InteractionTrackerClampingOption option, int requestId); public virtual void Dispose() => _disposed = true; diff --git a/src/Uno.UI.Composition/Composition/VisualInteractionSource.cs b/src/Uno.UI.Composition/Composition/VisualInteractionSource.cs index 3e9f221ac0b7..486a8375f217 100644 --- a/src/Uno.UI.Composition/Composition/VisualInteractionSource.cs +++ b/src/Uno.UI.Composition/Composition/VisualInteractionSource.cs @@ -60,6 +60,8 @@ private VisualInteractionSource(Visual source) : base(source.Compositor) /// public VisualInteractionSourceRedirectionMode ManipulationRedirectionMode { get; set; } + internal bool RedirectsPointerWheel => (ManipulationRedirectionMode & VisualInteractionSourceRedirectionMode.PointerWheelOnly) != 0; + public static VisualInteractionSource Create(Visual source) { // WinUI doesn't allow a second `VisualInteractionSource`s with the same source, unless the previous one is disposed. diff --git a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Composition/Given_InteractionTracker.cs b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Composition/Given_InteractionTracker.cs index e035d768455c..636c5a8dfb50 100644 --- a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Composition/Given_InteractionTracker.cs +++ b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Composition/Given_InteractionTracker.cs @@ -263,4 +263,77 @@ public async Task When_UserInteraction() Assert.IsTrue(captureLostRaised); Assert.IsTrue(helper.IsDone); } + +#if HAS_UNO + [TestMethod] + [RequiresFullWindow] +#if !HAS_INPUT_INJECTOR + [Ignore("InputInjector is only supported on skia")] +#endif + public async Task When_MouseWheel() + { + var border = new Border() + { + Width = 200, + Height = 200, + Background = new SolidColorBrush(Microsoft.UI.Colors.Red), + }; + + var position = await UITestHelper.Load(border); + + var visual = ElementCompositionPreview.GetElementVisual(border); + var tracker = SetupTracker(visual.Compositor); + + Assert.AreEqual(Vector3.Zero, tracker.Position); + + var vis = VisualInteractionSource.Create(visual); + vis.ManipulationRedirectionMode = VisualInteractionSourceRedirectionMode.PointerWheelOnly; + vis.PositionXSourceMode = InteractionSourceMode.EnabledWithInertia; + vis.PositionYSourceMode = InteractionSourceMode.EnabledWithInertia; + tracker.InteractionSources.Add(vis); + + await TestServices.WindowHelper.WaitForIdle(); + + var injector = InputInjector.TryCreate() ?? throw new InvalidOperationException("Failed to init the InputInjector"); + var finger = injector.GetMouse(); + finger.MoveTo(new(position.Left + 100, position.Top + 100), steps: 1); + finger.WheelDown(); + + string logs = await WaitTrackerLogs(tracker); + var helper = new TrackerAssertHelper(logs); + + Assert.AreEqual( + TrackerLogsConstructingHelper.GetInertiaStateEntered( + trackerPosition: new(0.0f, 0.0f, 0.0f), + requestId: 0, + naturalRestingPosition: new(0.0f, 48.0f, 0.0f), + modifiedRestingPosition: new(0.0f, 48.0f, 0.0f), + positionVelocityInPixelsPerSecond: new(0.0f, 192.0f, 0.0f)), + helper.Current); + + helper.Advance(); + + var linesSkipped = helper.SkipLines(current => current.StartsWith("ValuesChanged:", StringComparison.Ordinal)); + Assert.IsTrue(linesSkipped >= 2); + helper.Back(); + + Assert.AreEqual( + TrackerLogsConstructingHelper.GetValuesChanged( + trackerPosition: new(0.0f, 48.0f, 0.0f), + requestId: 0, + argsPosition: new(0.0f, 48.0f, 0.0f)), + helper.Current); + + helper.Advance(); + + Assert.AreEqual( + TrackerLogsConstructingHelper.GetIdleStateEntered( + trackerPosition: new(0.0f, 48.0f, 0.0f), + requestId: 0), + helper.Current); + + helper.Advance(); + Assert.IsTrue(helper.IsDone); + } +#endif } diff --git a/src/Uno.UI/UI/Xaml/Internal/InputManager.Pointers.Managed.cs b/src/Uno.UI/UI/Xaml/Internal/InputManager.Pointers.Managed.cs index 8f92aaba1c4c..97e23e345d87 100644 --- a/src/Uno.UI/UI/Xaml/Internal/InputManager.Pointers.Managed.cs +++ b/src/Uno.UI/UI/Xaml/Internal/InputManager.Pointers.Managed.cs @@ -22,6 +22,8 @@ using PointerDeviceType = Windows.Devices.Input.PointerDeviceType; using PointerEventArgs = Windows.UI.Core.PointerEventArgs; using PointerUpdateKind = Windows.UI.Input.PointerUpdateKind; +using Microsoft.UI.Composition.Interactions; +using Microsoft.UI.Composition; namespace Uno.UI.Xaml.Core; @@ -143,6 +145,24 @@ private void OnPointerWheelChanged(Windows.UI.Core.PointerEventArgs args) UpdateLastInputType(args); +#if __SKIA__ // Currently, only Skia supports interaction tracker. + Visual? currentVisual = originalSource.Visual; + while (currentVisual is not null) + { + if (currentVisual.VisualInteractionSource is { RedirectsPointerWheel: true } vis) + { + foreach (var tracker in vis.Trackers) + { + tracker.ReceivePointerWheel(args.CurrentPoint.Properties.MouseWheelDelta / global::Microsoft.UI.Xaml.Controls.ScrollContentPresenter.ScrollViewerDefaultMouseWheelDelta, args.CurrentPoint.Properties.IsHorizontalMouseWheel); + } + + return; + } + + currentVisual = currentVisual.Parent; + } +#endif + var routedArgs = new PointerRoutedEventArgs(args, originalSource); // First raise the event, either on the OriginalSource or on the capture owners if any