ソースを参照

Add Refresh Container

Emmanuel Hansen 3 年 前
コミット
5bdbd930d9

+ 3 - 0
samples/ControlCatalog/MainView.xaml

@@ -132,6 +132,9 @@
       <TabItem Header="RadioButton">
         <pages:RadioButtonPage />
       </TabItem>
+      <TabItem Header="RefreshContainer">
+        <pages:RefreshContainerPage />
+      </TabItem>
       <TabItem Header="RelativePanel">
         <pages:RelativePanelPage />
       </TabItem>

+ 24 - 0
samples/ControlCatalog/Pages/RefreshContainerPage.axaml

@@ -0,0 +1,24 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             mc:Ignorable="d"
+             d:DesignWidth="800"
+             d:DesignHeight="450"
+             x:Class="ControlCatalog.Pages.RefreshContainerPage">
+  <DockPanel HorizontalAlignment="Stretch"
+             Height="600"
+             VerticalAlignment="Top">
+    <Label DockPanel.Dock="Top">A control that supports pull to refresh</Label>
+    <RefreshContainer Name="Refresh"
+                      DockPanel.Dock="Bottom"
+                      HorizontalAlignment="Stretch"
+                      VerticalAlignment="Stretch"
+                      RefreshRequested="RefreshContainerPage_RefreshRequested"
+                      Margin="5">
+      <ListBox HorizontalAlignment="Stretch"
+               VerticalAlignment="Top"
+               Items="{Binding Items}"/>
+    </RefreshContainer>
+  </DockPanel>
+</UserControl>

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

+ 26 - 0
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<string> Items { get; }
+
+        public RefreshContainerViewModel()
+        {
+            Items = new ObservableCollection<string>(Enumerable.Range(1, 200).Select(i => $"Item {i}"));
+        }
+
+        public async Task AddToTop()
+        {
+            await Task.Delay(1000);
+            Items.Insert(0, $"Item {200 - Items.Count}");
+        }
+    }
+}

+ 8 - 0
src/Avalonia.Base/Input/Gestures.cs

@@ -46,6 +46,14 @@ namespace Avalonia.Input
         private static readonly WeakReference<IInteractive?> s_lastPress = new WeakReference<IInteractive?>(null);
         private static Point s_lastPressPoint;
 
+        public static readonly RoutedEvent<PullGestureEventArgs> PullGestureEvent =
+            RoutedEvent.Register<PullGestureEventArgs>(
+                "PullGesture", RoutingStrategies.Bubble, typeof(Gestures));
+
+        public static readonly RoutedEvent<PullGestureEndedEventArgs> PullGestureEndedEvent =
+            RoutedEvent.Register<PullGestureEndedEventArgs>(
+                "PullGestureEnded", RoutingStrategies.Bubble, typeof(Gestures));
+
         static Gestures()
         {
             InputElement.PointerPressedEvent.RouteFinished.Subscribe(PointerPressed);

+ 43 - 0
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
+    }
+}

+ 152 - 0
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;
+
+        /// <summary>
+        /// Defines the <see cref="PullDirection"/> property.
+        /// </summary>
+        public static readonly DirectProperty<PullGestureRecognizer, PullDirection> PullDirectionProperty =
+            AvaloniaProperty.RegisterDirect<PullGestureRecognizer, PullDirection>(
+                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));
+        }
+    }
+}

