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