From 3da7aa48405a62d506ffe643718da27332af834b Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Wed, 30 Jul 2025 23:04:59 +0900 Subject: [PATCH] [iOS] Enable Pointer/Trackpad scrolling (#19342) * [iOS] Enable Pointer/Trackpad scrolling * Cleanup * Update comments, make sure StopMomentumScrolling is called when momentum stops * Wrap RawMouseWheel Invoke call with null check * Replace check --- src/iOS/Avalonia.iOS/AvaloniaView.cs | 11 +++ src/iOS/Avalonia.iOS/InputHandler.cs | 132 +++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index eaad60a860..491eacf489 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -76,6 +76,17 @@ namespace Avalonia.iOS { #if !TVOS MultipleTouchEnabled = true; + + if (OperatingSystem.IsIOSVersionAtLeast(13, 4) || OperatingSystem.IsMacCatalyst()) + { + var scrollGestureRecognizer = new UIPanGestureRecognizer(_input.HandleScrollWheel) + { + // Only respond to scroll events, not touches + MaximumNumberOfTouches = 0, + AllowedScrollTypesMask = UIScrollTypeMask.Discrete | UIScrollTypeMask.Continuous + }; + AddGestureRecognizer(scrollGestureRecognizer); + } #endif } } diff --git a/src/iOS/Avalonia.iOS/InputHandler.cs b/src/iOS/Avalonia.iOS/InputHandler.cs index 2a28950219..552f003a38 100644 --- a/src/iOS/Avalonia.iOS/InputHandler.cs +++ b/src/iOS/Avalonia.iOS/InputHandler.cs @@ -6,6 +6,9 @@ using Avalonia.Input.Raw; using Avalonia.Platform; using Foundation; using UIKit; +#if !TVOS +using CoreAnimation; +#endif namespace Avalonia.iOS; @@ -20,6 +23,14 @@ internal sealed class InputHandler private readonly PenDevice _penDevice = new(releasePointerOnPenUp: true); private static long _nextTouchPointId = 1; private readonly Dictionary _knownTouches = new(); + private Point? _cachedScrollLocation; + + #if !TVOS + private CADisplayLink? _momentumDisplayLink; + private double _momentumVelocityX; + private double _momentumVelocityY; + private const double DecelerationRate = 0.95; + #endif public InputHandler(AvaloniaView view, ITopLevelImpl tl) { @@ -249,6 +260,127 @@ internal sealed class InputHandler return modifier; } + public void HandleScrollWheel(UIPanGestureRecognizer recognizer) + { + switch (recognizer.State) + { + case UIGestureRecognizerState.Began: + // We've started scrolling, stop any previous inertia scrolling + // and cache the current scroll location. + StopMomentumScrolling(); + _cachedScrollLocation = recognizer.LocationInView(_view).ToAvalonia(); + return; + case UIGestureRecognizerState.Changed: + // When you are actively scrolling, we send the scroll events + SendActiveScrollEvent(recognizer); + return; + case UIGestureRecognizerState.Ended: + // When you stop scrolling, we start inertia scrolling + // UpdateInertiaScrolling will check when the inertia stops + // and will call StopMomentumScrolling + StartInertiaScrolling(recognizer); + return; + case UIGestureRecognizerState.Cancelled: + case UIGestureRecognizerState.Failed: + // If the gesture is cancelled or failed, stop. + StopMomentumScrolling(); + return; + default: + return; + } + } + + private void SendActiveScrollEvent(UIPanGestureRecognizer recognizer) + { +#if !TVOS + // iOS 13.4+ and Catalyst support scroll wheel events + if (!OperatingSystem.IsIOSVersionAtLeast(13, 4) && !OperatingSystem.IsMacCatalyst()) + return; + + var velocity = recognizer.VelocityInView(_view); + + // Use much more sensitive scaling for active scrolling to match AppKit. + // macOS uses small deltas, so we need much larger divisors + var scaleFactor = 3000.0; + + var deltaX = velocity.X / scaleFactor; + var deltaY = velocity.Y / scaleFactor; + + _tl.Input?.Invoke(new RawMouseWheelEventArgs( + _mouseDevice, + (ulong)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), + Root, + _cachedScrollLocation ?? new Point(0, 0), + new Vector(deltaX, deltaY), + RawInputModifiers.None + )); +#endif + } + + private void StartInertiaScrolling(UIPanGestureRecognizer recognizer) + { +#if !TVOS + // iOS 13.4+ and Catalyst support scroll wheel events + if (!OperatingSystem.IsIOSVersionAtLeast(13, 4) && !OperatingSystem.IsMacCatalyst()) + return; + + var velocity = recognizer.VelocityInView(_view); + + var scaleFactor = 800.0; + _momentumVelocityX = velocity.X / scaleFactor; + _momentumVelocityY = velocity.Y / scaleFactor; + _momentumDisplayLink = CADisplayLink.Create(UpdateInertiaScrolling); + _momentumDisplayLink.AddToRunLoop(NSRunLoop.Main, NSRunLoopMode.Common); +#endif + } + + private void StopMomentumScrolling() + { +#if !TVOS + if (_momentumDisplayLink != null) + { + // Invalidate removes it from all run loops + // https://developer.apple.com/documentation/quartzcore/cadisplaylink + _momentumDisplayLink.Invalidate(); + _momentumDisplayLink = null; + } + + _momentumVelocityX = 0; + _momentumVelocityY = 0; + _cachedScrollLocation = null; +#endif + } + + private void UpdateInertiaScrolling() + { +#if !TVOS + _momentumVelocityX *= DecelerationRate; + _momentumVelocityY *= DecelerationRate; + + var currentMagnitude = Math.Sqrt(_momentumVelocityX * _momentumVelocityX + _momentumVelocityY * _momentumVelocityY); + + if (currentMagnitude < 0.0001 || _cachedScrollLocation is null) + { + StopMomentumScrolling(); + return; + } + + // UIPanGestureRecognizer will continue to upload the location of the pointer, + // to where it would be if it was moving with the current velocity, + // even though the pointer on screen is not moving. + // We can cache the location when we start scrolling and keep it static + // until the inertia stops. + _tl.Input?.Invoke(new RawMouseWheelEventArgs( + _mouseDevice, + (ulong)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), + Root, + _cachedScrollLocation.Value, + new Vector(_momentumVelocityX, _momentumVelocityY), + RawInputModifiers.None + )); +#endif + } + #pragma warning disable CA1416 private static Dictionary s_keys = new() {