+ 221 - 0
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<RefreshRequestedEventArgs> RefreshRequestedEvent =
+            RoutedEvent.Register<RefreshContainer, RefreshRequestedEventArgs>(nameof(RefreshRequested), RoutingStrategies.Bubble);
+
+        internal static readonly DirectProperty<RefreshContainer, ScrollViewerIRefreshInfoProviderAdapter> RefreshInfoProviderAdapterProperty =
+            AvaloniaProperty.RegisterDirect<RefreshContainer, ScrollViewerIRefreshInfoProviderAdapter>(nameof(RefreshInfoProviderAdapter),
+                (s) => s.RefreshInfoProviderAdapter, (s, o) => s.RefreshInfoProviderAdapter = o);
+
+        public static readonly DirectProperty<RefreshContainer, RefreshVisualizer> RefreshVisualizerProperty =
+            AvaloniaProperty.RegisterDirect<RefreshContainer, RefreshVisualizer>(nameof(RefreshVisualizer),
+                s => s.RefreshVisualizer, (s, o) => s.RefreshVisualizer = o);
+
+        public static readonly StyledProperty<PullDirection> PullDirectionProperty =
+            AvaloniaProperty.Register<RefreshContainer, PullDirection>(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<RefreshRequestedEventArgs>? 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<Grid>("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();
+        }
+    }
+}

+ 129 - 0
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<RefreshInfoProvider, bool> IsInteractingForRefreshProperty =
+            AvaloniaProperty.RegisterDirect<RefreshInfoProvider, bool>(nameof(IsInteractingForRefresh),
+                s => s.IsInteractingForRefresh, (s, o) => s.IsInteractingForRefresh = o);
+
+        public DirectProperty<RefreshInfoProvider, double> InteractionRatioProperty =
+            AvaloniaProperty.RegisterDirect<RefreshInfoProvider, double>(nameof(InteractionRatio),
+                s => s.InteractionRatio, (s, o) => s.InteractionRatio = o);
+
+        /// <summary>
+        /// Defines the <see cref="RefreshStarted"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> RefreshStartedEvent =
+            RoutedEvent.Register<RefreshInfoProvider, RoutedEventArgs>(nameof(RefreshStarted), RoutingStrategies.Bubble);
+
+        /// <summary>
+        /// Defines the <see cref="RefreshCompleted"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> RefreshCompletedEvent =
+            RoutedEvent.Register<RefreshInfoProvider, RoutedEventArgs>(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<RoutedEventArgs> RefreshStarted
+        {
+            add => AddHandler(RefreshStartedEvent, value);
+            remove => RemoveHandler(RefreshStartedEvent, value);
+        }
+
+        public event EventHandler<RoutedEventArgs> 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;
+            }
+        }
+    }
+}

+ 544 - 0
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<PullDirection> PullDirectionProperty =
+            AvaloniaProperty.Register<RefreshVisualizer, PullDirection>(nameof(PullDirection), PullDirection.TopToBottom);
+        public static readonly RoutedEvent<RefreshRequestedEventArgs> RefreshRequestedEvent =
+            RoutedEvent.Register<RefreshVisualizer, RefreshRequestedEventArgs>(nameof(RefreshRequested), RoutingStrategies.Bubble);
+
+        public static readonly DirectProperty<RefreshVisualizer, RefreshVisualizerState> RefreshVisualizerStateProperty =
+            AvaloniaProperty.RegisterDirect<RefreshVisualizer, RefreshVisualizerState>(nameof(RefreshVisualizerState),
+                s => s.RefreshVisualizerState);
+
+        public static readonly DirectProperty<RefreshVisualizer, RefreshVisualizerOrientation> OrientationProperty =
+            AvaloniaProperty.RegisterDirect<RefreshVisualizer, RefreshVisualizerOrientation>(nameof(Orientation),
+                s => s.Orientation, (s, o) => s.Orientation = o);
+
+        public DirectProperty<RefreshVisualizer, RefreshInfoProvider> RefreshInfoProviderProperty =
+            AvaloniaProperty.RegisterDirect<RefreshVisualizer, RefreshInfoProvider>(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<RefreshRequestedEventArgs>? 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<Grid>("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;
+        }
+    }
+}

+ 227 - 0
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;
+        }
+    }
+}

+ 5 - 1
src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml

@@ -637,6 +637,10 @@
     <StaticResource x:Key="FlyoutBorderThemeBrush" ResourceKey="SystemControlTransientBorderBrush" />
 
     <!-- BaseResources for ScrollViewer.xaml -->
-    <SolidColorBrush x:Key="ScrollViewerScrollBarsSeparatorBackground" Color="{StaticResource SystemChromeMediumColor}" Opacity="0.9" />    
+    <SolidColorBrush x:Key="ScrollViewerScrollBarsSeparatorBackground" Color="{StaticResource SystemChromeMediumColor}" Opacity="0.9" />
+
+    <!-- BaseResources for RefreshVisualizer.xaml -->
+    <SolidColorBrush x:Key="RefreshVisualizerForeground" Color="White"/>
+    <SolidColorBrush x:Key="RefreshVisualizerBackground" Color="Transparent"/> 
   </Style.Resources>
 </Style>

+ 4 - 0
src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml

@@ -633,5 +633,9 @@
     
     <!-- Resources for ScrollViewer.xaml -->
     <SolidColorBrush x:Key="ScrollViewerScrollBarsSeparatorBackground" Color="{StaticResource SystemChromeMediumColor}" Opacity="0.9" />
+
+    <!-- BaseResources for RefreshVisualizer.xaml -->
+    <SolidColorBrush x:Key="RefreshVisualizerForeground" Color="Black"/>
+    <SolidColorBrush x:Key="RefreshVisualizerBackground" Color="Transparent"/> 
   </Style.Resources>
 </Style>

+ 2 - 0
src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml

@@ -69,6 +69,8 @@
                 <!--  ManagedFileChooser comes last because it uses (and overrides) styles for a multitude of other controls...the dialogs were originally UserControls, after all  -->
                 <ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" />
                 <ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/SelectableTextBlock.xaml"/>
+                <ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml" />
+                <ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml" />
             </ResourceDictionary.MergedDictionaries>
         </ResourceDictionary>
     </Styles.Resources>

+ 24 - 0
src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml

@@ -0,0 +1,24 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+  <ControlTheme x:Key="{x:Type RefreshContainer}"
+                TargetType="RefreshContainer">
+    <Setter Property="Template">
+      <ControlTemplate>
+        <Grid Background="{DynamicResource SystemChromeMediumLowColor}">
+          <ContentPresenter Name="PART_ContentPresenter"
+                              Background="{TemplateBinding Background}"
+                              BorderBrush="{TemplateBinding BorderBrush}"
+                              BorderThickness="{TemplateBinding BorderThickness}"
+                              CornerRadius="{TemplateBinding CornerRadius}"
+                              ContentTemplate="{TemplateBinding ContentTemplate}"
+                              Content="{TemplateBinding Content}"
+                              Padding="{TemplateBinding Padding}"
+                              VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+                              HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}">
+          </ContentPresenter>
+          <Grid Name="PART_RefreshVisualizerPresenter"/>
+        </Grid>
+      </ControlTemplate>
+    </Setter>
+  </ControlTheme>
+</ResourceDictionary>

+ 67 - 0
src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml

@@ -0,0 +1,67 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+  <ControlTheme x:Key="{x:Type RefreshVisualizer}"
+                TargetType="RefreshVisualizer">
+    <Setter Property="IsTabStop" Value="False"/>
+    <Setter Property="IsHitTestVisible" Value="False"/>
+    <Setter Property="Height"
+            Value="100"/>
+    <Setter Property="Background"
+            Value="{DynamicResource RefreshVisualizerBackground}"/>
+    <Setter Property="Foreground"
+            Value="{DynamicResource RefreshVisualizerForeground}"/>
+    <Setter Property="Template">
+      <ControlTemplate>
+          <Grid Name="PART_Root" MinHeight="80" Background="{TemplateBinding Background}">
+            <Grid.Styles>
+              <Style Selector="Grid.pending > Grid">
+                <Style.Animations>
+                  <Animation IterationCount="1" Duration="0:0:0.25">
+                    <KeyFrame Cue="0%">
+                      <Setter Property="ScaleTransform.ScaleX"
+                              Value="1"/>
+                    </KeyFrame>
+                    <KeyFrame Cue="0%">
+                      <Setter Property="ScaleTransform.ScaleY"
+                              Value="1"/>
+                    </KeyFrame>
+                    <KeyFrame Cue="50%">
+                      <Setter Property="ScaleTransform.ScaleX"
+                              Value="1.2"/>
+                    </KeyFrame>
+                    <KeyFrame Cue="50%">
+                      <Setter Property="ScaleTransform.ScaleY"
+                              Value="1.2"/>
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                      <Setter Property="ScaleTransform.ScaleX"
+                              Value="1"/>
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                      <Setter Property="ScaleTransform.ScaleY"
+                              Value="1"/>
+                    </KeyFrame>
+                  </Animation>
+                </Style.Animations>
+              </Style>
+              <Style Selector="Grid > Grid > PathIcon.refreshing">
+                <Style.Animations>
+                  <Animation Duration="0:0:1"
+                             IterationCount="Infinite">
+                    <KeyFrame Cue="0%">
+                      <Setter Property="RotateTransform.Angle"
+                              Value="0"/>
+                    </KeyFrame>
+                    <KeyFrame Cue="100%">
+                      <Setter Property="RotateTransform.Angle"
+                              Value="360"/>
+                    </KeyFrame>
+                  </Animation>
+                </Style.Animations>
+              </Style>
+            </Grid.Styles>
+          </Grid>
+      </ControlTemplate>
+    </Setter>
+  </ControlTheme>
+</ResourceDictionary>

+ 3 - 0
src/Avalonia.Themes.Simple/Accents/BaseDark.xaml

@@ -33,6 +33,9 @@
         <SolidColorBrush x:Key="ThemeControlHighlightHighBrush" Color="{StaticResource ThemeControlHighlightHighColor}" />
         <SolidColorBrush x:Key="ThemeForegroundBrush" Color="{StaticResource ThemeForegroundColor}" />
         <SolidColorBrush x:Key="HighlightBrush" Color="{StaticResource HighlightColor}" />
+      
+        <SolidColorBrush x:Key="RefreshVisualizerForeground" Color="White"/>
+        <SolidColorBrush x:Key="RefreshVisualizerBackground" Color="Transparent"/>
 
     </Style.Resources>
 </Style>

+ 3 - 1
src/Avalonia.Themes.Simple/Accents/BaseLight.xaml

@@ -33,6 +33,8 @@
         <SolidColorBrush x:Key="ThemeControlHighlightHighBrush" Color="{StaticResource ThemeControlHighlightHighColor}" />
         <SolidColorBrush x:Key="ThemeForegroundBrush" Color="{StaticResource ThemeForegroundColor}" />
 
-        <SolidColorBrush x:Key="HighlightBrush" Color="{StaticResource HighlightColor}" />
+        <SolidColorBrush x:Key="HighlightBrush" Color="{StaticResource HighlightColor}" />      
+        <SolidColorBrush x:Key="RefreshVisualizerForeground" Color="Black"/>
+        <SolidColorBrush x:Key="RefreshVisualizerBackground" Color="Transparent"/>
     </Style.Resources>
 </Style>

+ 24 - 0
src/Avalonia.Themes.Simple/Controls/RefreshContainer.xaml

