Browse Source

Merge pull request #9751 from AvaloniaUI/gestures

Add Pinch Gestures and improved other gesture recognizers
pull/9763/head
Max Katz 3 years ago
committed by GitHub
parent
commit
111a03abe8
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      samples/ControlCatalog/MainView.xaml
  2. 212
      samples/ControlCatalog/Pages/GesturePage.cs
  3. 117
      samples/ControlCatalog/Pages/GesturePage.xaml
  4. 128
      src/Avalonia.Base/Input/GestureRecognizers/PinchGestureRecognizer.cs
  5. 33
      src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs
  6. 22
      src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs
  7. 8
      src/Avalonia.Base/Input/Gestures.cs
  8. 24
      src/Avalonia.Base/Input/PinchEventArgs.cs
  9. 98
      tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs
  10. 53
      tests/Avalonia.UnitTests/TouchTestHelper.cs

3
samples/ControlCatalog/MainView.xaml

@ -92,6 +92,9 @@
<TabItem Header="Flyouts">
<pages:FlyoutsPage />
</TabItem>
<TabItem Header="Gestures">
<pages:GesturePage />
</TabItem>
<TabItem Header="Image"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Disabled">

212
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<Border>("TopPullZone"), false);
SetPullHandlers(this.Find<Border>("BottomPullZone"), true);
SetPullHandlers(this.Find<Border>("RightPullZone"), true);
SetPullHandlers(this.Find<Border>("LeftPullZone"), false);
var image = this.Find<Image>("PinchImage");
SetPinchHandlers(image);
var reset = this.Find<Button>("ResetButton");
reset!.Click += (s, e) =>
{
var compositionVisual = ElementComposition.GetElementVisual(image);
if(compositionVisual!= null)
{
_currentScale = 1;
compositionVisual.Scale = new Vector3(1,1,1);
image.InvalidateMeasure();
}
};
}
private void SetPinchHandlers(Control? control)
{
if (control == null)
{
return;
}
_currentScale = 1;
Vector3 currentOffset = default;
bool isZooming = false;
CompositionVisual? compositionVisual = null;
void InitComposition(Control visual)
{
if (compositionVisual != null)
{
return;
}
compositionVisual = ElementComposition.GetElementVisual(visual);
}
control.LayoutUpdated += (s, e) =>
{
InitComposition(control!);
if (compositionVisual != null)
{
compositionVisual.Scale = new(_currentScale, _currentScale, 1);
if(currentOffset == default)
{
currentOffset = compositionVisual.Offset;
}
}
};
control.AddHandler(Gestures.PinchEvent, (s, e) =>
{
InitComposition(control!);
isZooming = true;
if(compositionVisual != null)
{
var scale = _currentScale * (float)e.Scale;
compositionVisual.Scale = new(scale, scale, 1);
}
});
control.AddHandler(Gestures.PinchEndedEvent, (s, e) =>
{
InitComposition(control!);
isZooming = false;
if (compositionVisual != null)
{
_currentScale = compositionVisual.Scale.X;
}
});
control.AddHandler(Gestures.ScrollGestureEvent, (s, e) =>
{
InitComposition(control!);
if (compositionVisual != null && !isZooming)
{
currentOffset -= new Vector3((float)e.Delta.X, (float)e.Delta.Y, 0);
compositionVisual.Offset = currentOffset;
}
});
}
private void SetPullHandlers(Control? control, bool inverse)
{
if (control == null)
{
return;
}
var ball = control.FindLogicalDescendantOfType<Border>();
Vector3 defaultOffset = default;
CompositionVisual? ballCompositionVisual = null;
if (ball != null)
{
InitComposition(ball);
}
else
{
return;
}
control.LayoutUpdated += (s, e) =>
{
InitComposition(ball!);
if (ballCompositionVisual != null)
{
defaultOffset = ballCompositionVisual.Offset;
}
};
control.AddHandler(Gestures.PullGestureEvent, (s, e) =>
{
Vector3 center = new((float)control.Bounds.Center.X, (float)control.Bounds.Center.Y, 0);
InitComposition(ball!);
if (ballCompositionVisual != null)
{
ballCompositionVisual.Offset = defaultOffset + new System.Numerics.Vector3((float)e.Delta.X * 0.4f, (float)e.Delta.Y * 0.4f, 0) * (inverse ? -1 : 1);
}
});
control.AddHandler(Gestures.PullGestureEndedEvent, (s, e) =>
{
InitComposition(ball!);
if (ballCompositionVisual != null)
{
ballCompositionVisual.Offset = defaultOffset;
}
});
void InitComposition(Control control)
{
if (ballCompositionVisual != null)
{
return;
}
ballCompositionVisual = ElementComposition.GetElementVisual(ball);
if (ballCompositionVisual != null)
{
var offsetAnimation = ballCompositionVisual.Compositor.CreateVector3KeyFrameAnimation();
offsetAnimation.Target = "Offset";
offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue");
offsetAnimation.Duration = TimeSpan.FromMilliseconds(100);
var implicitAnimations = ballCompositionVisual.Compositor.CreateImplicitAnimationCollection();
implicitAnimations["Offset"] = offsetAnimation;
ballCompositionVisual.ImplicitAnimations = implicitAnimations;
}
}
}
}
}

