Browse Source

Merge pull request #9652 from AvaloniaUI/hold

Add Hold gesture
pull/9819/head
Max Katz 3 years ago
committed by GitHub
parent
commit
6cfa75aaf3
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 121
      src/Avalonia.Base/Input/Gestures.cs
  2. 51
      src/Avalonia.Base/Input/HoldingRoutedEventArgs.cs
  3. 16
      src/Avalonia.Base/Input/InputElement.cs
  4. 2
      src/Avalonia.Base/Platform/DefaultPlatformSettings.cs
  5. 2
      src/Avalonia.Base/Platform/IPlatformSettings.cs
  6. 13
      src/Avalonia.Controls/Control.cs
  7. 2
      src/Windows/Avalonia.Win32/Win32Platform.cs
  8. 240
      tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs
  9. 2
      tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs

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

@ -1,6 +1,8 @@
using System;
using System.Threading;
using Avalonia.Interactivity;
using Avalonia.Platform;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace Avalonia.Input
@ -8,6 +10,21 @@ namespace Avalonia.Input
public static class Gestures
{
private static bool s_isDoubleTapped = false;
private static bool s_isHolding;
private static CancellationTokenSource? s_holdCancellationToken;
/// <summary>
/// Defines the IsHoldingEnabled attached property.
/// </summary>
public static readonly AttachedProperty<bool> IsHoldingEnabledProperty =
AvaloniaProperty.RegisterAttached<StyledElement, bool>("IsHoldingEnabled", typeof(Gestures), true);
/// <summary>
/// Defines the IsHoldWithMouseEnabled attached property.
/// </summary>
public static readonly AttachedProperty<bool> IsHoldWithMouseEnabledProperty =
AvaloniaProperty.RegisterAttached<StyledElement, bool>("IsHoldWithMouseEnabled", typeof(Gestures), false);
public static readonly RoutedEvent<TappedEventArgs> TappedEvent = RoutedEvent.Register<TappedEventArgs>(
"Tapped",
RoutingStrategies.Bubble,
@ -45,6 +62,7 @@ namespace Avalonia.Input
private static readonly WeakReference<object?> s_lastPress = new WeakReference<object?>(null);
private static Point s_lastPressPoint;
private static IPointer? s_lastPointer;
public static readonly RoutedEvent<PinchEventArgs> PinchEvent =
RoutedEvent.Register<PinchEventArgs>(
@ -58,14 +76,40 @@ namespace Avalonia.Input
RoutedEvent.Register<PullGestureEventArgs>(
"PullGesture", RoutingStrategies.Bubble, typeof(Gestures));
/// <summary>
/// Occurs when a user performs a press and hold gesture (with a single touch, mouse, or pen/stylus contact).
/// </summary>
public static readonly RoutedEvent<HoldingRoutedEventArgs> HoldingEvent =
RoutedEvent.Register<HoldingRoutedEventArgs>(
"Holding", RoutingStrategies.Bubble, typeof(Gestures));
public static readonly RoutedEvent<PullGestureEndedEventArgs> PullGestureEndedEvent =
RoutedEvent.Register<PullGestureEndedEventArgs>(
"PullGestureEnded", RoutingStrategies.Bubble, typeof(Gestures));
public static bool GetIsHoldingEnabled(StyledElement element)
{
return element.GetValue(IsHoldingEnabledProperty);
}
public static void SetIsHoldingEnabled(StyledElement element, bool value)
{
element.SetValue(IsHoldingEnabledProperty, value);
}
public static bool GetIsHoldWithMouseEnabled(StyledElement element)
{
return element.GetValue(IsHoldWithMouseEnabledProperty);
}
public static void SetIsHoldWithMouseEnabled(StyledElement element, bool value)
{
element.SetValue(IsHoldWithMouseEnabledProperty, value);
}
static Gestures()
{
InputElement.PointerPressedEvent.RouteFinished.Subscribe(PointerPressed);
InputElement.PointerReleasedEvent.RouteFinished.Subscribe(PointerReleased);
InputElement.PointerMovedEvent.RouteFinished.Subscribe(PointerMoved);
}
public static void AddTappedHandler(Interactive element, EventHandler<RoutedEventArgs> handler)
@ -110,11 +154,42 @@ namespace Avalonia.Input
var e = (PointerPressedEventArgs)ev;
var visual = (Visual)ev.Source;
if(s_lastPointer != null)
{
if(s_isHolding && ev.Source is Interactive i)
{
i.RaiseEvent(new HoldingRoutedEventArgs(HoldingState.Cancelled, s_lastPressPoint, s_lastPointer.Type));
}
s_holdCancellationToken?.Cancel();
s_holdCancellationToken?.Dispose();
s_holdCancellationToken = null;
s_lastPointer = null;
}
s_isHolding = false;
if (e.ClickCount % 2 == 1)
{
s_isDoubleTapped = false;
s_lastPress.SetTarget(ev.Source);
s_lastPointer = e.Pointer;
s_lastPressPoint = e.GetPosition((Visual)ev.Source);
s_holdCancellationToken = new CancellationTokenSource();
var token = s_holdCancellationToken.Token;
var settings = AvaloniaLocator.Current.GetService<IPlatformSettings>();
if (settings != null)
{
DispatcherTimer.RunOnce(() =>
{
if (!token.IsCancellationRequested && e.Source is InputElement i && GetIsHoldingEnabled(i) && (e.Pointer.Type != PointerType.Mouse || GetIsHoldWithMouseEnabled(i)))
{
s_isHolding = true;
i.RaiseEvent(new HoldingRoutedEventArgs(HoldingState.Started, s_lastPressPoint, s_lastPointer.Type));
}
}, settings.HoldWaitDuration);
}
}
else if (e.ClickCount % 2 == 0 && e.GetCurrentPoint(visual).Properties.IsLeftButtonPressed)
{
@ -148,7 +223,12 @@ namespace Avalonia.Input
if (tapRect.ContainsExclusive(point.Position))
{
if (e.InitialPressMouseButton == MouseButton.Right)
if(s_isHolding)
{
s_isHolding = false;
i.RaiseEvent(new HoldingRoutedEventArgs(HoldingState.Completed, s_lastPressPoint, s_lastPointer!.Type));
}
else if (e.InitialPressMouseButton == MouseButton.Right)
{
i.RaiseEvent(new TappedEventArgs(RightTappedEvent, e));
}
@ -160,6 +240,45 @@ namespace Avalonia.Input
}
}
}
s_holdCancellationToken?.Cancel();
s_holdCancellationToken?.Dispose();
s_holdCancellationToken = null;
s_lastPointer = null;
}
}
private static void PointerMoved(RoutedEventArgs ev)
{
if (ev.Route == RoutingStrategies.Bubble)
{
var e = (PointerEventArgs)ev;
if (s_lastPress.TryGetTarget(out var target))
{
if (e.Pointer == s_lastPointer)
{
var point = e.GetCurrentPoint((Visual)target);
var settings = AvaloniaLocator.Current.GetService<IPlatformSettings>();
var tapSize = settings?.GetTapSize(point.Pointer.Type) ?? new Size(4, 4);
var tapRect = new Rect(s_lastPressPoint, new Size())
.Inflate(new Thickness(tapSize.Width, tapSize.Height));
if (tapRect.ContainsExclusive(point.Position))
{
return;
}
if (s_isHolding && ev.Source is Interactive i)
{
i.RaiseEvent(new HoldingRoutedEventArgs(HoldingState.Cancelled, s_lastPressPoint, s_lastPointer!.Type));
}
}
}
s_holdCancellationToken?.Cancel();
s_holdCancellationToken?.Dispose();
s_holdCancellationToken = null;
s_isHolding = false;
}
}
}

