diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index b95b455ca4..4a5f5bc96c 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -135,6 +135,9 @@
+
+
+
diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml
new file mode 100644
index 0000000000..f3bf1724b4
--- /dev/null
+++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml.cs
new file mode 100644
index 0000000000..f9d0328d9a
--- /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.GetDeferral();
+
+ 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..d4b43043be
--- /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(3000);
+ Items.Insert(0, $"Item {200 - Items.Count}");
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/PullGestureRecognizer.cs
new file mode 100644
index 0000000000..fedd07ec32
--- /dev/null
+++ b/src/Avalonia.Base/Input/GestureRecognizers/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 && _target is Visual visual)
+ {
+ var currentPosition = e.GetPosition(visual);
+ _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 && _target is Visual visual && (e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen))
+ {
+ var position = e.GetPosition(visual);
+
+ var canPull = false;
+
+ var bounds = visual.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.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs
index 496052ad35..1ea88fe824 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