117
samples/ControlCatalog/Pages/GesturePage.xaml

@ -0,0 +1,117 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
d:DesignHeight="800"
d:DesignWidth="400"
x:Class="ControlCatalog.Pages.GesturePage">
<StackPanel Orientation="Vertical"
Spacing="4">
<TextBlock FontWeight="Bold"
FontSize="18"
Margin="5">Pull Gexture (Touch / Pen)</TextBlock>
<TextBlock Margin="5">Pull from colored rectangles</TextBlock>
<Border>
<DockPanel HorizontalAlignment="Stretch"
ClipToBounds="True"
Margin="5"
Height="200">
<Border DockPanel.Dock="Top"
Margin="2"
Name="TopPullZone"
Background="Transparent"
BorderBrush="Red"
HorizontalAlignment="Stretch"
Height="50"
BorderThickness="1">
<Border.GestureRecognizers>
<PullGestureRecognizer PullDirection="TopToBottom"/>
</Border.GestureRecognizers>
<Border Width="10"
Height="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="5"
Name="TopBall"
Background="Green"/>
</Border>
<Border DockPanel.Dock="Bottom"
BorderBrush="Green"
Margin="2"
Background="Transparent"
Name="BottomPullZone"
HorizontalAlignment="Stretch"
Height="50"
BorderThickness="1">
<Border.GestureRecognizers>
<PullGestureRecognizer PullDirection="BottomToTop"/>
</Border.GestureRecognizers>
<Border Width="10"
Name="BottomBall"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Height="10"
CornerRadius="5"
Background="Green"/>
</Border>
<Border DockPanel.Dock="Right"
Margin="2"
Background="Transparent"
Name="RightPullZone"
BorderBrush="Blue"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
Width="50"
BorderThickness="1">
<Border.GestureRecognizers>
<PullGestureRecognizer PullDirection="RightToLeft"/>
</Border.GestureRecognizers>
<Border Width="10"
Height="10"
Name="RightBall"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="5"
Background="Green"/>
</Border>
<Border DockPanel.Dock="Left"
Margin="2"
Background="Transparent"
Name="LeftPullZone"
BorderBrush="Orange"
HorizontalAlignment="Left"
VerticalAlignment="Stretch"
Width="50"
BorderThickness="1">
<Border.GestureRecognizers>
<PullGestureRecognizer PullDirection="LeftToRight"/>
</Border.GestureRecognizers>
<Border Width="10"
Height="10"
Name="LeftBall"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="5"
Background="Green"/>
</Border>
</DockPanel>
</Border>
<TextBlock FontWeight="Bold"
FontSize="18"
Margin="5">Pinch/Zoom Gexture (Multi Touch)</TextBlock>
<Border ClipToBounds="True">
<Image Stretch="UniformToFill"
Margin="5"
Name="PinchImage"
Source="/Assets/delicate-arch-896885_640.jpg">
<Image.GestureRecognizers>
<PinchGestureRecognizer/>
<ScrollGestureRecognizer CanHorizontallyScroll="True" CanVerticallyScroll="True"/>
</Image.GestureRecognizers>
</Image>
</Border>
<Button HorizontalAlignment="Center" Name="ResetButton">Reset</Button>
</StackPanel>
</UserControl>

128
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;
}
}
}

33
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;
/// <summary>
/// Defines the <see cref="PullDirection"/> 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));
}

22
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);
/// <summary>
/// Defines the <see cref="ScrollStartDistance"/> property.
/// </summary>
public static readonly DirectProperty<ScrollGestureRecognizer, int> ScrollStartDistanceProperty =
AvaloniaProperty.RegisterDirect<ScrollGestureRecognizer, int>(
nameof(ScrollStartDistance),
o => o.ScrollStartDistance,
(o, v) => o.ScrollStartDistance = v);
/// <summary>
/// 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);
}
/// <summary>
/// Gets or sets a value indicating the distance the pointer moves before scrolling is started
/// </summary>
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;

8
src/Avalonia.Base/Input/Gestures.cs

@ -46,6 +46,14 @@ namespace Avalonia.Input
private static readonly WeakReference<object?> s_lastPress = new WeakReference<object?>(null);
private static Point s_lastPressPoint;
public static readonly RoutedEvent<PinchEventArgs> PinchEvent =
RoutedEvent.Register<PinchEventArgs>(
"PinchEvent", RoutingStrategies.Bubble, typeof(Gestures));
public static readonly RoutedEvent<PinchEndedEventArgs> PinchEndedEvent =
RoutedEvent.Register<PinchEndedEventArgs>(
"PinchEndedEvent", RoutingStrategies.Bubble, typeof(Gestures));
public static readonly RoutedEvent<PullGestureEventArgs> PullGestureEvent =
RoutedEvent.Register<PullGestureEventArgs>(
"PullGesture", RoutingStrategies.Bubble, typeof(Gestures));

24
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)
{
}
}
}

98
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);
}
}
}

53
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);
}
}
}
Loading…
Cancel
Save