51
src/Avalonia.Base/Input/HoldingRoutedEventArgs.cs

@ -0,0 +1,51 @@
using System;
using Avalonia.Interactivity;
namespace Avalonia.Input
{
public class HoldingRoutedEventArgs : RoutedEventArgs
{
/// <summary>
/// Gets the state of the <see cref="Gestures.HoldingEvent"/> event.
/// </summary>
public HoldingState HoldingState { get; }
/// <summary>
/// Gets the location of the touch, mouse, or pen/stylus contact.
/// </summary>
public Point Position { get; }
/// <summary>
/// Gets the pointer type of the input source.
/// </summary>
public PointerType PointerType { get; }
/// <summary>
/// Initializes a new instance of the <see cref="HoldingRoutedEventArgs"/> class.
/// </summary>
public HoldingRoutedEventArgs(HoldingState holdingState, Point position, PointerType pointerType) : base(Gestures.HoldingEvent)
{
HoldingState = holdingState;
Position = position;
PointerType = pointerType;
}
}
public enum HoldingState
{
/// <summary>
/// A single contact has been detected and a time threshold is crossed without the contact being lifted, another contact detected, or another gesture started.
/// </summary>
Started,
/// <summary>
/// The single contact is lifted.
/// </summary>
Completed,
/// <summary>
/// An additional contact is detected or a subsequent gesture (such as a slide) is detected.
/// </summary>
Cancelled,
}
}

