diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 6ab30cc180..42f35c7205 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -59,6 +59,9 @@ + diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml index 06f54dd28a..a905e4efdf 100644 --- a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml +++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="using:ControlCatalog.ViewModels" + xmlns:generic="clr-namespace:System.Collections.Generic;assembly=netstandard" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" @@ -12,12 +13,27 @@ - + + + + Used pull direction: + + + + TopToBottom + LeftToRight + RightToLeft + BottomToTop + + + + + ()?.GetTapSize(PointerType.Touch).Height ?? 10) / 2); private int _scrollStartDistance = s_defaultScrollStartDistance; @@ -64,6 +67,30 @@ namespace Avalonia.Input.GestureRecognizers o => o.ScrollStartDistance, (o, v) => o.ScrollStartDistance = v, unsetValue: s_defaultScrollStartDistance); + /// + /// Defines the property. + /// + public static readonly DirectProperty OffsetProperty = + AvaloniaProperty.RegisterDirect(nameof(Offset), + o => o.Offset, (o, v) => o.Offset = v, + unsetValue: null); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ExtentProperty = + AvaloniaProperty.RegisterDirect(nameof(Extent), + o => o.Extent, (o, v) => o.Extent = v, + unsetValue: null); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ViewportProperty = + AvaloniaProperty.RegisterDirect(nameof(Viewport), + o => o.Viewport, (o, v) => o.Viewport = v, + unsetValue: null); + /// /// Gets or sets a value indicating whether the content can be scrolled horizontally. /// @@ -100,6 +127,33 @@ namespace Avalonia.Input.GestureRecognizers set => SetAndRaise(ScrollStartDistanceProperty, ref _scrollStartDistance, value); } + /// + /// Gets the extent of the scrollable content. + /// + public Size? Extent + { + get => _extent; + private set => SetAndRaise(ExtentProperty, ref _extent, value); + } + + /// + /// Gets or sets the current scroll offset. + /// + public Vector? Offset + { + get => _offset; + private set => SetAndRaise(OffsetProperty, ref _offset, value); + } + + /// + /// Gets the size of the viewport on the scrollable content. + /// + public Size? Viewport + { + get => _viewport; + private set => SetAndRaise(ViewportProperty, ref _viewport, value); + } + protected override void PointerPressed(PointerPressedEventArgs e) { var point = e.GetCurrentPoint(null); @@ -124,10 +178,34 @@ namespace Avalonia.Input.GestureRecognizers var rootPoint = e.GetPosition(null); if (!_scrolling) { - if (CanHorizontallyScroll && Math.Abs(_trackedRootPoint.X - rootPoint.X) > ScrollStartDistance) - _scrolling = true; - if (CanVerticallyScroll && Math.Abs(_trackedRootPoint.Y - rootPoint.Y) > ScrollStartDistance) - _scrolling = true; + if (CanVerticallyScroll) + { + double delta = _trackedRootPoint.Y - rootPoint.Y; + + if (Offset?.Y == 0 && delta < 0) + return; + + if (Offset?.Y + Viewport?.Height - Extent?.Height == 0 && delta > 0) + return; + + if (Math.Abs(delta) > ScrollStartDistance) + _scrolling = true; + } + + if (CanHorizontallyScroll) + { + double delta = _trackedRootPoint.X - rootPoint.X; + + if (Offset?.X == 0 && delta < 0) + return; + + if (Offset?.X + Viewport?.Width - Extent?.Width == 0 && delta > 0) + return; + + if (Math.Abs(delta) > ScrollStartDistance) + _scrolling = true; + } + if (_scrolling) { // Correct _trackedRootPoint with ScrollStartDistance, so scrolling does not start with a skip of ScrollStartDistance diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs index 2c29b19d48..07bbfd53f3 100644 --- a/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs +++ b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs @@ -41,7 +41,7 @@ namespace Avalonia.Controls s => s.Visualizer, (s, o) => s.Visualizer = o); /// - /// Defines the event. + /// Defines the property. /// public static readonly StyledProperty PullDirectionProperty = AvaloniaProperty.Register(nameof(PullDirection), PullDirection.TopToBottom); @@ -54,6 +54,25 @@ namespace Avalonia.Controls SetAndRaise(RefreshInfoProviderAdapterProperty, ref _refreshInfoProviderAdapter, value); } } + + /// + /// Defines the property. + /// + /// + /// Allows to enable the pull 2 refresh gesture for devices using a mouse. Disabled by default + /// + public static readonly StyledProperty IsMouseEnabledProperty = + AvaloniaProperty.Register(nameof(IsMouseEnabled), false); + + /// + /// Gets or sets a value that indicates whether the pull-to-refresh gesture is enabled for desktop devices. + /// Allows to enable the pull 2 refresh gesture for devices using a mouse. Disabled by default + /// + public bool IsMouseEnabled + { + get => GetValue(IsMouseEnabledProperty); + set => SetValue(IsMouseEnabledProperty, value); + } /// /// Gets or sets the for this container. @@ -93,7 +112,7 @@ namespace Avalonia.Controls public RefreshContainer() { _hasDefaultRefreshInfoProviderAdapter = true; - _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection, IsMouseEnabled); RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); } @@ -119,18 +138,29 @@ namespace Avalonia.Controls private void OnVisualizerSizeChanged(Rect obj) { - if (_hasDefaultRefreshInfoProviderAdapter) - { - _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + if (_hasDefaultRefreshInfoProviderAdapter && _refreshInfoProviderAdapter != null) + { + _refreshInfoProviderAdapter.UpdateVisualizerSize(obj.Size); RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); } } private void Visualizer_RefreshRequested(object? sender, RefreshRequestedEventArgs e) { + // Guarantee the inner deferral is balanced even if a downstream handler + // of RefreshRequestedEvent throws synchronously from RaiseEvent. + // Without this, a synchronously-throwing consumer leaves the visualizer's + // deferral count above zero forever, so RefreshCompleted never fires and + // the spinner stays stuck in Refreshing. var ev = new RefreshRequestedEventArgs(e.GetDeferral(), RefreshRequestedEvent); - RaiseEvent(ev); - ev.DecrementCount(); + try + { + RaiseEvent(ev); + } + finally + { + ev.DecrementCount(); + } } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -181,6 +211,10 @@ namespace Avalonia.Controls { OnPullDirectionChanged(); } + else if (change.Property == IsMouseEnabledProperty) + { + OnIsMouseEnabledChanged(); + } } private void OnPullDirectionChanged() @@ -231,17 +265,25 @@ namespace Avalonia.Controls break; } - if (_hasDefaultRefreshInfoProviderAdapter && - _hasDefaultRefreshVisualizer && - _refreshVisualizer.Bounds.Height == DefaultPullDimensionSize && - _refreshVisualizer.Bounds.Width == DefaultPullDimensionSize) - { - _refreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection); + if (_hasDefaultRefreshInfoProviderAdapter) + { + if (_hasDefaultRefreshVisualizer) + { + var size = new Size(_refreshVisualizer.Width, _refreshVisualizer.Height); + _refreshInfoProviderAdapter?.UpdateVisualizerSize(size); + } + + _refreshInfoProviderAdapter?.UpdatePullDirection(PullDirection); RaisePropertyChanged(RefreshInfoProviderAdapterProperty, null, _refreshInfoProviderAdapter); } } } + private void OnIsMouseEnabledChanged() + { + _refreshInfoProviderAdapter?.UpdateIsMouseEnabled(IsMouseEnabled); + } + /// /// Initiates an update of the content. /// diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs index db7b493fb7..8a57f68abc 100644 --- a/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs +++ b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs @@ -9,8 +9,8 @@ namespace Avalonia.Controls.PullToRefresh { internal const double DefaultExecutionRatio = 0.8; - private readonly PullDirection _refreshPullDirection; - private readonly Size _refreshVisualizerSize; + private PullDirection _refreshPullDirection; + private Size _refreshVisualizerSize; private readonly CompositionVisual? _visual; private bool _isInteractingForRefresh; @@ -30,6 +30,14 @@ namespace Avalonia.Controls.PullToRefresh AvaloniaProperty.RegisterDirect(nameof(InteractionRatio), s => s.InteractionRatio, (s, o) => s.InteractionRatio = o); + public static readonly DirectProperty PullDirectionProperty = + AvaloniaProperty.RegisterDirect(nameof(PullDirection), + s => s.PullDirection, (s, o) => s.PullDirection = o); + + public static readonly DirectProperty RefreshVisualizerSizeProperty = + AvaloniaProperty.RegisterDirect(nameof(RefreshVisualizerSize), + s => s.RefreshVisualizerSize, (s, o) => s.RefreshVisualizerSize = o); + /// /// Defines the event. /// @@ -54,6 +62,17 @@ namespace Avalonia.Controls.PullToRefresh if (isInteractingForRefresh != _isInteractingForRefresh) { SetAndRaise(IsInteractingForRefreshProperty, ref _isInteractingForRefresh, isInteractingForRefresh); + + // Keep _entered in sync with IsInteractingForRefresh. + // The flag can be cleared by paths other than PullGestureEnded + // (ScrollViewer_ScrollChanged / ScrollViewer_PointerReleased on the adapter). + // Without this, InteractingStateEntered would short-circuit and never + // re-assert the flag for the rest of the gesture, leaving the visualizer + // stuck in Idle and the spinner invisible until the next gesture. + if (!isInteractingForRefresh) + { + _entered = false; + } } } } @@ -64,6 +83,18 @@ namespace Avalonia.Controls.PullToRefresh set => SetAndRaise(InteractionRatioProperty, ref _interactionRatio, value); } + public Size RefreshVisualizerSize + { + get => _refreshVisualizerSize; + set => SetAndRaise(RefreshVisualizerSizeProperty, ref _refreshVisualizerSize, value); + } + + public PullDirection PullDirection + { + get => _refreshPullDirection; + set => SetAndRaise(PullDirectionProperty, ref _refreshPullDirection, value); + } + public double ExecutionRatio { get => DefaultExecutionRatio; @@ -130,7 +161,7 @@ namespace Avalonia.Controls.PullToRefresh break; case PullDirection.LeftToRight: case PullDirection.RightToLeft: - InteractionRatio = _refreshVisualizerSize.Height == 0 ? 1 : Math.Min(1, value.X / _refreshVisualizerSize.Width); + InteractionRatio = _refreshVisualizerSize.Width == 0 ? 1 : Math.Min(1, value.X / _refreshVisualizerSize.Width); break; } } diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs index f92151e02a..d323965c13 100644 --- a/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs +++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs @@ -18,6 +18,8 @@ namespace Avalonia.Controls private const float ParallaxPositionRatio = 0.5f; private double _executingRatio = 0.8; + private Vector3D _initialVisualOffset = default; + private RefreshVisualizerState _refreshVisualizerState; private RefreshInfoProvider? _refreshInfoProvider; private IDisposable? _isInteractingSubscription; @@ -211,47 +213,32 @@ namespace Avalonia.Controls _rotateAnimation.IterationBehavior = AnimationIterationBehavior.Count; _rotateAnimation = null; } + contentVisual.Opacity = MinimumIndicatorOpacity; contentVisual.RotationAngle = _startingRotationAngle; - visualizerVisual.Offset = IsPullDirectionVertical ? - new Vector3D(visualizerVisual.Offset.X, 0, 0) : - new Vector3D(0, visualizerVisual.Offset.Y, 0); - visual.Offset = visualizerVisual.Offset; + + if (visualizerVisual.Offset.X != 0 || visualizerVisual.Offset.Y != 0) + { + visual.Offset = _initialVisualOffset; + visualizerVisual.Offset = new Vector3D(0, 0, 0); + } + _content.InvalidateMeasure(); + break; case RefreshVisualizerState.Interacting: _played = false; contentVisual.Opacity = MinimumIndicatorOpacity; contentVisual.RotationAngle = (float)(_startingRotationAngle + _interactionRatio * 2 * Math.PI); - Vector3D offset = default; - if (IsPullDirectionVertical) - { - offset = new Vector3D(0, (_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0); - } - else - { - offset = new Vector3D((_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0); - } - visual.Offset = offset; - visualizerVisual.Offset = IsPullDirectionVertical ? - new Vector3D(visualizerVisual.Offset.X, offset.Y, 0) : - new Vector3D(offset.X, visualizerVisual.Offset.Y, 0); + + CalculateAndSetOffsets(root, visual, visualizerVisual); + break; case RefreshVisualizerState.Pending: contentVisual.Opacity = 1; contentVisual.RotationAngle = _startingRotationAngle + (float)(2 * Math.PI); - if (IsPullDirectionVertical) - { - offset = new Vector3D(0, (_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0); - } - else - { - offset = new Vector3D((_interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0); - } - visual.Offset = offset; - visualizerVisual.Offset = IsPullDirectionVertical ? - new Vector3D(visualizerVisual.Offset.X, offset.Y, 0) : - new Vector3D(offset.X, visualizerVisual.Offset.Y, 0); + + CalculateAndSetOffsets(root, visual, visualizerVisual); if (!_played) { @@ -278,20 +265,8 @@ namespace Avalonia.Controls contentVisual.Opacity = 1; float translationRatio = (float)(_refreshInfoProvider != null ? (1.0f - _refreshInfoProvider.ExecutionRatio) * ParallaxPositionRatio : 1.0f) * (IsPullDirectionFar ? -1f : 1f); - if (IsPullDirectionVertical) - { - offset = new Vector3D(0, (_executingRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height), 0); - } - else - { - offset = new Vector3D((_executingRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width), 0, 0); - } - visual.Offset = offset; - contentVisual.Offset += IsPullDirectionVertical ? new Vector3D(0, (translationRatio * root.Bounds.Height), 0) : - new Vector3D((translationRatio * root.Bounds.Width), 0, 0); - visualizerVisual.Offset = IsPullDirectionVertical ? - new Vector3D(visualizerVisual.Offset.X, offset.Y, 0) : - new Vector3D(offset.X, visualizerVisual.Offset.Y, 0); + + CalculateAndSetOffsets(root, visual, visualizerVisual); break; case RefreshVisualizerState.Peeking: contentVisual.Opacity = 1; @@ -302,6 +277,36 @@ namespace Avalonia.Controls } } + private void CalculateAndSetOffsets(Grid root, CompositionVisual visual, CompositionVisual indicatorVisualizer) + { + if (IsPullDirectionVertical) + { + // As long as the indicator-container isn't visible, + // the initial offset is the current offset + if (indicatorVisualizer.Offset.Y == 0) + { + _initialVisualOffset = visual.Offset; + } + + var offset = _interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Height; + indicatorVisualizer.Offset = indicatorVisualizer.Offset with { Y = offset }; + + visual.Offset = _initialVisualOffset with { Y = _initialVisualOffset.Y + offset }; + } + else + { + if (indicatorVisualizer.Offset.X == 0) + { + _initialVisualOffset = visual.Offset; + } + + var offset = _interactionRatio * (IsPullDirectionFar ? -1 : 1) * root.Bounds.Width; + indicatorVisualizer.Offset = indicatorVisualizer.Offset with { X = offset }; + + visual.Offset = _initialVisualOffset with { X = _initialVisualOffset.X + offset }; + } + } + /// /// Initiates an update of the content. /// @@ -371,32 +376,39 @@ namespace Avalonia.Controls } else if (change.Property == BoundsProperty) { - switch (PullDirection) - { - case PullDirection.TopToBottom: - RenderTransform = new TranslateTransform(0, -Bounds.Height); - break; - case PullDirection.BottomToTop: - RenderTransform = new TranslateTransform(0, Bounds.Height); - break; - case PullDirection.LeftToRight: - RenderTransform = new TranslateTransform(-Bounds.Width, 0); - break; - case PullDirection.RightToLeft: - RenderTransform = new TranslateTransform(Bounds.Width, 0); - break; - } + OnBoundsChanged(); UpdateContent(); } - else if(change.Property == PullDirectionProperty) + else if (change.Property == PullDirectionProperty) { OnOrientationChanged(); + OnBoundsChanged(); + UpdateContent(); } } + private void OnBoundsChanged() + { + switch (PullDirection) + { + case PullDirection.TopToBottom: + RenderTransform = new TranslateTransform(0, -Bounds.Height); + break; + case PullDirection.BottomToTop: + RenderTransform = new TranslateTransform(0, Bounds.Height); + break; + case PullDirection.LeftToRight: + RenderTransform = new TranslateTransform(-Bounds.Width, 0); + break; + case PullDirection.RightToLeft: + RenderTransform = new TranslateTransform(Bounds.Width, 0); + break; + } + } + private void OnOrientationChanged() { switch (_orientation) diff --git a/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs index 48063d90f8..e55912a5cd 100644 --- a/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs +++ b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs @@ -12,15 +12,17 @@ namespace Avalonia.Controls.PullToRefresh private const int InitialOffsetThreshold = 1; private PullDirection _refreshPullDirection; + private bool _IsMouseEnabled; private ScrollViewer? _scrollViewer; private RefreshInfoProvider? _refreshInfoProvider; - private PullGestureRecognizer? _pullGestureRecognizer; + private ScrollablePullGestureRecognizer? _pullGestureRecognizer; private InputElement? _interactionSource; private bool _isVisualizerInteractionSourceAttached; - public ScrollViewerIRefreshInfoProviderAdapter(PullDirection pullDirection) + public ScrollViewerIRefreshInfoProviderAdapter(PullDirection pullDirection, bool isMouseEnabled) { _refreshPullDirection = pullDirection; + _IsMouseEnabled = isMouseEnabled; } public RefreshInfoProvider? AdaptFromTree(Visual root, Size? refreshVIsualizerSize) @@ -93,6 +95,18 @@ namespace Avalonia.Controls.PullToRefresh _interactionSource.RemoveHandler(InputElement.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited); } + // Remove the previous pull gesture recognizer from the previous interaction source, + // otherwise repeated Adapt() calls (e.g. when the visual tree gets re-templated) + // accumulate recognizers, leading to duplicate PullGesture/PullGestureEnded events. + if (_pullGestureRecognizer != null && _interactionSource != null) + { + _interactionSource.GestureRecognizers.Remove(_pullGestureRecognizer); + } + + _pullGestureRecognizer = null; + _interactionSource = null; + _isVisualizerInteractionSourceAttached = false; + _refreshInfoProvider = null; _scrollViewer = adaptee; @@ -124,7 +138,7 @@ namespace Avalonia.Controls.PullToRefresh _refreshInfoProvider = new RefreshInfoProvider(_refreshPullDirection, refreshVIsualizerSize, ElementComposition.GetElementVisual(content)); - _pullGestureRecognizer = new PullGestureRecognizer(_refreshPullDirection); + _pullGestureRecognizer = new ScrollablePullGestureRecognizer(_refreshPullDirection, _IsMouseEnabled); if (_interactionSource != null) { @@ -261,6 +275,39 @@ namespace Avalonia.Controls.PullToRefresh return false; } + public void UpdatePullDirection(PullDirection pullDirection) + { + _refreshPullDirection = pullDirection; + + if (_refreshInfoProvider != null) + { + _refreshInfoProvider.PullDirection = pullDirection; + } + + if (_pullGestureRecognizer != null) + { + _pullGestureRecognizer.PullDirection = pullDirection; + } + } + + public void UpdateIsMouseEnabled(bool IsMouseEnabled) + { + _IsMouseEnabled = IsMouseEnabled; + + if (_pullGestureRecognizer != null) + { + _pullGestureRecognizer.IsMouseEnabled = IsMouseEnabled; + } + } + + public void UpdateVisualizerSize(Size? refreshVisualizerSize) + { + if (_refreshInfoProvider != null) + { + _refreshInfoProvider.RefreshVisualizerSize = refreshVisualizerSize ?? default; + } + } + private void CleanUpScrollViewer() { if (_scrollViewer != null) diff --git a/src/Avalonia.Controls/PullToRefresh/ScrollablePullGestureRecognizer.cs b/src/Avalonia.Controls/PullToRefresh/ScrollablePullGestureRecognizer.cs new file mode 100644 index 0000000000..3c8adf4c47 --- /dev/null +++ b/src/Avalonia.Controls/PullToRefresh/ScrollablePullGestureRecognizer.cs @@ -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; + + /// + /// Defines the property. + /// + public static readonly StyledProperty PullDirectionProperty = + AvaloniaProperty.Register(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, + }; + } +} diff --git a/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml b/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml index 611c7dc49b..ef861532ae 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml @@ -37,7 +37,10 @@ + IsScrollInertiaEnabled="{Binding (ScrollViewer.IsScrollInertiaEnabled), ElementName=PART_ContentPresenter}" + Offset="{Binding Offset, ElementName=PART_ContentPresenter}" + Viewport="{Binding Viewport, ElementName=PART_ContentPresenter}" + Extent="{Binding Extent, ElementName=PART_ContentPresenter}"/> + IsScrollInertiaEnabled="{Binding (ScrollViewer.IsScrollInertiaEnabled), ElementName=PART_ContentPresenter}" + Offset="{Binding Offset, ElementName=PART_ContentPresenter}" + Viewport="{Binding Viewport, ElementName=PART_ContentPresenter}" + Extent="{Binding Extent, ElementName=PART_ContentPresenter}"/> 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); + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapterTests.cs b/tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapterTests.cs new file mode 100644 index 0000000000..34b52da48b --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapterTests.cs @@ -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(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().Count(); + Assert.Equal(1, afterFirst); + + adapter.Adapt(sv, new Size(100, 100)); + var interactionSourceAfter = GetInteractionSource(adapter); + Assert.NotNull(interactionSourceAfter); + var afterSecond = interactionSourceAfter.GestureRecognizers.OfType().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)!; + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollablePullGestureRecognizerTests.cs b/tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollablePullGestureRecognizerTests.cs new file mode 100644 index 0000000000..d1188fcd6f --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollablePullGestureRecognizerTests.cs @@ -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 }); + } + } +}