Browse Source
Added gesture-recognizer for scroll-viewer to improve usability Added possibility to enable pulltorefresh on desktop toopull/18617/head
committed by
Alexander Marek
13 changed files with 694 additions and 86 deletions
@ -0,0 +1,158 @@ |
|||
using System; |
|||
using Avalonia.Controls.Primitives; |
|||
using Avalonia.Input; |
|||
using Avalonia.Input.GestureRecognizers; |
|||
|
|||
namespace Avalonia.Controls.PullToRefresh |
|||
{ |
|||
internal class ScrollablePullGestureRecognizer : GestureRecognizer |
|||
{ |
|||
private int _gestureId; |
|||
private bool _pullInProgress; |
|||
|
|||
private double _delta = 1; |
|||
|
|||
private Point _initialPosition; |
|||
private IPointer? _tracking; |
|||
|
|||
/// <summary>
|
|||
/// Defines the <see cref="PullDirection"/> property.
|
|||
/// </summary>
|
|||
public static readonly StyledProperty<PullDirection> PullDirectionProperty = |
|||
AvaloniaProperty.Register<ScrollablePullGestureRecognizer, PullDirection>(nameof(PullDirection)); |
|||
|
|||
public PullDirection PullDirection |
|||
{ |
|||
get => GetValue(PullDirectionProperty); |
|||
set => SetValue(PullDirectionProperty, value); |
|||
} |
|||
|
|||
public bool IsMouseEnabled { get; set; } |
|||
|
|||
public ScrollablePullGestureRecognizer(PullDirection pullDirection, bool isMouseEnabled) |
|||
{ |
|||
PullDirection = pullDirection; |
|||
IsMouseEnabled = isMouseEnabled; |
|||
} |
|||
|
|||
public ScrollablePullGestureRecognizer() { } |
|||
|
|||
protected override void PointerCaptureLost(IPointer pointer) |
|||
{ |
|||
if (_tracking == pointer) |
|||
{ |
|||
EndPull(); |
|||
} |
|||
|
|||
// PointerReleased clears these fields; PointerCaptureLost must do the same,
|
|||
// otherwise the next gesture re-enters PointerMoved with _pullInProgress=true
|
|||
// and reuses the just-ended _gestureId for a new PullGestureEvent.
|
|||
_tracking = null; |
|||
_initialPosition = default; |
|||
_pullInProgress = false; |
|||
} |
|||
|
|||
protected override void PointerPressed(PointerPressedEventArgs e) |
|||
{ |
|||
var isEnabledOnPlatform = (e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen) // either it is a touch device
|
|||
|| IsMouseEnabled; // or desktop is enabled
|
|||
|
|||
if (Target != null && Target is Visual visual && isEnabledOnPlatform) |
|||
{ |
|||
_tracking = e.Pointer; |
|||
_initialPosition = e.GetPosition(visual); |
|||
} |
|||
} |
|||
|
|||
protected override void PointerMoved(PointerEventArgs e) |
|||
{ |
|||
if (_tracking != e.Pointer) |
|||
return; |
|||
|
|||
if (Target is Visual visual && visual is IScrollable scrollable && CanPull(scrollable)) |
|||
{ |
|||
var currentPosition = e.GetPosition(visual); |
|||
|
|||
var delta = CalculateDelta(currentPosition); |
|||
|
|||
bool pulling = delta.Y > 0 || delta.X > 0; |
|||
_pullInProgress = (_pullInProgress, pulling) switch |
|||
{ |
|||
(false, false) => false, |
|||
(false, true) => BeginPull(e, delta), |
|||
(true, true) => HandlePull(e, delta), |
|||
(true, false) => EndPull(), |
|||
}; |
|||
} |
|||
} |
|||
|
|||
protected override void PointerReleased(PointerReleasedEventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
if (_pullInProgress == true) |
|||
{ |
|||
EndPull(); |
|||
} |
|||
} |
|||
finally |
|||
{ |
|||
// HandlePull captures the pointer on every PointerMoved with positive delta.
|
|||
// The (true, false) -> EndPull transition in PointerMoved clears
|
|||
// _pullInProgress without releasing capture, so by the time we get here
|
|||
// the gesture is no longer in progress but the pointer can still be
|
|||
// captured by this recognizer. Always release capture so the next
|
|||
// gesture starts from a clean state.
|
|||
if (_tracking != null) |
|||
{ |
|||
e.Pointer.Capture(null); |
|||
} |
|||
|
|||
_tracking = null; |
|||
_initialPosition = default; |
|||
_pullInProgress = false; |
|||
} |
|||
} |
|||
|
|||
private bool BeginPull(PointerEventArgs e, Vector delta) |
|||
{ |
|||
_gestureId = PullGestureEventArgs.GetNextFreeId(); |
|||
return HandlePull(e, delta); |
|||
} |
|||
|
|||
private bool HandlePull(PointerEventArgs e, Vector delta) |
|||
{ |
|||
Capture(e.Pointer); |
|||
|
|||
var pullEventArgs = new PullGestureEventArgs(_gestureId, delta, PullDirection); |
|||
Target?.RaiseEvent(pullEventArgs); |
|||
|
|||
e.Handled = pullEventArgs.Handled; |
|||
return true; |
|||
} |
|||
|
|||
private bool EndPull() |
|||
{ |
|||
Target?.RaiseEvent(new PullGestureEndedEventArgs(_gestureId, PullDirection)); |
|||
return false; |
|||
} |
|||
|
|||
private Vector CalculateDelta(Point currentPosition) => PullDirection switch |
|||
{ |
|||
PullDirection.TopToBottom => new Vector(0, currentPosition.Y - _initialPosition.Y), |
|||
PullDirection.BottomToTop => new Vector(0, _initialPosition.Y - currentPosition.Y), |
|||
PullDirection.LeftToRight => new Vector(currentPosition.X - _initialPosition.X, 0), |
|||
PullDirection.RightToLeft => new Vector(_initialPosition.X - currentPosition.X, 0), |
|||
_ => default, |
|||
}; |
|||
|
|||
private bool CanPull(IScrollable scrollable) => PullDirection switch |
|||
{ |
|||
PullDirection.TopToBottom => scrollable.Offset.Y < _delta, |
|||
PullDirection.BottomToTop => Math.Abs(scrollable.Offset.Y + scrollable.Viewport.Height - scrollable.Extent.Height) <= _delta, |
|||
PullDirection.LeftToRight => scrollable.Offset.X < _delta, |
|||
PullDirection.RightToLeft => Math.Abs(scrollable.Offset.X + scrollable.Viewport.Width - scrollable.Extent.Width) <= _delta, |
|||
_ => false, |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
using Avalonia.Controls.PullToRefresh; |
|||
using Avalonia.Input; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Controls.UnitTests.PullToRefresh |
|||
{ |
|||
public class RefreshInfoProviderTests : ScopedTestBase |
|||
{ |
|||
// Repro for the "_entered desync" bug.
|
|||
//
|
|||
// Real-world flow that triggers it:
|
|||
// 1. User starts pulling. ScrollablePullGestureRecognizer raises PullGestureEvent
|
|||
// -> InteractingStateEntered sets IsInteractingForRefresh = true
|
|||
// and _entered = true.
|
|||
// 2. The pull motion produces a small ScrollChanged that pushes the scroll offset
|
|||
// past the threshold. ScrollViewerIRefreshInfoProviderAdapter.ScrollViewer_ScrollChanged
|
|||
// writes IsInteractingForRefresh = false directly, bypassing PullGestureEnded.
|
|||
// (ScrollViewer_PointerReleased does the same.)
|
|||
// 3. _entered stays true.
|
|||
// 4. The user is still pulling, more PullGestureEvents arrive.
|
|||
// InteractingStateEntered short-circuits because _entered is already true,
|
|||
// so IsInteractingForRefresh is NOT reasserted.
|
|||
// 5. RefreshVisualizer never re-enters the Interacting state -> spinner does not appear.
|
|||
[Fact] |
|||
public void IsInteractingForRefresh_is_reasserted_after_being_cleared_externally() |
|||
{ |
|||
var provider = new RefreshInfoProvider( |
|||
PullDirection.TopToBottom, |
|||
new Size(100, 100), |
|||
visual: null); |
|||
|
|||
var pullArgs = new PullGestureEventArgs(0, new Vector(0, 50), PullDirection.TopToBottom); |
|||
|
|||
// 1. First PullGestureEvent of a gesture
|
|||
provider.InteractingStateEntered(this, pullArgs); |
|||
Assert.True(provider.IsInteractingForRefresh, |
|||
"IsInteractingForRefresh should be true after the first PullGestureEvent"); |
|||
|
|||
// 2. Adapter clears the flag directly (simulating ScrollViewer_ScrollChanged
|
|||
// or ScrollViewer_PointerReleased)
|
|||
provider.IsInteractingForRefresh = false; |
|||
Assert.False(provider.IsInteractingForRefresh); |
|||
|
|||
// 3. Pull is still in progress, the next PullGestureEvent arrives
|
|||
provider.InteractingStateEntered(this, pullArgs); |
|||
|
|||
// BUG: stays false because _entered short-circuits the assignment.
|
|||
// After the fix this assertion must pass.
|
|||
Assert.True(provider.IsInteractingForRefresh, |
|||
"PullGestureEvent must re-assert IsInteractingForRefresh after it was cleared by something other than PullGestureEnded"); |
|||
} |
|||
|
|||
// Repro for the typo where horizontal pulls checked Height==0 instead of Width==0.
|
|||
// With Width==0, value.X / Width produces +Infinity / NaN, which then breaks every
|
|||
// downstream consumer of InteractionRatio (Math.Min(1, NaN) returns NaN).
|
|||
[Fact] |
|||
public void Horizontal_pull_with_zero_width_produces_safe_InteractionRatio() |
|||
{ |
|||
var provider = new RefreshInfoProvider( |
|||
PullDirection.LeftToRight, |
|||
new Size(0, 100), |
|||
visual: null); |
|||
|
|||
provider.ValuesChanged(new Vector(50, 0)); |
|||
|
|||
Assert.False(double.IsNaN(provider.InteractionRatio)); |
|||
Assert.False(double.IsInfinity(provider.InteractionRatio)); |
|||
} |
|||
|
|||
// Sanity check for the existing happy-path: a complete gesture lifecycle
|
|||
// (Entered -> Exited -> Entered) must toggle IsInteractingForRefresh correctly.
|
|||
[Fact] |
|||
public void Normal_gesture_lifecycle_toggles_IsInteractingForRefresh_correctly() |
|||
{ |
|||
var provider = new RefreshInfoProvider( |
|||
PullDirection.TopToBottom, |
|||
new Size(100, 100), |
|||
visual: null); |
|||
|
|||
var pullArgs = new PullGestureEventArgs(0, new Vector(0, 50), PullDirection.TopToBottom); |
|||
var endArgs = new PullGestureEndedEventArgs(0, PullDirection.TopToBottom); |
|||
|
|||
provider.InteractingStateEntered(this, pullArgs); |
|||
Assert.True(provider.IsInteractingForRefresh); |
|||
|
|||
provider.InteractingStateExited(this, endArgs); |
|||
Assert.False(provider.IsInteractingForRefresh); |
|||
|
|||
// Next gesture should work
|
|||
provider.InteractingStateEntered(this, pullArgs); |
|||
Assert.True(provider.IsInteractingForRefresh); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
using System.Linq; |
|||
using System.Reflection; |
|||
using Avalonia.Controls.PullToRefresh; |
|||
using Avalonia.Controls.Templates; |
|||
using Avalonia.Input; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Controls.UnitTests.PullToRefresh |
|||
{ |
|||
public class ScrollViewerIRefreshInfoProviderAdapterTests : ScopedTestBase |
|||
{ |
|||
// Repro for the "gesture recognizer leaked across Adapt() calls" bug.
|
|||
//
|
|||
// Adapt() cleans up the previous ScrollViewer's pointer handlers and the previous
|
|||
// RefreshInfoProvider's pull-event handlers, but never removes the previously-created
|
|||
// ScrollablePullGestureRecognizer from _interactionSource.GestureRecognizers.
|
|||
// Each subsequent Adapt() instantiates and adds a new recognizer, so the input element
|
|||
// ends up holding N recognizers after N calls. They all listen for the same pointer
|
|||
// events and raise duplicate PullGesture/PullGestureEnded pairs, which (combined with
|
|||
// the _entered desync fix in RefreshInfoProvider) corrupts the visualizer state.
|
|||
[Fact] |
|||
public void Adapt_called_twice_does_not_leak_pull_gesture_recognizer() |
|||
{ |
|||
var sv = new ScrollViewer |
|||
{ |
|||
Template = new FuncControlTemplate<ScrollViewer>(ScrollViewerTests.CreateTemplate), |
|||
Content = new Border(), |
|||
}; |
|||
|
|||
// Wrap in a TestRoot and execute the layout pass so Loaded fires and the
|
|||
// visual tree under the ScrollContentPresenter is fully wired up
|
|||
// (otherwise the adapter never reaches MakeInteractionSource).
|
|||
var root = new TestRoot(sv); |
|||
root.LayoutManager.ExecuteInitialLayoutPass(); |
|||
|
|||
var adapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection.TopToBottom, isMouseEnabled: false); |
|||
|
|||
adapter.Adapt(sv, new Size(100, 100)); |
|||
var interactionSource = GetInteractionSource(adapter); |
|||
Assert.NotNull(interactionSource); |
|||
var afterFirst = interactionSource.GestureRecognizers.OfType<ScrollablePullGestureRecognizer>().Count(); |
|||
Assert.Equal(1, afterFirst); |
|||
|
|||
adapter.Adapt(sv, new Size(100, 100)); |
|||
var interactionSourceAfter = GetInteractionSource(adapter); |
|||
Assert.NotNull(interactionSourceAfter); |
|||
var afterSecond = interactionSourceAfter.GestureRecognizers.OfType<ScrollablePullGestureRecognizer>().Count(); |
|||
Assert.Equal(1, afterSecond); |
|||
} |
|||
|
|||
private static InputElement GetInteractionSource(ScrollViewerIRefreshInfoProviderAdapter adapter) |
|||
{ |
|||
var field = typeof(ScrollViewerIRefreshInfoProviderAdapter) |
|||
.GetField("_interactionSource", BindingFlags.Instance | BindingFlags.NonPublic); |
|||
return (InputElement)field!.GetValue(adapter)!; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
using System.Reflection; |
|||
using Avalonia.Controls.PullToRefresh; |
|||
using Avalonia.Input; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Controls.UnitTests.PullToRefresh |
|||
{ |
|||
public class ScrollablePullGestureRecognizerTests : ScopedTestBase |
|||
{ |
|||
// Repro for the "PointerCaptureLost doesn't clean up state" bug.
|
|||
//
|
|||
// PointerReleased clears _pullInProgress, _tracking, _initialPosition.
|
|||
// PointerCaptureLost only raises EndPull (so the PullGestureEnded event fires)
|
|||
// but leaves _pullInProgress == true and _tracking pointing at the lost pointer.
|
|||
// The next gesture re-enters PointerMoved with (_pullInProgress=true, pulling=true)
|
|||
// and goes straight to HandlePull, skipping BeginPull. This reuses the OLD _gestureId
|
|||
// for the next PullGestureEvent - the same id that was just used in the
|
|||
// PullGestureEndedEvent for the previous gesture.
|
|||
[Fact] |
|||
public void PointerCaptureLost_resets_recognizer_state_like_PointerReleased() |
|||
{ |
|||
var recognizer = new ScrollablePullGestureRecognizer(PullDirection.TopToBottom, isMouseEnabled: true); |
|||
|
|||
var pullInProgressField = GetField("_pullInProgress"); |
|||
var trackingField = GetField("_tracking"); |
|||
var initialPositionField = GetField("_initialPosition"); |
|||
|
|||
// Simulate the recognizer being mid-pull with a tracked pointer
|
|||
var pointer = new Avalonia.Input.Pointer(Avalonia.Input.Pointer.GetNextFreeId(), PointerType.Touch, isPrimary: true); |
|||
pullInProgressField.SetValue(recognizer, true); |
|||
trackingField.SetValue(recognizer, pointer); |
|||
initialPositionField.SetValue(recognizer, new Point(10, 20)); |
|||
|
|||
// Capture is lost (e.g. another control steals it, or the visual is detached mid-gesture)
|
|||
InvokePointerCaptureLost(recognizer, pointer); |
|||
|
|||
// The recognizer must be in a fully-clean state, like after PointerReleased.
|
|||
Assert.False((bool)pullInProgressField.GetValue(recognizer)!, |
|||
"_pullInProgress must be cleared after PointerCaptureLost"); |
|||
Assert.Null(trackingField.GetValue(recognizer)); |
|||
Assert.Equal(default(Point), (Point)initialPositionField.GetValue(recognizer)!); |
|||
} |
|||
|
|||
private static FieldInfo GetField(string name) |
|||
{ |
|||
var f = typeof(ScrollablePullGestureRecognizer) |
|||
.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); |
|||
Assert.NotNull(f); |
|||
return f!; |
|||
} |
|||
|
|||
private static void InvokePointerCaptureLost(ScrollablePullGestureRecognizer recognizer, IPointer pointer) |
|||
{ |
|||
var method = typeof(ScrollablePullGestureRecognizer) |
|||
.GetMethod("PointerCaptureLost", BindingFlags.Instance | BindingFlags.NonPublic); |
|||
Assert.NotNull(method); |
|||
method!.Invoke(recognizer, new object[] { pointer }); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue