Browse Source

Added improved refresh-capabilities to ScrollViewer in simple theme

Added gesture-recognizer for scroll-viewer to improve usability
Added possibility to enable pulltorefresh on desktop too
pull/18617/head
nickodei 2 years ago
committed by Alexander Marek
parent
commit
6e66a0dcb6
  1. 3
      samples/ControlCatalog/App.xaml
  2. 20
      samples/ControlCatalog/Pages/RefreshContainerPage.axaml
  3. 86
      src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs
  4. 68
      src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs
  5. 37
      src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs
  6. 130
      src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs
  7. 53
      src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs
  8. 158
      src/Avalonia.Controls/PullToRefresh/ScrollablePullGestureRecognizer.cs
  9. 5
      src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml
  10. 5
      src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml
  11. 95
      tests/Avalonia.Controls.UnitTests/PullToRefresh/RefreshInfoProviderTests.cs
  12. 59
      tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapterTests.cs
  13. 61
      tests/Avalonia.Controls.UnitTests/PullToRefresh/ScrollablePullGestureRecognizerTests.cs

3
samples/ControlCatalog/App.xaml

@ -59,6 +59,9 @@
<Style Selector="Label.h3">
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="RefreshContainer">
<Setter Property="IsMouseEnabled" Value="true" />
</Style>
</Application.Styles>
<NativeDock.Menu>
<NativeMenu>

20
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 @@
<DockPanel HorizontalAlignment="Stretch"
Height="600"
VerticalAlignment="Top">
<Label DockPanel.Dock="Top">A control that supports pull to refresh</Label>
<StackPanel DockPanel.Dock="Top">
<Label>A control that supports pull to refresh on touch inputs</Label>
<StackPanel Orientation="Horizontal" Margin="3" Spacing="5">
<TextBlock VerticalAlignment="Center">Used pull direction:</TextBlock>
<ComboBox x:Name="PullDirections" SelectedIndex="0">
<ComboBox.ItemsSource>
<generic:List x:TypeArguments="PullDirection">
<PullDirection>TopToBottom</PullDirection>
<PullDirection>LeftToRight</PullDirection>
<PullDirection>RightToLeft</PullDirection>
<PullDirection>BottomToTop</PullDirection>
</generic:List>
</ComboBox.ItemsSource>
</ComboBox>
</StackPanel>
</StackPanel>
<RefreshContainer Name="Refresh"
DockPanel.Dock="Bottom"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
PullDirection="TopToBottom"
PullDirection="{Binding #PullDirections.SelectedItem}"
RefreshRequested="RefreshContainerPage_RefreshRequested"
Margin="5">
<ListBox HorizontalAlignment="Stretch"

86
src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs

@ -16,6 +16,9 @@ namespace Avalonia.Input.GestureRecognizers
private bool _canHorizontallyScroll;
private bool _canVerticallyScroll;
private bool _isScrollInertiaEnabled;
private Vector? _offset;
private Size? _viewport;
private Size? _extent;
private readonly static int s_defaultScrollStartDistance = (int)((AvaloniaLocator.Current?.GetService<IPlatformSettings>()?.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);
/// <summary>
/// Defines the <see cref="Offset"/> property.
/// </summary>
public static readonly DirectProperty<ScrollGestureRecognizer, Vector?> OffsetProperty =
AvaloniaProperty.RegisterDirect<ScrollGestureRecognizer, Vector?>(nameof(Offset),
o => o.Offset, (o, v) => o.Offset = v,
unsetValue: null);
/// <summary>
/// Defines the <see cref="Extent"/> property.
/// </summary>
public static readonly DirectProperty<ScrollGestureRecognizer, Size?> ExtentProperty =
AvaloniaProperty.RegisterDirect<ScrollGestureRecognizer, Size?>(nameof(Extent),
o => o.Extent, (o, v) => o.Extent = v,
unsetValue: null);
/// <summary>
/// Defines the <see cref="Viewport"/> property.
/// </summary>
public static readonly DirectProperty<ScrollGestureRecognizer, Size?> ViewportProperty =
AvaloniaProperty.RegisterDirect<ScrollGestureRecognizer, Size?>(nameof(Viewport),
o => o.Viewport, (o, v) => o.Viewport = v,
unsetValue: null);
/// <summary>
/// Gets or sets a value indicating whether the content can be scrolled horizontally.
/// </summary>
@ -100,6 +127,33 @@ namespace Avalonia.Input.GestureRecognizers
set => SetAndRaise(ScrollStartDistanceProperty, ref _scrollStartDistance, value);
}
/// <summary>
/// Gets the extent of the scrollable content.
/// </summary>
public Size? Extent
{
get => _extent;
private set => SetAndRaise(ExtentProperty, ref _extent, value);
}
/// <summary>
/// Gets or sets the current scroll offset.
/// </summary>
public Vector? Offset
{
get => _offset;
private set => SetAndRaise(OffsetProperty, ref _offset, value);
}
/// <summary>
/// Gets the size of the viewport on the scrollable content.
/// </summary>
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