16
src/Avalonia.Base/Input/InputElement.cs

@ -188,11 +188,16 @@ namespace Avalonia.Input
/// </summary>
public static readonly RoutedEvent<TappedEventArgs> TappedEvent = Gestures.TappedEvent;
/// <summary>
/// Defines the <see cref="Holding"/> event.
/// </summary>
public static readonly RoutedEvent<HoldingRoutedEventArgs> HoldingEvent = Gestures.HoldingEvent;
/// <summary>
/// Defines the <see cref="DoubleTapped"/> event.
/// </summary>
public static readonly RoutedEvent<TappedEventArgs> DoubleTappedEvent = Gestures.DoubleTappedEvent;
private bool _isEffectivelyEnabled = true;
private bool _isFocused;
private bool _isKeyboardFocusWithin;
@ -352,6 +357,15 @@ namespace Avalonia.Input
add { AddHandler(TappedEvent, value); }
remove { RemoveHandler(TappedEvent, value); }
}
/// <summary>
/// Occurs when a hold gesture occurs on the control.
/// </summary>
public event EventHandler<HoldingRoutedEventArgs>? Holding
{
add { AddHandler(HoldingEvent, value); }
remove { RemoveHandler(HoldingEvent, value); }
}
/// <summary>
/// Occurs when a double-tap gesture occurs on the control.

2
src/Avalonia.Base/Platform/DefaultPlatformSettings.cs

@ -26,5 +26,7 @@ namespace Avalonia.Platform
};
}
public TimeSpan GetDoubleTapTime(PointerType type) => TimeSpan.FromMilliseconds(500);
public TimeSpan HoldWaitDuration { get; set; } = TimeSpan.FromMilliseconds(300);
}
}

2
src/Avalonia.Base/Platform/IPlatformSettings.cs

@ -27,5 +27,7 @@ namespace Avalonia.Platform
/// tap gesture.
/// </summary>
TimeSpan GetDoubleTapTime(PointerType type);
TimeSpan HoldWaitDuration { get; set; }
}
}

13
src/Avalonia.Controls/Control.cs

@ -366,16 +366,29 @@ namespace Avalonia.Controls
{
base.OnAttachedToVisualTreeCore(e);
AddHandler(Gestures.HoldingEvent, OnHoldEvent);
InitializeIfNeeded();
ScheduleOnLoadedCore();
}
private void OnHoldEvent(object? sender, HoldingRoutedEventArgs e)
{
if(e.HoldingState == HoldingState.Started)
{
// Trigger ContentRequest when hold has started
RaiseEvent(new ContextRequestedEventArgs());
}
}
/// <inheritdoc/>
protected sealed override void OnDetachedFromVisualTreeCore(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTreeCore(e);
RemoveHandler(Gestures.HoldingEvent, OnHoldEvent);
OnUnloadedCore();
}

2
src/Windows/Avalonia.Win32/Win32Platform.cs

@ -258,6 +258,8 @@ namespace Avalonia.Win32
public bool CurrentThreadIsLoopThread => _uiThread == Thread.CurrentThread;
public TimeSpan HoldWaitDuration { get; set; } = TimeSpan.FromMilliseconds(300);
public event Action<DispatcherPriority?> Signaled;
public event EventHandler<ShutdownRequestedEventArgs> ShutdownRequested;

240
tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs

