diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 4a5f5bc96c..9e0fb3f852 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -92,6 +92,9 @@ + + + diff --git a/samples/ControlCatalog/Pages/GesturePage.cs b/samples/ControlCatalog/Pages/GesturePage.cs new file mode 100644 index 0000000000..ee10f21317 --- /dev/null +++ b/samples/ControlCatalog/Pages/GesturePage.cs @@ -0,0 +1,212 @@ +using System; +using System.Numerics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Markup.Xaml; +using Avalonia.Rendering.Composition; + +namespace ControlCatalog.Pages +{ + public class GesturePage : UserControl + { + private bool _isInit; + private float _currentScale; + + public GesturePage() + { + this.InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if(_isInit) + { + return; + } + + _isInit = true; + + SetPullHandlers(this.Find("TopPullZone"), false); + SetPullHandlers(this.Find("BottomPullZone"), true); + SetPullHandlers(this.Find("RightPullZone"), true); + SetPullHandlers(this.Find("LeftPullZone"), false); + + var image = this.Find("PinchImage"); + SetPinchHandlers(image); + + var reset = this.Find + + diff --git a/src/Avalonia.Base/Input/GestureRecognizers/PinchGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/PinchGestureRecognizer.cs new file mode 100644 index 0000000000..eea7c3b7d1 --- /dev/null +++ b/src/Avalonia.Base/Input/GestureRecognizers/PinchGestureRecognizer.cs @@ -0,0 +1,128 @@ +using Avalonia.Input.GestureRecognizers; + +namespace Avalonia.Input +{ + public class PinchGestureRecognizer : StyledElement, IGestureRecognizer + { + private IInputElement? _target; + private IGestureRecognizerActionsDispatcher? _actions; + private float _initialDistance; + private IPointer? _firstContact; + private Point _firstPoint; + private IPointer? _secondContact; + private Point _secondPoint; + private Point _origin; + + public void Initialize(IInputElement target, IGestureRecognizerActionsDispatcher actions) + { + _target = target; + _actions = actions; + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + PointerPressed(e); + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + PointerReleased(e); + } + + public void PointerCaptureLost(IPointer pointer) + { + RemoveContact(pointer); + } + + public void PointerMoved(PointerEventArgs e) + { + if (_target != null && _target is Visual visual) + { + if(_firstContact == e.Pointer) + { + _firstPoint = e.GetPosition(visual); + } + else if (_secondContact == e.Pointer) + { + _secondPoint = e.GetPosition(visual); + } + else + { + return; + } + + if (_firstContact != null && _secondContact != null) + { + var distance = GetDistance(_firstPoint, _secondPoint); + + var scale = distance / _initialDistance; + + _target?.RaiseEvent(new PinchEventArgs(scale, _origin)); + } + } + } + + public void PointerPressed(PointerPressedEventArgs e) + { + if (_target != null && _target is Visual visual && (e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen)) + { + if (_firstContact == null) + { + _firstContact = e.Pointer; + _firstPoint = e.GetPosition(visual); + + return; + } + else if (_secondContact == null && _firstContact != e.Pointer) + { + _secondContact = e.Pointer; + _secondPoint = e.GetPosition(visual); + } + else + { + return; + } + + if (_firstContact != null && _secondContact != null) + { + _initialDistance = GetDistance(_firstPoint, _secondPoint); + + _origin = new Point((_firstPoint.X + _secondPoint.X) / 2.0f, (_firstPoint.Y + _secondPoint.Y) / 2.0f); + + _actions!.Capture(_firstContact, this); + _actions!.Capture(_secondContact, this); + } + } + } + + public void PointerReleased(PointerReleasedEventArgs e) + { + RemoveContact(e.Pointer); + } + + private void RemoveContact(IPointer pointer) + { + if (_firstContact == pointer || _secondContact == pointer) + { + if (_secondContact == pointer) + { + _secondContact = null; + } + + if (_firstContact == pointer) + { + _firstContact = _secondContact; + + _secondContact = null; + } + _target?.RaiseEvent(new PinchEndedEventArgs()); + } + } + + private float GetDistance(Point a, Point b) + { + var length = _secondPoint - _firstPoint; + return (float)new Vector(length.X, length.Y).Length; + } + } +} diff --git a/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs index fedd07ec32..23bab13fc8 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs @@ -1,15 +1,19 @@ -using Avalonia.Input.GestureRecognizers; +using System; +using Avalonia.Input.GestureRecognizers; namespace Avalonia.Input { public class PullGestureRecognizer : StyledElement, IGestureRecognizer { + internal static int MinPullDetectionSize = 50; + private IInputElement? _target; private IGestureRecognizerActionsDispatcher? _actions; private Point _initialPosition; private int _gestureId; private IPointer? _tracking; private PullDirection _pullDirection; + private bool _pullInProgress; /// /// Defines the property. @@ -31,23 +35,12 @@ namespace Avalonia.Input PullDirection = pullDirection; } + public PullGestureRecognizer() { } + public void Initialize(IInputElement target, IGestureRecognizerActionsDispatcher actions) { _target = target; _actions = actions; - - _target?.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble); - _target?.AddHandler(InputElement.PointerReleasedEvent, OnPointerReleased, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble); - } - - private void OnPointerPressed(object? sender, PointerPressedEventArgs e) - { - PointerPressed(e); - } - - private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) - { - PointerReleased(e); } public void PointerCaptureLost(IPointer pointer) @@ -94,6 +87,7 @@ namespace Avalonia.Input break; } + _pullInProgress = true; _target?.RaiseEvent(new PullGestureEventArgs(_gestureId, delta, PullDirection)); } } @@ -111,16 +105,16 @@ namespace Avalonia.Input switch (PullDirection) { case PullDirection.TopToBottom: - canPull = position.Y < bounds.Height * 0.1; + canPull = position.Y < Math.Max(MinPullDetectionSize, bounds.Height * 0.1); break; case PullDirection.BottomToTop: - canPull = position.Y > bounds.Height - (bounds.Height * 0.1); + canPull = position.Y > Math.Min(bounds.Height - MinPullDetectionSize, bounds.Height - (bounds.Height * 0.1)); break; case PullDirection.LeftToRight: - canPull = position.X < bounds.Width * 0.1; + canPull = position.X < Math.Max(MinPullDetectionSize, bounds.Width * 0.1); break; case PullDirection.RightToLeft: - canPull = position.X > bounds.Width - (bounds.Width * 0.1); + canPull = position.X > Math.Min(bounds.Width - MinPullDetectionSize, bounds.Width - (bounds.Width * 0.1)); break; } @@ -135,7 +129,7 @@ namespace Avalonia.Input public void PointerReleased(PointerReleasedEventArgs e) { - if (_tracking == e.Pointer) + if (_tracking == e.Pointer && _pullInProgress) { EndPull(); } @@ -145,6 +139,7 @@ namespace Avalonia.Input { _tracking = null; _initialPosition = default; + _pullInProgress = false; _target?.RaiseEvent(new PullGestureEndedEventArgs(_gestureId, PullDirection)); } diff --git a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs index 7bcb81767d..64fe275547 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs @@ -16,6 +16,7 @@ namespace Avalonia.Input.GestureRecognizers private bool _canHorizontallyScroll; private bool _canVerticallyScroll; private int _gestureId; + private int _scrollStartDistance = 30; // Movement per second private Vector _inertia; @@ -38,6 +39,15 @@ namespace Avalonia.Input.GestureRecognizers nameof(CanVerticallyScroll), o => o.CanVerticallyScroll, (o, v) => o.CanVerticallyScroll = v); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ScrollStartDistanceProperty = + AvaloniaProperty.RegisterDirect( + nameof(ScrollStartDistance), + o => o.ScrollStartDistance, + (o, v) => o.ScrollStartDistance = v); /// /// Gets or sets a value indicating whether the content can be scrolled horizontally. @@ -56,6 +66,15 @@ namespace Avalonia.Input.GestureRecognizers get => _canVerticallyScroll; set => SetAndRaise(CanVerticallyScrollProperty, ref _canVerticallyScroll, value); } + + /// + /// Gets or sets a value indicating the distance the pointer moves before scrolling is started + /// + public int ScrollStartDistance + { + get => _scrollStartDistance; + set => SetAndRaise(ScrollStartDistanceProperty, ref _scrollStartDistance, value); + } public void Initialize(IInputElement target, IGestureRecognizerActionsDispatcher actions) @@ -75,9 +94,6 @@ namespace Avalonia.Input.GestureRecognizers _trackedRootPoint = e.GetPosition((Visual?)_target); } } - - // Arbitrary chosen value, probably need to move that to platform settings or something - private const double ScrollStartDistance = 30; // Pixels per second speed that is considered to be the stop of inertial scroll private const double InertialScrollSpeedEnd = 5; diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs index 1ea88fe824..b4d5feaf3b 100644 --- a/src/Avalonia.Base/Input/Gestures.cs +++ b/src/Avalonia.Base/Input/Gestures.cs @@ -46,6 +46,14 @@ namespace Avalonia.Input private static readonly WeakReference s_lastPress = new WeakReference(null); private static Point s_lastPressPoint; + public static readonly RoutedEvent PinchEvent = + RoutedEvent.Register( + "PinchEvent", RoutingStrategies.Bubble, typeof(Gestures)); + + public static readonly RoutedEvent PinchEndedEvent = + RoutedEvent.Register( + "PinchEndedEvent", RoutingStrategies.Bubble, typeof(Gestures)); + public static readonly RoutedEvent PullGestureEvent = RoutedEvent.Register( "PullGesture", RoutingStrategies.Bubble, typeof(Gestures)); diff --git a/src/Avalonia.Base/Input/PinchEventArgs.cs b/src/Avalonia.Base/Input/PinchEventArgs.cs new file mode 100644 index 0000000000..31c760eb51 --- /dev/null +++ b/src/Avalonia.Base/Input/PinchEventArgs.cs @@ -0,0 +1,24 @@ +using Avalonia.Interactivity; + +namespace Avalonia.Input +{ + public class PinchEventArgs : RoutedEventArgs + { + public PinchEventArgs(double scale, Point scaleOrigin) : base(Gestures.PinchEvent) + { + Scale = scale; + ScaleOrigin = scaleOrigin; + } + + public double Scale { get; } = 1; + + public Point ScaleOrigin { get; } + } + + public class PinchEndedEventArgs : RoutedEventArgs + { + public PinchEndedEventArgs() : base(Gestures.PinchEndedEvent) + { + } + } +} diff --git a/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs b/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs index 508eff5f9d..59085a21ce 100644 --- a/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Input.GestureRecognizers; +using Avalonia.Media; using Avalonia.UnitTests; using Xunit; @@ -201,5 +203,101 @@ namespace Avalonia.Base.UnitTests.Input border.AddHandler(Gestures.TappedEvent, (s, e) => result.Add("bt")); border.AddHandler(Gestures.DoubleTappedEvent, (s, e) => result.Add("bdt")); } + + [Fact] + public void Pinched_Should_Not_Be_Raised_For_Same_Pointer() + { + var touch = new TouchTestHelper(); + + Border border = new Border() + { + Width = 100, + Height = 100, + Background = new SolidColorBrush(Colors.Red) + }; + border.GestureRecognizers.Add(new PinchGestureRecognizer()); + var decorator = new Decorator + { + Child = border + }; + var raised = false; + + decorator.AddHandler(Gestures.PinchEvent, (s, e) => raised = true); + + var firstPoint = new Point(5, 5); + var secondPoint = new Point(10, 10); + + touch.Down(border, position: firstPoint); + touch.Down(border, position: secondPoint); + touch.Down(border, position: new Point(20, 20)); + + Assert.False(raised); + } + + [Fact] + public void Pinched_Should_Be_Raised_For_Two_Pointers_Moving() + { + Border border = new Border() + { + Width = 100, + Height = 100, + Background = new SolidColorBrush(Colors.Red) + }; + border.GestureRecognizers.Add(new PinchGestureRecognizer()); + var decorator = new Decorator + { + Child = border + }; + var raised = false; + + decorator.AddHandler(Gestures.PinchEvent, (s, e) => raised = true); + + var firstPoint = new Point(5, 5); + var secondPoint = new Point(10, 10); + + var firstTouch = new TouchTestHelper(); + var secondTouch = new TouchTestHelper(); + + firstTouch.Down(border, position: firstPoint); + secondTouch.Down(border, position: secondPoint); + secondTouch.Move(border, position: new Point(20, 20)); + + Assert.True(raised); + } + + [Fact] + public void Scrolling_Should_Start_After_Start_Distance_Is_Exceded() + { + Border border = new Border() + { + Width = 100, + Height = 100, + Background = new SolidColorBrush(Colors.Red) + }; + border.GestureRecognizers.Add(new ScrollGestureRecognizer() + { + CanHorizontallyScroll = true, + CanVerticallyScroll = true, + ScrollStartDistance = 50 + }); + var decorator = new Decorator + { + Child = border + }; + var raised = false; + + decorator.AddHandler(Gestures.ScrollGestureEvent, (s, e) => raised = true); + + var firstTouch = new TouchTestHelper(); + + firstTouch.Down(border, position: new Point(5, 5)); + firstTouch.Move(border, position: new Point(20, 20)); + + Assert.False(raised); + + firstTouch.Move(border, position: new Point(70, 20)); + + Assert.True(raised); + } } } diff --git a/tests/Avalonia.UnitTests/TouchTestHelper.cs b/tests/Avalonia.UnitTests/TouchTestHelper.cs new file mode 100644 index 0000000000..db70f570a2 --- /dev/null +++ b/tests/Avalonia.UnitTests/TouchTestHelper.cs @@ -0,0 +1,53 @@ +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace Avalonia.UnitTests +{ + public class TouchTestHelper + { + private readonly Pointer _pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true); + private ulong _nextStamp = 1; + private ulong Timestamp() => _nextStamp++; + public IInputElement Captured => _pointer.Captured; + + public void Down(Interactive target, Point position = default, KeyModifiers modifiers = default) + { + Down(target, target, position, modifiers); + } + + public void Down(Interactive target, Interactive source, Point position = default, KeyModifiers modifiers = default) + { + _pointer.Capture((IInputElement)target); + source.RaiseEvent(new PointerPressedEventArgs(source, _pointer, (Visual)source, position, Timestamp(), PointerPointProperties.None, + modifiers)); + } + + public void Move(Interactive target, in Point position, KeyModifiers modifiers = default) => Move(target, target, position, modifiers); + + public void Move(Interactive target, Interactive source, in Point position, KeyModifiers modifiers = default) + { + target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, (Visual)target, position, + Timestamp(), PointerPointProperties.None, modifiers)); + } + + public void Up(Interactive target, Point position = default, KeyModifiers modifiers = default) + => Up(target, target, position, modifiers); + + public void Up(Interactive target, Interactive source, Point position = default, KeyModifiers modifiers = default) + { + source.RaiseEvent(new PointerReleasedEventArgs(source, _pointer, (Visual)target, position, Timestamp(), PointerPointProperties.None, + modifiers, MouseButton.None)); + _pointer.Capture(null); + } + + public void Tap(Interactive target, Point position = default, KeyModifiers modifiers = default) + => Tap(target, target, position, modifiers); + + public void Tap(Interactive target, Interactive source, Point position = default, KeyModifiers modifiers = default) + { + Down(target, source, position, modifiers); + Up(target, source, position, modifiers); + } + } +}