68
src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs

@ -41,7 +41,7 @@ namespace Avalonia.Controls
s => s.Visualizer, (s, o) => s.Visualizer = o);
/// <summary>
/// Defines the <see cref="PullDirection"/> event.
/// Defines the <see cref="PullDirection"/> property.
/// </summary>
public static readonly StyledProperty<PullDirection> PullDirectionProperty =
AvaloniaProperty.Register<RefreshContainer, PullDirection>(nameof(PullDirection), PullDirection.TopToBottom);
@ -54,6 +54,25 @@ namespace Avalonia.Controls
SetAndRaise(RefreshInfoProviderAdapterProperty, ref _refreshInfoProviderAdapter, value);
}
}
/// <summary>
/// Defines the <see cref="IsMouseEnabled"/> property.
/// </summary>
/// <remarks>
/// Allows to enable the pull 2 refresh gesture for devices using a mouse. Disabled by default
/// </remarks>
public static readonly StyledProperty<bool> IsMouseEnabledProperty =
AvaloniaProperty.Register<RefreshContainer, bool>(nameof(IsMouseEnabled), false);
/// <summary>
/// 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
/// </summary>
public bool IsMouseEnabled
{
get => GetValue(IsMouseEnabledProperty);
set => SetValue(IsMouseEnabledProperty, value);
}
/// <summary>
/// Gets or sets the <see cref="RefreshVisualizer"/> 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);
}
/// <summary>
/// Initiates an update of the content.
/// </summary>

37
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<RefreshInfoProvider, double>(nameof(InteractionRatio),
s => s.InteractionRatio, (s, o) => s.InteractionRatio = o);
public static readonly DirectProperty<RefreshInfoProvider, PullDirection> PullDirectionProperty =
AvaloniaProperty.RegisterDirect<RefreshInfoProvider, PullDirection>(nameof(PullDirection),
s => s.PullDirection, (s, o) => s.PullDirection = o);
public static readonly DirectProperty<RefreshInfoProvider, Size> RefreshVisualizerSizeProperty =
AvaloniaProperty.RegisterDirect<RefreshInfoProvider, Size>(nameof(RefreshVisualizerSize),
s => s.RefreshVisualizerSize, (s, o) => s.RefreshVisualizerSize = o);
/// <summary>
/// Defines the <see cref="RefreshStarted"/> event.
/// </summary>
@ -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;
}
}

130
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 };
}
}
/// <summary>
/// Initiates an update of the content.
/// </summary>
@ -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)

53
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)

158
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;
/// <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,
};
}
}

5
src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml

@ -37,7 +37,10 @@
<ScrollContentPresenter.GestureRecognizers>
<ScrollGestureRecognizer CanHorizontallyScroll="{Binding CanHorizontallyScroll, ElementName=PART_ContentPresenter}"
CanVerticallyScroll="{Binding CanVerticallyScroll, ElementName=PART_ContentPresenter}"
IsScrollInertiaEnabled="{Binding (ScrollViewer.IsScrollInertiaEnabled), 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}"/>
</ScrollContentPresenter.GestureRecognizers>
</ScrollContentPresenter>
<ScrollBar Name="PART_HorizontalScrollBar"

5
src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml

@ -20,7 +20,10 @@
<ScrollContentPresenter.GestureRecognizers>
<ScrollGestureRecognizer CanHorizontallyScroll="{Binding CanHorizontallyScroll, ElementName=PART_ContentPresenter}"
CanVerticallyScroll="{Binding CanVerticallyScroll, ElementName=PART_ContentPresenter}"
IsScrollInertiaEnabled="{Binding (ScrollViewer.IsScrollInertiaEnabled), 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}"/>
</ScrollContentPresenter.GestureRecognizers>
</ScrollContentPresenter>
<ScrollBar Name="PART_HorizontalScrollBar"

95
tests/Avalonia.Controls.UnitTests/PullToRefresh/RefreshInfoProviderTests.cs

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

59
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<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)!;
}
}
}

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