diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index b5a09b5fbd..33ea8c0db0 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -132,6 +132,9 @@
+
+
+
diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml
new file mode 100644
index 0000000000..0123251bd0
--- /dev/null
+++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs
new file mode 100644
index 0000000000..4c52179e00
--- /dev/null
+++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs
@@ -0,0 +1,36 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using ControlCatalog.ViewModels;
+
+namespace ControlCatalog.Pages
+{
+ public class RefreshContainerPage : UserControl
+ {
+ private RefreshContainerViewModel _viewModel;
+
+ public RefreshContainerPage()
+ {
+ this.InitializeComponent();
+
+ _viewModel = new RefreshContainerViewModel();
+
+ DataContext = _viewModel;
+ }
+
+ private async void RefreshContainerPage_RefreshRequested(object? sender, RefreshRequestedEventArgs e)
+ {
+ var deferral = e.GetRefreshCompletionDeferral();
+
+ await _viewModel.AddToTop();
+
+ deferral.Complete();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+ }
+}
diff --git a/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs
new file mode 100644
index 0000000000..a78bc9e44a
--- /dev/null
+++ b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs
@@ -0,0 +1,26 @@
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reactive;
+using System.Threading.Tasks;
+using Avalonia.Controls.Notifications;
+using ControlCatalog.Pages;
+using MiniMvvm;
+
+namespace ControlCatalog.ViewModels
+{
+ public class RefreshContainerViewModel : ViewModelBase
+ {
+ public ObservableCollection Items { get; }
+
+ public RefreshContainerViewModel()
+ {
+ Items = new ObservableCollection(Enumerable.Range(1, 200).Select(i => $"Item {i}"));
+ }
+
+ public async Task AddToTop()
+ {
+ await Task.Delay(1000);
+ Items.Insert(0, $"Item {200 - Items.Count}");
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs
index 82ed96d982..acd78515cd 100644
--- a/src/Avalonia.Base/Input/Gestures.cs
+++ b/src/Avalonia.Base/Input/Gestures.cs
@@ -46,6 +46,14 @@ namespace Avalonia.Input
private static readonly WeakReference s_lastPress = new WeakReference(null);
private static Point s_lastPressPoint;
+ public static readonly RoutedEvent PullGestureEvent =
+ RoutedEvent.Register(
+ "PullGesture", RoutingStrategies.Bubble, typeof(Gestures));
+
+ public static readonly RoutedEvent PullGestureEndedEvent =
+ RoutedEvent.Register(
+ "PullGestureEnded", RoutingStrategies.Bubble, typeof(Gestures));
+
static Gestures()
{
InputElement.PointerPressedEvent.RouteFinished.Subscribe(PointerPressed);
diff --git a/src/Avalonia.Base/Input/PullGestureEventArgs.cs b/src/Avalonia.Base/Input/PullGestureEventArgs.cs
new file mode 100644
index 0000000000..57ad24f7a3
--- /dev/null
+++ b/src/Avalonia.Base/Input/PullGestureEventArgs.cs
@@ -0,0 +1,43 @@
+using System;
+using Avalonia.Interactivity;
+
+namespace Avalonia.Input
+{
+ public class PullGestureEventArgs : RoutedEventArgs
+ {
+ public int Id { get; }
+ public Vector Delta { get; }
+ public PullDirection PullDirection { get; }
+
+ private static int _nextId = 1;
+
+ public static int GetNextFreeId() => _nextId++;
+
+ public PullGestureEventArgs(int id, Vector delta, PullDirection pullDirection) : base(Gestures.PullGestureEvent)
+ {
+ Id = id;
+ Delta = delta;
+ PullDirection = pullDirection;
+ }
+ }
+
+ public class PullGestureEndedEventArgs : RoutedEventArgs
+ {
+ public int Id { get; }
+ public PullDirection PullDirection { get; }
+
+ public PullGestureEndedEventArgs(int id, PullDirection pullDirection) : base(Gestures.PullGestureEndedEvent)
+ {
+ Id = id;
+ PullDirection = pullDirection;
+ }
+ }
+
+ public enum PullDirection
+ {
+ TopToBottom,
+ BottomToTop,
+ LeftToRight,
+ RightToLeft
+ }
+}
diff --git a/src/Avalonia.Base/Input/PullGestureRecognizer.cs b/src/Avalonia.Base/Input/PullGestureRecognizer.cs
new file mode 100644
index 0000000000..bbbded44fa
--- /dev/null
+++ b/src/Avalonia.Base/Input/PullGestureRecognizer.cs
@@ -0,0 +1,152 @@
+using Avalonia.Input.GestureRecognizers;
+
+namespace Avalonia.Input
+{
+ public class PullGestureRecognizer : StyledElement, IGestureRecognizer
+ {
+ private IInputElement? _target;
+ private IGestureRecognizerActionsDispatcher? _actions;
+ private Point _initialPosition;
+ private int _gestureId;
+ private IPointer? _tracking;
+ private PullDirection _pullDirection;
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty PullDirectionProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(PullDirection),
+ o => o.PullDirection,
+ (o, v) => o.PullDirection = v);
+
+ public PullDirection PullDirection
+ {
+ get => _pullDirection;
+ set => SetAndRaise(PullDirectionProperty, ref _pullDirection, value);
+ }
+
+ public PullGestureRecognizer(PullDirection pullDirection)
+ {
+ PullDirection = pullDirection;
+ }
+
+ public void Initialize(IInputElement target, IGestureRecognizerActionsDispatcher actions)
+ {
+ _target = target;
+ _actions = actions;
+
+ _target?.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble);
+ _target?.AddHandler(InputElement.PointerReleasedEvent, OnPointerReleased, Interactivity.RoutingStrategies.Tunnel | Interactivity.RoutingStrategies.Bubble);
+ }
+
+ private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ PointerPressed(e);
+ }
+
+ private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ PointerReleased(e);
+ }
+
+ public void PointerCaptureLost(IPointer pointer)
+ {
+ if (_tracking == pointer)
+ {
+ EndPull();
+ }
+ }
+
+ public void PointerMoved(PointerEventArgs e)
+ {
+ if (_tracking == e.Pointer)
+ {
+ var currentPosition = e.GetPosition(_target);
+ _actions!.Capture(e.Pointer, this);
+
+ Vector delta = default;
+ switch (PullDirection)
+ {
+ case PullDirection.TopToBottom:
+ if (currentPosition.Y > _initialPosition.Y)
+ {
+ delta = new Vector(0, currentPosition.Y - _initialPosition.Y);
+ }
+ break;
+ case PullDirection.BottomToTop:
+ if (currentPosition.Y < _initialPosition.Y)
+ {
+ delta = new Vector(0, _initialPosition.Y - currentPosition.Y);
+ }
+ break;
+ case PullDirection.LeftToRight:
+ if (currentPosition.X > _initialPosition.X)
+ {
+ delta = new Vector(currentPosition.X - _initialPosition.X, 0);
+ }
+ break;
+ case PullDirection.RightToLeft:
+ if (currentPosition.X < _initialPosition.X)
+ {
+ delta = new Vector(_initialPosition.X - currentPosition.X, 0);
+ }
+ break;
+ }
+
+ _target?.RaiseEvent(new PullGestureEventArgs(_gestureId, delta, PullDirection));
+ }
+ }
+
+ public void PointerPressed(PointerPressedEventArgs e)
+ {
+ if (_target != null && (e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen))
+ {
+ var position = e.GetPosition(_target);
+
+ var canPull = false;
+
+ var bounds = _target.Bounds;
+
+ switch (PullDirection)
+ {
+ case PullDirection.TopToBottom:
+ canPull = position.Y < bounds.Height * 0.1;
+ break;
+ case PullDirection.BottomToTop:
+ canPull = position.Y > bounds.Height - (bounds.Height * 0.1);
+ break;
+ case PullDirection.LeftToRight:
+ canPull = position.X < bounds.Width * 0.1;
+ break;
+ case PullDirection.RightToLeft:
+ canPull = position.X > bounds.Width - (bounds.Width * 0.1);
+ break;
+ }
+
+ if (canPull)
+ {
+ _gestureId = PullGestureEventArgs.GetNextFreeId();
+ _tracking = e.Pointer;
+ _initialPosition = position;
+ }
+ }
+ }
+
+ public void PointerReleased(PointerReleasedEventArgs e)
+ {
+ if (_tracking == e.Pointer)
+ {
+ EndPull();
+ }
+ }
+
+ private void EndPull()
+ {
+ _tracking = null;
+ _initialPosition = default;
+
+ _target?.RaiseEvent(new PullGestureEndedEventArgs(_gestureId, PullDirection));
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs
new file mode 100644
index 0000000000..b882cf5a0f
--- /dev/null
+++ b/src/Avalonia.Controls/PullToRefresh/RefreshContainer.cs
@@ -0,0 +1,221 @@
+using System;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.PullToRefresh;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace Avalonia.Controls
+{
+ public class RefreshContainer : ContentControl
+ {
+ internal const int DefaultPullDimensionSize = 100;
+
+ private readonly bool _hasDefaultRefreshInfoProviderAdapter;
+
+ private ScrollViewerIRefreshInfoProviderAdapter _refreshInfoProviderAdapter;
+ private RefreshInfoProvider _refreshInfoProvider;
+ private IDisposable _visualizerSizeSubscription;
+ private Grid? _visualizerPresenter;
+ private RefreshVisualizer _refreshVisualizer;
+
+ public static readonly RoutedEvent RefreshRequestedEvent =
+ RoutedEvent.Register(nameof(RefreshRequested), RoutingStrategies.Bubble);
+
+ internal static readonly DirectProperty RefreshInfoProviderAdapterProperty =
+ AvaloniaProperty.RegisterDirect(nameof(RefreshInfoProviderAdapter),
+ (s) => s.RefreshInfoProviderAdapter, (s, o) => s.RefreshInfoProviderAdapter = o);
+
+ public static readonly DirectProperty RefreshVisualizerProperty =
+ AvaloniaProperty.RegisterDirect(nameof(RefreshVisualizer),
+ s => s.RefreshVisualizer, (s, o) => s.RefreshVisualizer = o);
+
+ public static readonly StyledProperty PullDirectionProperty =
+ AvaloniaProperty.Register(nameof(PullDirection), PullDirection.TopToBottom);
+
+ public ScrollViewerIRefreshInfoProviderAdapter RefreshInfoProviderAdapter
+ {
+ get => _refreshInfoProviderAdapter; set
+ {
+ SetAndRaise(RefreshInfoProviderAdapterProperty, ref _refreshInfoProviderAdapter, value);
+ }
+ }
+
+ private bool _hasDefaultRefreshVisualizer;
+
+ public RefreshVisualizer RefreshVisualizer
+ {
+ get => _refreshVisualizer; set
+ {
+ if (_refreshVisualizer != null)
+ {
+ _visualizerSizeSubscription?.Dispose();
+ _refreshVisualizer.RefreshRequested -= Visualizer_RefreshRequested;
+ }
+
+ SetAndRaise(RefreshVisualizerProperty, ref _refreshVisualizer, value);
+ }
+ }
+
+ public PullDirection PullDirection
+ {
+ get => GetValue(PullDirectionProperty);
+ set => SetValue(PullDirectionProperty, value);
+ }
+
+ public event EventHandler? RefreshRequested
+ {
+ add => AddHandler(RefreshRequestedEvent, value);
+ remove => RemoveHandler(RefreshRequestedEvent, value);
+ }
+
+ public RefreshContainer()
+ {
+ _hasDefaultRefreshInfoProviderAdapter = true;
+ RefreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection);
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ _visualizerPresenter = e.NameScope.Find("PART_RefreshVisualizerPresenter");
+
+ if (_refreshVisualizer == null)
+ {
+ _hasDefaultRefreshVisualizer = true;
+ RefreshVisualizer = new RefreshVisualizer();
+ }
+ else
+ {
+ _hasDefaultRefreshVisualizer = false;
+ RaisePropertyChanged(RefreshVisualizerProperty, null, _refreshVisualizer);
+ }
+
+ OnPullDirectionChanged();
+ }
+
+ private void OnVisualizerSizeChanged(Rect obj)
+ {
+ if (_hasDefaultRefreshInfoProviderAdapter)
+ {
+ RefreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection);
+ }
+ }
+
+ private void Visualizer_RefreshRequested(object? sender, RefreshRequestedEventArgs e)
+ {
+ var ev = new RefreshRequestedEventArgs(e.GetRefreshCompletionDeferral(), RefreshRequestedEvent);
+ RaiseEvent(ev);
+ ev.DecrementCount();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == RefreshInfoProviderAdapterProperty)
+ {
+ if (_refreshInfoProvider != null)
+ {
+ _refreshVisualizer.RefreshInfoProvider = _refreshInfoProvider;
+ }
+ else
+ {
+ if (RefreshInfoProviderAdapter != null && _refreshVisualizer != null)
+ {
+ _refreshInfoProvider = RefreshInfoProviderAdapter.AdaptFromTree(this, _refreshVisualizer.Bounds.Size);
+
+ if (_refreshInfoProvider != null)
+ {
+ _refreshVisualizer.RefreshInfoProvider = _refreshInfoProvider;
+ }
+ }
+ }
+ }
+ else if (change.Property == RefreshVisualizerProperty)
+ {
+ if (_visualizerPresenter != null)
+ {
+ _visualizerPresenter.Children.Clear();
+ if (_refreshVisualizer != null)
+ {
+ _visualizerPresenter.Children.Add(_refreshVisualizer);
+ }
+ }
+
+ if (_refreshVisualizer != null)
+ {
+ _refreshVisualizer.RefreshRequested += Visualizer_RefreshRequested;
+ _visualizerSizeSubscription = _refreshVisualizer.GetObservable(Control.BoundsProperty).Subscribe(OnVisualizerSizeChanged);
+ }
+ }
+ else if (change.Property == PullDirectionProperty)
+ {
+ OnPullDirectionChanged();
+ }
+ }
+
+ private void OnPullDirectionChanged()
+ {
+ if (_visualizerPresenter != null)
+ {
+ switch (PullDirection)
+ {
+ case PullDirection.TopToBottom:
+ _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Top;
+ _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Stretch;
+ if (_hasDefaultRefreshVisualizer)
+ {
+ _refreshVisualizer.PullDirection = PullDirection.TopToBottom;
+ _refreshVisualizer.Height = DefaultPullDimensionSize;
+ _refreshVisualizer.Width = double.NaN;
+ }
+ break;
+ case PullDirection.BottomToTop:
+ _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Bottom;
+ _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Stretch;
+ if (_hasDefaultRefreshVisualizer)
+ {
+ _refreshVisualizer.PullDirection = PullDirection.BottomToTop;
+ _refreshVisualizer.Height = DefaultPullDimensionSize;
+ _refreshVisualizer.Width = double.NaN;
+ }
+ break;
+ case PullDirection.LeftToRight:
+ _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Stretch;
+ _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Left;
+ if (_hasDefaultRefreshVisualizer)
+ {
+ _refreshVisualizer.PullDirection = PullDirection.LeftToRight;
+ _refreshVisualizer.Width = DefaultPullDimensionSize;
+ _refreshVisualizer.Height = double.NaN;
+ }
+ break;
+ case PullDirection.RightToLeft:
+ _visualizerPresenter.VerticalAlignment = Layout.VerticalAlignment.Stretch;
+ _visualizerPresenter.HorizontalAlignment = Layout.HorizontalAlignment.Right;
+ if (_hasDefaultRefreshVisualizer)
+ {
+ _refreshVisualizer.PullDirection = PullDirection.RightToLeft;
+ _refreshVisualizer.Width = DefaultPullDimensionSize;
+ _refreshVisualizer.Height = double.NaN;
+ }
+ break;
+ }
+
+ if (_hasDefaultRefreshInfoProviderAdapter &&
+ _hasDefaultRefreshVisualizer &&
+ _refreshVisualizer.Bounds.Height == DefaultPullDimensionSize &&
+ _refreshVisualizer.Bounds.Width == DefaultPullDimensionSize)
+ {
+ RefreshInfoProviderAdapter = new ScrollViewerIRefreshInfoProviderAdapter(PullDirection);
+ }
+ }
+ }
+
+ public void RequestRefresh()
+ {
+ _refreshVisualizer?.RequestRefresh();
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs
new file mode 100644
index 0000000000..3fc32cdd08
--- /dev/null
+++ b/src/Avalonia.Controls/PullToRefresh/RefreshInfoProvider.cs
@@ -0,0 +1,129 @@
+using System;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace Avalonia.Controls.PullToRefresh
+{
+ public class RefreshInfoProvider : Interactive
+ {
+ internal const double ExecutionRatio = 0.8;
+
+ private readonly PullDirection _refreshPullDirection;
+ private readonly Size _refreshVisualizerSize;
+
+ private readonly Visual _visual;
+ private bool _isInteractingForRefresh;
+ private double _interactionRatio;
+ private bool _entered;
+
+ public DirectProperty IsInteractingForRefreshProperty =
+ AvaloniaProperty.RegisterDirect(nameof(IsInteractingForRefresh),
+ s => s.IsInteractingForRefresh, (s, o) => s.IsInteractingForRefresh = o);
+
+ public DirectProperty InteractionRatioProperty =
+ AvaloniaProperty.RegisterDirect(nameof(InteractionRatio),
+ s => s.InteractionRatio, (s, o) => s.InteractionRatio = o);
+
+ ///
+ /// Defines the event.
+ ///
+ public static readonly RoutedEvent RefreshStartedEvent =
+ RoutedEvent.Register(nameof(RefreshStarted), RoutingStrategies.Bubble);
+
+ ///
+ /// Defines the event.
+ ///
+ public static readonly RoutedEvent RefreshCompletedEvent =
+ RoutedEvent.Register(nameof(RefreshCompleted), RoutingStrategies.Bubble);
+
+ public bool PeekingMode { get; internal set; }
+
+ public bool IsInteractingForRefresh
+ {
+ get => _isInteractingForRefresh; internal set
+ {
+ var isInteractingForRefresh = value && !PeekingMode;
+
+ if (isInteractingForRefresh != _isInteractingForRefresh)
+ {
+ SetAndRaise(IsInteractingForRefreshProperty, ref _isInteractingForRefresh, isInteractingForRefresh);
+ }
+ }
+ }
+
+ public double InteractionRatio
+ {
+ get => _interactionRatio;
+ set
+ {
+ SetAndRaise(InteractionRatioProperty, ref _interactionRatio, value);
+ }
+ }
+
+ internal Visual Visual => _visual;
+
+ public event EventHandler RefreshStarted
+ {
+ add => AddHandler(RefreshStartedEvent, value);
+ remove => RemoveHandler(RefreshStartedEvent, value);
+ }
+
+ public event EventHandler RefreshCompleted
+ {
+ add => AddHandler(RefreshCompletedEvent, value);
+ remove => RemoveHandler(RefreshCompletedEvent, value);
+ }
+
+ internal void InteractingStateEntered(object sender, PullGestureEventArgs e)
+ {
+ if (!_entered)
+ {
+ IsInteractingForRefresh = true;
+ _entered = true;
+ }
+
+ ValuesChanged(e.Delta);
+ }
+
+ internal void InteractingStateExited(object sender, PullGestureEndedEventArgs e)
+ {
+ IsInteractingForRefresh = false;
+ _entered = false;
+
+ ValuesChanged(default);
+ }
+
+
+ public RefreshInfoProvider(PullDirection refreshPullDirection, Size refreshVIsualizerSize, Visual visual)
+ {
+ _refreshPullDirection = refreshPullDirection;
+ _refreshVisualizerSize = refreshVIsualizerSize;
+ _visual = visual;
+ }
+
+ public void OnRefreshStarted()
+ {
+ RaiseEvent(new RoutedEventArgs(RefreshStartedEvent));
+ }
+
+ public void OnRefreshCompleted()
+ {
+ RaiseEvent(new RoutedEventArgs(RefreshCompletedEvent));
+ }
+
+ internal void ValuesChanged(Vector value)
+ {
+ switch (_refreshPullDirection)
+ {
+ case PullDirection.TopToBottom:
+ case PullDirection.BottomToTop:
+ InteractionRatio = _refreshVisualizerSize.Height == 0 ? 1 : Math.Min(1, value.Y / _refreshVisualizerSize.Height);
+ break;
+ case PullDirection.LeftToRight:
+ case PullDirection.RightToLeft:
+ InteractionRatio = _refreshVisualizerSize.Height == 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
new file mode 100644
index 0000000000..81c613443d
--- /dev/null
+++ b/src/Avalonia.Controls/PullToRefresh/RefreshVisualizer.cs
@@ -0,0 +1,544 @@
+using System;
+using System.Reactive.Linq;
+using System.Threading;
+using Avalonia.Animation;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.PullToRefresh;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+
+namespace Avalonia.Controls
+{
+ public class RefreshVisualizer : ContentControl
+ {
+ private const int DefaultIndicatorSize = 24;
+ private const double MinimumIndicatorOpacity = 0.4;
+ private const string ArrowPathData = "M18.6195264,3.31842271 C19.0080059,3.31842271 19.3290603,3.60710385 19.3798716,3.9816481 L19.3868766,4.08577298 L19.3868766,6.97963208 C19.3868766,7.36811161 19.0981955,7.68916605 18.7236513,7.73997735 L18.6195264,7.74698235 L15.7256673,7.74698235 C15.3018714,7.74698235 14.958317,7.40342793 14.958317,6.97963208 C14.958317,6.59115255 15.2469981,6.27009811 15.6215424,6.21928681 L15.7256673,6.21228181 L16.7044011,6.21182461 C13.7917384,3.87107476 9.52212532,4.05209336 6.81933829,6.75488039 C3.92253872,9.65167996 3.92253872,14.34832 6.81933829,17.2451196 C9.71613786,20.1419192 14.4127779,20.1419192 17.3095775,17.2451196 C19.0725398,15.4821573 19.8106555,12.9925923 19.3476248,10.58925 C19.2674502,10.173107 19.5398064,9.77076216 19.9559494,9.69058758 C20.3720923,9.610413 20.7744372,9.88276918 20.8546118,10.2989121 C21.4129973,13.1971899 20.5217103,16.2033812 18.3947747,18.3303168 C14.8986373,21.8264542 9.23027854,21.8264542 5.73414113,18.3303168 C2.23800371,14.8341794 2.23800371,9.16582064 5.73414113,5.66968323 C9.05475132,2.34907304 14.3349409,2.18235834 17.8523166,5.16953912 L17.8521761,4.08577298 C17.8521761,3.66197713 18.1957305,3.31842271 18.6195264,3.31842271 Z";
+ private double _executingRatio = 0.8;
+
+ private RotateTransform _visualizerRotateTransform;
+ private TranslateTransform _contentTranslateTransform;
+ private RefreshVisualizerState _refreshVisualizerState;
+ private RefreshInfoProvider _refreshInfoProvider;
+ private IDisposable _isInteractingSubscription;
+ private IDisposable _interactionRatioSubscription;
+ private bool _isInteractingForRefresh;
+ private Grid? _root;
+ private Control _content;
+ private RefreshVisualizerOrientation _orientation;
+ private float _startingRotationAngle;
+ private double _interactionRatio;
+
+ private bool IsPullDirectionVertical => PullDirection == PullDirection.TopToBottom || PullDirection == PullDirection.BottomToTop;
+ private bool IsPullDirectionFar => PullDirection == PullDirection.BottomToTop || PullDirection == PullDirection.RightToLeft;
+
+ public static readonly StyledProperty PullDirectionProperty =
+ AvaloniaProperty.Register(nameof(PullDirection), PullDirection.TopToBottom);
+ public static readonly RoutedEvent RefreshRequestedEvent =
+ RoutedEvent.Register(nameof(RefreshRequested), RoutingStrategies.Bubble);
+
+ public static readonly DirectProperty RefreshVisualizerStateProperty =
+ AvaloniaProperty.RegisterDirect(nameof(RefreshVisualizerState),
+ s => s.RefreshVisualizerState);
+
+ public static readonly DirectProperty OrientationProperty =
+ AvaloniaProperty.RegisterDirect(nameof(Orientation),
+ s => s.Orientation, (s, o) => s.Orientation = o);
+
+ public DirectProperty RefreshInfoProviderProperty =
+ AvaloniaProperty.RegisterDirect(nameof(RefreshInfoProvider),
+ s => s.RefreshInfoProvider, (s, o) => s.RefreshInfoProvider = o);
+
+ public RefreshVisualizerState RefreshVisualizerState
+ {
+ get
+ {
+ return _refreshVisualizerState;
+ }
+ private set
+ {
+ SetAndRaise(RefreshVisualizerStateProperty, ref _refreshVisualizerState, value);
+ UpdateContent();
+ }
+ }
+
+ public RefreshVisualizerOrientation Orientation
+ {
+ get
+ {
+ return _orientation;
+ }
+ set
+ {
+ SetAndRaise(OrientationProperty, ref _orientation, value);
+ }
+ }
+
+ internal PullDirection PullDirection
+ {
+ get => GetValue(PullDirectionProperty);
+ set
+ {
+ SetValue(PullDirectionProperty, value);
+
+ OnOrientationChanged();
+
+ UpdateContent();
+ }
+ }
+
+ public RefreshInfoProvider RefreshInfoProvider
+ {
+ get => _refreshInfoProvider; internal set
+ {
+ if (_refreshInfoProvider != null)
+ {
+ _refreshInfoProvider.RenderTransform = null;
+ }
+ SetAndRaise(RefreshInfoProviderProperty, ref _refreshInfoProvider, value);
+ }
+ }
+
+ public event EventHandler? RefreshRequested
+ {
+ add => AddHandler(RefreshRequestedEvent, value);
+ remove => RemoveHandler(RefreshRequestedEvent, value);
+ }
+
+ public RefreshVisualizer()
+ {
+ _visualizerRotateTransform = new RotateTransform();
+ _contentTranslateTransform = new TranslateTransform();
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ _root = e.NameScope.Find("PART_Root");
+
+ if (_root != null)
+ {
+ if (_content == null)
+ {
+ Content = new PathIcon()
+ {
+ Data = PathGeometry.Parse(ArrowPathData),
+ Height = DefaultIndicatorSize,
+ Width = DefaultIndicatorSize
+ };
+ }
+ else
+ {
+ RaisePropertyChanged(ContentProperty, null, Content);
+ }
+ }
+
+ OnOrientationChanged();
+
+ UpdateContent();
+ }
+
+ private void UpdateContent()
+ {
+ if (_content != null)
+ {
+ switch (RefreshVisualizerState)
+ {
+ case RefreshVisualizerState.Idle:
+ _content.Classes.Remove("refreshing");
+ _root.Classes.Remove("pending");
+ _content.RenderTransform = _visualizerRotateTransform;
+ _content.Opacity = MinimumIndicatorOpacity;
+ _visualizerRotateTransform.Angle = _startingRotationAngle;
+ _contentTranslateTransform.X = 0;
+ _contentTranslateTransform.Y = 0;
+ break;
+ case RefreshVisualizerState.Interacting:
+ _content.Classes.Remove("refreshing");
+ _root.Classes.Remove("pending");
+ _content.RenderTransform = _visualizerRotateTransform;
+ _content.Opacity = MinimumIndicatorOpacity;
+ _visualizerRotateTransform.Angle = _startingRotationAngle + (_interactionRatio * 360);
+ _content.Height = DefaultIndicatorSize;
+ _content.Width = DefaultIndicatorSize;
+ if (IsPullDirectionVertical)
+ {
+ _contentTranslateTransform.X = 0;
+ _contentTranslateTransform.Y = _interactionRatio * (IsPullDirectionFar ? -1 : 1) * _root.Bounds.Height;
+ }
+ else
+ {
+ _contentTranslateTransform.Y = 0;
+ _contentTranslateTransform.X = _interactionRatio * (IsPullDirectionFar ? -1 : 1) * _root.Bounds.Width;
+ }
+ break;
+ case RefreshVisualizerState.Pending:
+ _content.Classes.Remove("refreshing");
+ _content.Opacity = 1;
+ _content.RenderTransform = _visualizerRotateTransform;
+ if (IsPullDirectionVertical)
+ {
+ _contentTranslateTransform.X = 0;
+ _contentTranslateTransform.Y = _interactionRatio * (IsPullDirectionFar ? -1 : 1) * _root.Bounds.Height;
+ }
+ else
+ {
+ _contentTranslateTransform.Y = 0;
+ _contentTranslateTransform.X = _interactionRatio * (IsPullDirectionFar ? -1 : 1) * _root.Bounds.Width;
+ }
+
+ _root.Classes.Add("pending");
+ break;
+ case RefreshVisualizerState.Refreshing:
+ _root.Classes.Remove("pending");
+ _content.Classes.Add("refreshing");
+ _content.Opacity = 1;
+ _content.Height = DefaultIndicatorSize;
+ _content.Width = DefaultIndicatorSize;
+ break;
+ case RefreshVisualizerState.Peeking:
+ _root.Classes.Remove("pending");
+ _content.Opacity = 1;
+ _visualizerRotateTransform.Angle += _startingRotationAngle;
+ break;
+ }
+ }
+ }
+
+ public void RequestRefresh()
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Refreshing;
+ RefreshInfoProvider?.OnRefreshStarted();
+
+ RaiseRefreshRequested();
+ }
+
+ private void RefreshCompleted()
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Idle;
+
+ RefreshInfoProvider?.OnRefreshCompleted();
+ }
+
+ private void RaiseRefreshRequested()
+ {
+ var refreshArgs = new RefreshRequestedEventArgs(RefreshCompleted, RefreshRequestedEvent);
+
+ refreshArgs.IncrementCount();
+
+ RaiseEvent(refreshArgs);
+
+ refreshArgs.DecrementCount();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == RefreshInfoProviderProperty)
+ {
+ OnRefreshInfoProviderChanged();
+ }
+ else if (change.Property == ContentProperty)
+ {
+ if (_root != null)
+ {
+ if (_content == null)
+ {
+ _content = new PathIcon()
+ {
+ Data = PathGeometry.Parse(ArrowPathData),
+ Height = DefaultIndicatorSize,
+ Width = DefaultIndicatorSize
+ };
+
+ var transformGroup = new TransformGroup();
+ transformGroup.Children.Add(_visualizerRotateTransform);
+
+ _content.RenderTransform = _visualizerRotateTransform;
+ _root.RenderTransform = _contentTranslateTransform;
+
+ var transition = new Transitions
+ {
+ new DoubleTransition()
+ {
+ Property = OpacityProperty,
+ Duration = TimeSpan.FromSeconds(0.5)
+ },
+ };
+
+ _content.Transitions = transition;
+ }
+
+ var scalingGrid = new Grid();
+ scalingGrid.VerticalAlignment = Layout.VerticalAlignment.Center;
+ scalingGrid.HorizontalAlignment = Layout.HorizontalAlignment.Center;
+ scalingGrid.RenderTransform = new ScaleTransform();
+
+ scalingGrid.Children.Add(_content);
+
+ _root.Children.Insert(0, scalingGrid);
+ _content.VerticalAlignment = Layout.VerticalAlignment.Center;
+ _content.HorizontalAlignment = Layout.HorizontalAlignment.Center;
+ }
+
+ UpdateContent();
+ }
+ else if (change.Property == OrientationProperty)
+ {
+ OnOrientationChanged();
+
+ UpdateContent();
+ }
+ else if (change.Property == BoundsProperty)
+ {
+ if (_content != null)
+ {
+ var parent = _content.Parent as Control;
+ switch (PullDirection)
+ {
+ case PullDirection.TopToBottom:
+ parent.Margin = new Thickness(0, -Bounds.Height - DefaultIndicatorSize - (0.5 * DefaultIndicatorSize), 0, 0);
+ break;
+ case PullDirection.BottomToTop:
+ parent.Margin = new Thickness(0, 0, 0, -Bounds.Height - DefaultIndicatorSize - (0.5 * DefaultIndicatorSize));
+ break;
+ case PullDirection.LeftToRight:
+ parent.Margin = new Thickness(-Bounds.Width - DefaultIndicatorSize - (0.5 * DefaultIndicatorSize), 0, 0, 0);
+ break;
+ case PullDirection.RightToLeft:
+ parent.Margin = new Thickness(0, 0, -Bounds.Width - DefaultIndicatorSize - (0.5 * DefaultIndicatorSize), 0);
+ break;
+ }
+ }
+ }
+ }
+
+ private void OnOrientationChanged()
+ {
+ switch (_orientation)
+ {
+ case RefreshVisualizerOrientation.Auto:
+ switch (PullDirection)
+ {
+ case PullDirection.TopToBottom:
+ case PullDirection.BottomToTop:
+ _startingRotationAngle = 0.0f;
+ break;
+ case PullDirection.LeftToRight:
+ _startingRotationAngle = 270;
+ break;
+ case PullDirection.RightToLeft:
+ _startingRotationAngle = 90;
+ break;
+ }
+ break;
+ case RefreshVisualizerOrientation.Normal:
+ _startingRotationAngle = 0.0f;
+ break;
+ case RefreshVisualizerOrientation.Rotate90DegreesCounterclockwise:
+ _startingRotationAngle = 270;
+ break;
+ case RefreshVisualizerOrientation.Rotate270DegreesCounterclockwise:
+ _startingRotationAngle = 90;
+ break;
+ }
+ }
+
+ private void OnRefreshInfoProviderChanged()
+ {
+ _isInteractingSubscription?.Dispose();
+ _isInteractingSubscription = null;
+ _interactionRatioSubscription?.Dispose();
+ _interactionRatioSubscription = null;
+
+ if (_refreshInfoProvider != null)
+ {
+ _isInteractingSubscription = _refreshInfoProvider.GetObservable(RefreshInfoProvider.IsInteractingForRefreshProperty)
+ .Subscribe(InteractingForRefreshObserver);
+
+ _interactionRatioSubscription = _refreshInfoProvider.GetObservable(RefreshInfoProvider.InteractionRatioProperty)
+ .Subscribe(InteractionRatioObserver);
+
+ var visual = _refreshInfoProvider.Visual;
+ visual.RenderTransform = _contentTranslateTransform;
+
+ _executingRatio = RefreshInfoProvider.ExecutionRatio;
+ }
+ else
+ {
+ _executingRatio = 1;
+ }
+ }
+
+ private void InteractionRatioObserver(double obj)
+ {
+ var wasAtZero = _interactionRatio == 0.0;
+ _interactionRatio = obj;
+
+ if (_isInteractingForRefresh)
+ {
+ if (RefreshVisualizerState == RefreshVisualizerState.Idle)
+ {
+ if (wasAtZero)
+ {
+ if (_interactionRatio > _executingRatio)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Pending;
+ }
+ else if (_interactionRatio > 0)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Interacting;
+ }
+ }
+ else if (_interactionRatio > 0)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Peeking;
+ }
+ }
+ else if (RefreshVisualizerState == RefreshVisualizerState.Interacting)
+ {
+ if (_interactionRatio <= 0)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Idle;
+ }
+ else if (_interactionRatio > _executingRatio)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Pending;
+ }
+ else
+ {
+ UpdateContent();
+ }
+ }
+ else if (RefreshVisualizerState == RefreshVisualizerState.Pending)
+ {
+ if (_interactionRatio <= _executingRatio)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Interacting;
+ }
+ else if (_interactionRatio <= 0)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Idle;
+ }
+ else
+ {
+ UpdateContent();
+ }
+ }
+ }
+ else
+ {
+ if (RefreshVisualizerState != RefreshVisualizerState.Refreshing)
+ {
+ if (_interactionRatio > 0)
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Peeking;
+ }
+ else
+ {
+ RefreshVisualizerState = RefreshVisualizerState.Idle;
+ }
+ }
+ }
+ }
+
+ private void InteractingForRefreshObserver(bool obj)
+ {
+ _isInteractingForRefresh = obj;
+
+ if (!_isInteractingForRefresh)
+ {
+ switch (_refreshVisualizerState)
+ {
+ case RefreshVisualizerState.Pending:
+ RequestRefresh();
+ break;
+ case RefreshVisualizerState.Refreshing:
+ // We don't want to interrupt a currently executing refresh.
+ break;
+ default:
+ RefreshVisualizerState = RefreshVisualizerState.Idle;
+ break;
+ }
+ }
+ }
+ }
+
+ public enum RefreshVisualizerState
+ {
+ Idle,
+ Peeking,
+ Interacting,
+ Pending,
+ Refreshing
+ }
+
+ public enum RefreshVisualizerOrientation
+ {
+ Auto,
+ Normal,
+ Rotate90DegreesCounterclockwise,
+ Rotate270DegreesCounterclockwise
+ }
+
+ public class RefreshRequestedEventArgs : RoutedEventArgs
+ {
+ private RefreshCompletionDeferral _refreshCompletionDeferral;
+
+ public RefreshCompletionDeferral GetRefreshCompletionDeferral()
+ {
+ return _refreshCompletionDeferral.Get();
+ }
+
+ public RefreshRequestedEventArgs(Action deferredAction, RoutedEvent? routedEvent) : base(routedEvent)
+ {
+ _refreshCompletionDeferral = new RefreshCompletionDeferral(deferredAction);
+ }
+
+ public RefreshRequestedEventArgs(RefreshCompletionDeferral completionDeferral, RoutedEvent? routedEvent) : base(routedEvent)
+ {
+ _refreshCompletionDeferral = completionDeferral;
+ }
+
+ internal void IncrementCount()
+ {
+ _refreshCompletionDeferral?.Get();
+ }
+
+ internal void DecrementCount()
+ {
+ _refreshCompletionDeferral?.Complete();
+ }
+ }
+
+ public class RefreshCompletionDeferral
+ {
+ private Action _deferredAction;
+ private int _deferCount;
+
+ public RefreshCompletionDeferral(Action deferredAction)
+ {
+ _deferredAction = deferredAction;
+ }
+
+ public void Complete()
+ {
+ Interlocked.Decrement(ref _deferCount);
+
+ if (_deferCount == 0)
+ {
+ _deferredAction?.Invoke();
+ }
+ }
+
+ public RefreshCompletionDeferral Get()
+ {
+ Interlocked.Increment(ref _deferCount);
+
+ return this;
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs
new file mode 100644
index 0000000000..0b0c8c99b2
--- /dev/null
+++ b/src/Avalonia.Controls/PullToRefresh/ScrollViewerIRefreshInfoProviderAdapter.cs
@@ -0,0 +1,227 @@
+using System;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.VisualTree;
+
+namespace Avalonia.Controls.PullToRefresh
+{
+ public class ScrollViewerIRefreshInfoProviderAdapter
+ {
+ private const int MaxSearchDepth = 10;
+ private const int InitialOffsetThreshold = 1;
+
+ private PullDirection _refreshPullDirection;
+ private ScrollViewer _scrollViewer;
+ private RefreshInfoProvider _refreshInfoProvider;
+ private PullGestureRecognizer _pullGestureRecognizer;
+ private InputElement? _interactionSource;
+ private bool _isVisualizerInteractionSourceAttached;
+
+ public ScrollViewerIRefreshInfoProviderAdapter(PullDirection pullDirection)
+ {
+ _refreshPullDirection = pullDirection;
+ }
+
+ public RefreshInfoProvider AdaptFromTree(IVisual root, Size refreshVIsualizerSize)
+ {
+ if (root is ScrollViewer scrollViewer)
+ {
+ return Adapt(scrollViewer, refreshVIsualizerSize);
+ }
+ else
+ {
+ int depth = 0;
+ while (depth < MaxSearchDepth)
+ {
+ var scroll = AdaptFromTreeRecursiveHelper(root, depth);
+
+ if (scroll != null)
+ {
+ return Adapt(scroll, refreshVIsualizerSize);
+ }
+
+ depth++;
+ }
+ }
+
+ ScrollViewer AdaptFromTreeRecursiveHelper(IVisual root, int depth)
+ {
+ if (depth == 0)
+ {
+ foreach (var child in root.VisualChildren)
+ {
+ if (child is ScrollViewer viewer)
+ {
+ return viewer;
+ }
+ }
+ }
+ else
+ {
+ foreach (var child in root.VisualChildren)
+ {
+ var viewer = AdaptFromTreeRecursiveHelper(child, depth - 1);
+ if (viewer != null)
+ {
+ return viewer;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ return null;
+ }
+
+ public RefreshInfoProvider Adapt(ScrollViewer adaptee, Size refreshVIsualizerSize)
+ {
+ if (adaptee == null)
+ {
+ throw new ArgumentNullException(nameof(adaptee), "Adaptee cannot be null");
+ }
+
+ if (_scrollViewer != null)
+ {
+ CleanUpScrollViewer();
+ }
+
+ if (_refreshInfoProvider != null && _interactionSource != null)
+ {
+ _interactionSource.RemoveHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered);
+ _interactionSource.RemoveHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited);
+ }
+
+ _refreshInfoProvider = null;
+ _scrollViewer = adaptee;
+
+ if (_scrollViewer.Content == null)
+ {
+ throw new ArgumentException(nameof(adaptee), "Adaptee's content property cannot be null.");
+ }
+
+ var content = adaptee.Content as Visual;
+
+ if (content == null)
+ {
+ throw new ArgumentException(nameof(adaptee), "Adaptee's content property must be a Visual");
+ }
+
+ if (content.GetVisualParent() == null)
+ {
+ _scrollViewer.Loaded += ScrollViewer_Loaded;
+ }
+ else
+ {
+ ScrollViewer_Loaded(null, null);
+
+ if (content.Parent is not InputElement)
+ {
+ throw new ArgumentException(nameof(adaptee), "Adaptee's content's parent must be a InputElement");
+ }
+ }
+
+ _refreshInfoProvider = new RefreshInfoProvider(_refreshPullDirection, refreshVIsualizerSize, content);
+
+ _pullGestureRecognizer = new PullGestureRecognizer(_refreshPullDirection);
+
+ if (_interactionSource != null)
+ {
+ _interactionSource.GestureRecognizers.Add(_pullGestureRecognizer);
+ _interactionSource.AddHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered);
+ _interactionSource.AddHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited);
+ _isVisualizerInteractionSourceAttached = true;
+ }
+
+ _scrollViewer.PointerPressed += ScrollViewer_PointerPressed;
+ _scrollViewer.PointerReleased += ScrollViewer_PointerReleased;
+ _scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged;
+
+ return _refreshInfoProvider;
+ }
+
+ private void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
+ {
+ if (_isVisualizerInteractionSourceAttached && _refreshInfoProvider != null && _refreshInfoProvider.IsInteractingForRefresh)
+ {
+ if (!IsWithinOffsetThreashold())
+ {
+ _refreshInfoProvider.IsInteractingForRefresh = false;
+ }
+ }
+ }
+
+ private void ScrollViewer_Loaded(object sender, RoutedEventArgs e)
+ {
+ var content = _scrollViewer.Content as Visual;
+ if (content == null)
+ {
+ throw new ArgumentException(nameof(_scrollViewer), "Adaptee's content property must be a Visual");
+ }
+
+ if (content.Parent is not InputElement)
+ {
+ throw new ArgumentException(nameof(_scrollViewer), "Adaptee's content parent must be an InputElement");
+ }
+
+ MakeInteractionSource(content.Parent as InputElement);
+
+ _scrollViewer.Loaded -= ScrollViewer_Loaded;
+ }
+
+ private void MakeInteractionSource(InputElement element)
+ {
+ _interactionSource = element;
+
+ if (_pullGestureRecognizer != null)
+ {
+ element.GestureRecognizers.Add(_pullGestureRecognizer);
+ _interactionSource.AddHandler(Gestures.PullGestureEvent, _refreshInfoProvider.InteractingStateEntered);
+ _interactionSource.AddHandler(Gestures.PullGestureEndedEvent, _refreshInfoProvider.InteractingStateExited);
+ _isVisualizerInteractionSourceAttached = true;
+ }
+ }
+
+ private void ScrollViewer_PointerReleased(object sender, PointerReleasedEventArgs e)
+ {
+ if (_refreshInfoProvider != null)
+ {
+ _refreshInfoProvider.IsInteractingForRefresh = false;
+ }
+ }
+
+ private void ScrollViewer_PointerPressed(object sender, PointerPressedEventArgs e)
+ {
+ _refreshInfoProvider.PeekingMode = !IsWithinOffsetThreashold();
+ }
+
+ private bool IsWithinOffsetThreashold()
+ {
+ if (_scrollViewer != null)
+ {
+ var offset = _scrollViewer.Offset;
+
+ switch (_refreshPullDirection)
+ {
+ case PullDirection.TopToBottom:
+ return offset.Y < InitialOffsetThreshold;
+ case PullDirection.LeftToRight:
+ return offset.X < InitialOffsetThreshold;
+ case PullDirection.RightToLeft:
+ return offset.X > _scrollViewer.Extent.Width - _scrollViewer.Viewport.Width - InitialOffsetThreshold;
+ case PullDirection.BottomToTop:
+ return offset.Y > _scrollViewer.Extent.Height - _scrollViewer.Viewport.Height - InitialOffsetThreshold;
+ }
+ }
+
+ return false;
+ }
+
+ private void CleanUpScrollViewer()
+ {
+ _scrollViewer.PointerPressed -= ScrollViewer_PointerPressed;
+ _scrollViewer.PointerReleased -= ScrollViewer_PointerReleased;
+ _scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged;
+ }
+ }
+}
diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml
index 7e3c8673f5..d9be545801 100644
--- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml
+++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml
@@ -637,6 +637,10 @@
-
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml
index 7917315e19..e80b137b2d 100644
--- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml
+++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml
@@ -633,5 +633,9 @@
+
+
+
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
index 5383aa3180..5d38b055b3 100644
--- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
+++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
@@ -69,6 +69,8 @@
+
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml b/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml
new file mode 100644
index 0000000000..97002c25bd
--- /dev/null
+++ b/src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml b/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml
new file mode 100644
index 0000000000..388ca814f8
--- /dev/null
+++ b/src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml b/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml
index 1843abebfd..c748775a12 100644
--- a/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml
+++ b/src/Avalonia.Themes.Simple/Accents/BaseDark.xaml
@@ -33,6 +33,9 @@
+
+
+
diff --git a/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml b/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml
index 6247815303..43acb16be5 100644
--- a/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml
+++ b/src/Avalonia.Themes.Simple/Accents/BaseLight.xaml
@@ -33,6 +33,8 @@
-
+
+
+
diff --git a/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml b/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml
new file mode 100644
index 0000000000..326b217068
--- /dev/null
+++ b/src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml b/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml
new file mode 100644
index 0000000000..0b12fe133d
--- /dev/null
+++ b/src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
index 4aefa0136c..4bad556338 100644
--- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
+++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
@@ -65,6 +65,8 @@
+
+