@ -1,9 +1,13 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Threading;
using Avalonia.UnitTests;
using Moq;
using Xunit;
namespace Avalonia.Base.UnitTests.Input
@ -169,6 +173,242 @@ namespace Avalonia.Base.UnitTests.Input
Assert.False(raised);
}
[Fact]
public void Hold_Should_Be_Raised_After_Hold_Duration()
{
using var scope = AvaloniaLocator.EnterScope();
var iSettingsMock = new Mock<IPlatformSettings>();
iSettingsMock.Setup(x => x.HoldWaitDuration).Returns(TimeSpan.FromMilliseconds(300));
iSettingsMock.Setup(x => x.GetTapSize(It.IsAny<PointerType>())).Returns(new Size(16, 16));
AvaloniaLocator.CurrentMutable.BindToSelf(this)
.Bind<IPlatformSettings>().ToConstant(iSettingsMock.Object);
var scheduledTimers = new List<(TimeSpan time, Action action)>();
using var app = UnitTestApplication.Start(new TestServices(
threadingInterface: CreatePlatformThreadingInterface(t => scheduledTimers.Add(t))));
Border border = new Border();
Gestures.SetIsHoldWithMouseEnabled(border, true);
var decorator = new Decorator
{
Child = border
};
HoldingState holding = HoldingState.Cancelled;
decorator.AddHandler(Gestures.HoldingEvent, (s, e) => holding = e.HoldingState);
_mouse.Down(border);
Assert.False(holding != HoldingState.Cancelled);
// Verify timer duration, but execute it immediately.
var timer = Assert.Single(scheduledTimers);
Assert.Equal(iSettingsMock.Object.HoldWaitDuration, timer.time);
timer.action();
Assert.True(holding == HoldingState.Started);
_mouse.Up(border);
Assert.True(holding == HoldingState.Completed);
}
[Fact]
public void Hold_Should_Not_Raised_When_Pointer_Released_Before_Timer()
{
using var scope = AvaloniaLocator.EnterScope();
var iSettingsMock = new Mock<IPlatformSettings>();
iSettingsMock.Setup(x => x.HoldWaitDuration).Returns(TimeSpan.FromMilliseconds(300));
AvaloniaLocator.CurrentMutable.BindToSelf(this)
.Bind<IPlatformSettings>().ToConstant(iSettingsMock.Object);
var scheduledTimers = new List<(TimeSpan time, Action action)>();
using var app = UnitTestApplication.Start(new TestServices(
threadingInterface: CreatePlatformThreadingInterface(t => scheduledTimers.Add(t))));
Border border = new Border();
Gestures.SetIsHoldWithMouseEnabled(border, true);
var decorator = new Decorator
{
Child = border
};
var raised = false;
decorator.AddHandler(Gestures.HoldingEvent, (s, e) => raised = e.HoldingState == HoldingState.Started);
_mouse.Down(border);
Assert.False(raised);
_mouse.Up(border);
Assert.False(raised);
// Verify timer duration, but execute it immediately.
var timer = Assert.Single(scheduledTimers);
Assert.Equal(iSettingsMock.Object.HoldWaitDuration, timer.time);
timer.action();
Assert.False(raised);
}
[Fact]
public void Hold_Should_Not_Raised_When_Pointer_Is_Moved_Before_Timer()
{
using var scope = AvaloniaLocator.EnterScope();
var iSettingsMock = new Mock<IPlatformSettings>();
iSettingsMock.Setup(x => x.HoldWaitDuration).Returns(TimeSpan.FromMilliseconds(300));
AvaloniaLocator.CurrentMutable.BindToSelf(this)
.Bind<IPlatformSettings>().ToConstant(iSettingsMock.Object);
var scheduledTimers = new List<(TimeSpan time, Action action)>();
using var app = UnitTestApplication.Start(new TestServices(
threadingInterface: CreatePlatformThreadingInterface(t => scheduledTimers.Add(t))));
Border border = new Border();
Gestures.SetIsHoldWithMouseEnabled(border, true);
var decorator = new Decorator
{
Child = border
};
var raised = false;
decorator.AddHandler(Gestures.HoldingEvent, (s, e) => raised = e.HoldingState == HoldingState.Completed);
_mouse.Down(border);
Assert.False(raised);
_mouse.Move(border, position: new Point(20, 20));
Assert.False(raised);
// Verify timer duration, but execute it immediately.
var timer = Assert.Single(scheduledTimers);
Assert.Equal(iSettingsMock.Object.HoldWaitDuration, timer.time);
timer.action();
Assert.False(raised);
}
[Fact]
public void Hold_Should_Be_Cancelled_When_Second_Contact_Is_Detected()
{
using var scope = AvaloniaLocator.EnterScope();
var iSettingsMock = new Mock<IPlatformSettings>();
iSettingsMock.Setup(x => x.HoldWaitDuration).Returns(TimeSpan.FromMilliseconds(300));
AvaloniaLocator.CurrentMutable.BindToSelf(this)
.Bind<IPlatformSettings>().ToConstant(iSettingsMock.Object);
var scheduledTimers = new List<(TimeSpan time, Action action)>();
using var app = UnitTestApplication.Start(new TestServices(
threadingInterface: CreatePlatformThreadingInterface(t => scheduledTimers.Add(t))));
Border border = new Border();
Gestures.SetIsHoldWithMouseEnabled(border, true);
var decorator = new Decorator
{
Child = border
};
var cancelled = false;
decorator.AddHandler(Gestures.HoldingEvent, (s, e) => cancelled = e.HoldingState == HoldingState.Cancelled);
_mouse.Down(border);
Assert.False(cancelled);
var timer = Assert.Single(scheduledTimers);
Assert.Equal(iSettingsMock.Object.HoldWaitDuration, timer.time);
timer.action();
var secondMouse = new MouseTestHelper();
secondMouse.Down(border);
Assert.True(cancelled);
}
[Fact]
public void Hold_Should_Be_Cancelled_When_Pointer_Moves_Too_Far()
{
using var scope = AvaloniaLocator.EnterScope();
var iSettingsMock = new Mock<IPlatformSettings>();
iSettingsMock.Setup(x => x.HoldWaitDuration).Returns(TimeSpan.FromMilliseconds(300));
iSettingsMock.Setup(x => x.GetTapSize(It.IsAny<PointerType>())).Returns(new Size(16, 16));
AvaloniaLocator.CurrentMutable.BindToSelf(this)
.Bind<IPlatformSettings>().ToConstant(iSettingsMock.Object);
var scheduledTimers = new List<(TimeSpan time, Action action)>();
using var app = UnitTestApplication.Start(new TestServices(
threadingInterface: CreatePlatformThreadingInterface(t => scheduledTimers.Add(t))));
Border border = new Border();
Gestures.SetIsHoldWithMouseEnabled(border, true);
var decorator = new Decorator
{
Child = border
};
var cancelled = false;
decorator.AddHandler(Gestures.HoldingEvent, (s, e) => cancelled = e.HoldingState == HoldingState.Cancelled);
_mouse.Down(border);
var timer = Assert.Single(scheduledTimers);
Assert.Equal(iSettingsMock.Object.HoldWaitDuration, timer.time);
timer.action();
_mouse.Move(border, position: new Point(3, 3));
Assert.False(cancelled);
_mouse.Move(border, position: new Point(20, 20));
Assert.True(cancelled);
}
[Fact]
public void Hold_Should_Not_Be_Raised_For_Multiple_Contacts()
{
using var scope = AvaloniaLocator.EnterScope();
var iSettingsMock = new Mock<IPlatformSettings>();
iSettingsMock.Setup(x => x.HoldWaitDuration).Returns(TimeSpan.FromMilliseconds(300));
AvaloniaLocator.CurrentMutable.BindToSelf(this)
.Bind<IPlatformSettings>().ToConstant(iSettingsMock.Object);
var scheduledTimers = new List<(TimeSpan time, Action action)>();
using var app = UnitTestApplication.Start(new TestServices(
threadingInterface: CreatePlatformThreadingInterface(t => scheduledTimers.Add(t))));
Border border = new Border();
Gestures.SetIsHoldWithMouseEnabled(border, true);
var decorator = new Decorator
{
Child = border
};
var raised = false;
decorator.AddHandler(Gestures.HoldingEvent, (s, e) => raised = e.HoldingState == HoldingState.Completed);
var secondMouse = new MouseTestHelper();
_mouse.Down(border, MouseButton.Left);
// Verify timer duration, but execute it immediately.
var timer = Assert.Single(scheduledTimers);
Assert.Equal(iSettingsMock.Object.HoldWaitDuration, timer.time);
timer.action();
secondMouse.Down(border, MouseButton.Left);
Assert.False(raised);
}
private static IPlatformThreadingInterface CreatePlatformThreadingInterface(Action<(TimeSpan, Action)> callback)
{
var threadingInterface = new Mock<IPlatformThreadingInterface>();
threadingInterface.SetupGet(p => p.CurrentThreadIsLoopThread).Returns(true);
threadingInterface.Setup(p => p
.StartTimer(It.IsAny<DispatcherPriority>(), It.IsAny<TimeSpan>(), It.IsAny<Action>()))
.Callback<DispatcherPriority, TimeSpan, Action>((_, t, a) => callback((t, a)));
return threadingInterface.Object;
}
private static void AddHandlers(
Decorator decorator,
Border border,

2
tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs

@ -207,7 +207,7 @@ namespace Avalonia.Input.UnitTests
private IDisposable UnitTestApp(TimeSpan doubleClickTime = new TimeSpan())
{
var unitTestApp = UnitTestApplication.Start(
new TestServices(inputManager: new InputManager()));
new TestServices(inputManager: new InputManager(), threadingInterface: Mock.Of<IPlatformThreadingInterface>(x => x.CurrentThreadIsLoopThread == true)));
var iSettingsMock = new Mock<IPlatformSettings>();
iSettingsMock.Setup(x => x.GetDoubleTapTime(It.IsAny<PointerType>())).Returns(doubleClickTime);
iSettingsMock.Setup(x => x.GetDoubleTapSize(It.IsAny<PointerType>())).Returns(new Size(16, 16));

Loading…
Cancel
Save