@@ -0,0 +1,24 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+  <ControlTheme x:Key="{x:Type RefreshContainer}"
+                TargetType="RefreshContainer">
+    <Setter Property="Template">
+      <ControlTemplate>
+        <Grid Background="{DynamicResource ThemeControlMidHighBrush}">
+          <ContentPresenter Name="PART_ContentPresenter"
+                            Background="{TemplateBinding Background}"
+                            BorderBrush="{TemplateBinding BorderBrush}"
+                            BorderThickness="{TemplateBinding BorderThickness}"
+                            CornerRadius="{TemplateBinding CornerRadius}"
+                            ContentTemplate="{TemplateBinding ContentTemplate}"
+                            Content="{TemplateBinding Content}"
+                            Padding="{TemplateBinding Padding}"
+                            VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
+                            HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}">
+          </ContentPresenter>
+          <Grid Name="PART_RefreshVisualizerPresenter"/>
+        </Grid>
+      </ControlTemplate>
+    </Setter>
+  </ControlTheme>
+</ResourceDictionary>

+ 72 - 0
src/Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml

@@ -0,0 +1,72 @@
+<ResourceDictionary xmlns="https://github.com/avaloniaui"
+                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+  <ControlTheme x:Key="{x:Type RefreshVisualizer}"
+                TargetType="RefreshVisualizer">
+    <Setter Property="IsTabStop"
+            Value="False"/>
+    <Setter Property="IsHitTestVisible"
+            Value="False"/>
+    <Setter Property="Height"
+            Value="100"/>
+    <Setter Property="Background"
+            Value="{DynamicResource RefreshVisualizerBackground}"/>
+    <Setter Property="Foreground"
+            Value="{DynamicResource RefreshVisualizerForeground}"/>
+    <Setter Property="Template">
+      <ControlTemplate>
+        <Grid Name="PART_Root"
+              MinHeight="80"
+              Background="{TemplateBinding Background}">
+          <Grid.Styles>
+            <Style Selector="Grid.pending > Grid">
+              <Style.Animations>
+                <Animation IterationCount="1"
+                           Duration="0:0:0.25">
+                  <KeyFrame Cue="0%">
+                    <Setter Property="ScaleTransform.ScaleX"
+                            Value="1"/>
+                  </KeyFrame>
+                  <KeyFrame Cue="0%">
+                    <Setter Property="ScaleTransform.ScaleY"
+                            Value="1"/>
+                  </KeyFrame>
+                  <KeyFrame Cue="50%">
+                    <Setter Property="ScaleTransform.ScaleX"
+                            Value="1.2"/>
+                  </KeyFrame>
+                  <KeyFrame Cue="50%">
+                    <Setter Property="ScaleTransform.ScaleY"
+                            Value="1.2"/>
+                  </KeyFrame>
+                  <KeyFrame Cue="100%">
+                    <Setter Property="ScaleTransform.ScaleX"
+                            Value="1"/>
+                  </KeyFrame>
+                  <KeyFrame Cue="100%">
+                    <Setter Property="ScaleTransform.ScaleY"
+                            Value="1"/>
+                  </KeyFrame>
+                </Animation>
+              </Style.Animations>
+            </Style>
+            <Style Selector="Grid > Grid > PathIcon.refreshing">
+              <Style.Animations>
+                <Animation Duration="0:0:1"
+                           IterationCount="Infinite">
+                  <KeyFrame Cue="0%">
+                    <Setter Property="RotateTransform.Angle"
+                            Value="0"/>
+                  </KeyFrame>
+                  <KeyFrame Cue="100%">
+                    <Setter Property="RotateTransform.Angle"
+                            Value="360"/>
+                  </KeyFrame>
+                </Animation>
+              </Style.Animations>
+            </Style>
+          </Grid.Styles>
+        </Grid>
+      </ControlTemplate>
+    </Setter>
+  </ControlTheme>
+</ResourceDictionary>

+ 2 - 0
src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml

@@ -65,6 +65,8 @@
         <ResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/ManagedFileChooser.xaml" />
         <ResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/SplitButton.xaml" />
         <ResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/SelectableTextBlock.xaml" />
+        <ResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/RefreshContainer.xaml" />
+        <ResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml" />
       </ResourceDictionary.MergedDictionaries>
     </ResourceDictionary>
   </Styles.Resources>