Browse Source

Merge branch 'master' into scroll_inertia

Max Katz 2 years ago
parent
commit
3346f6dcef

+ 4 - 1
samples/ControlCatalog/MainView.xaml

@@ -144,9 +144,12 @@
       <TabItem Header="RelativePanel">
         <pages:RelativePanelPage />
       </TabItem>
-        <TabItem Header="ScrollViewer">
+      <TabItem Header="ScrollViewer">
         <pages:ScrollViewerPage />
       </TabItem>
+      <TabItem Header="ScrollViewer Snapping">
+        <pages:ScrollSnapPage />
+      </TabItem>
       <TabItem Header="Slider">
         <pages:SliderPage />
       </TabItem>

+ 222 - 0
samples/ControlCatalog/Pages/ScrollSnapPage.xaml

@@ -0,0 +1,222 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             d:DesignHeight="800"
+             d:DesignWidth="400"
+             x:Class="ControlCatalog.Pages.ScrollSnapPage"
+             xmlns:pages="using:ControlCatalog.Pages"
+             x:DataType="pages:ScrollSnapPageViewModel">
+  <StackPanel Orientation="Vertical" Spacing="4">
+    <TextBlock TextWrapping="Wrap"
+               Classes="h2">Scrollviewer can snap supported content both vertically and horizontally. Snapping occurs from scrolling with touch or pen, or using the pointer wheel.</TextBlock>
+
+    <Grid RowDefinitions="Auto, Auto, Auto, Auto, Auto">
+      <StackPanel Orientation="Horizontal"
+                  Spacing="4">
+        <StackPanel Orientation="Vertical"
+                    Spacing="4">
+          <TextBlock Text="Snap Point Type" />
+          <ComboBox Items="{Binding AvailableSnapPointsType}"
+                    SelectedItem="{Binding SnapPointsType}" />
+        </StackPanel>
+
+        <StackPanel Orientation="Vertical"
+                    Spacing="4">
+          <TextBlock Text="Snap Point Alignment" />
+          <ComboBox Items="{Binding AvailableSnapPointsAlignment}"
+                    SelectedItem="{Binding SnapPointsAlignment}" />
+        </StackPanel>
+
+        <ToggleSwitch IsChecked="{Binding AreSnapPointsRegular}"
+                      OffContent="No"
+                      OnContent="Yes"
+                      Content="Are Snap Points regular?" />
+      </StackPanel>
+      <TextBlock TextWrapping="Wrap"
+                 Grid.Row="1"
+                 Margin="0,10"
+                 Classes="h2">Vertical Snapping</TextBlock>
+
+      <Border
+        BorderBrush="Green"
+        BorderThickness="1"
+        Padding="0"
+        Grid.Row="2"
+        Margin="10, 5">
+        <ScrollViewer x:Name="VerticalSnapsScrollViewer"
+                      VerticalSnapPointsType="{Binding SnapPointsType}"
+                      VerticalSnapPointsAlignment="{Binding SnapPointsAlignment}"
+                      HorizontalAlignment="Stretch"
+                      Height="350"
+                      HorizontalScrollBarVisibility="Disabled">
+          <StackPanel AreVerticalSnapPointsRegular="{Binding AreSnapPointsRegular}"
+                      Orientation="Vertical"
+                      HorizontalAlignment="Stretch">
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 1"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 2"/>
+            </Border>
+            <Border Padding="5, 20"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 3"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 4"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 5"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 6"/>
+            </Border>
+            <Border Padding="5,8"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 7"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 8"/>
+            </Border>
+            <Border Padding="5,4"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 9"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 20"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 11"/>
+            </Border>
+            <Border Padding="5, 30"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         Text="Child 12"/>
+            </Border>
+          </StackPanel>
+        </ScrollViewer>
+      </Border>
+      <TextBlock TextWrapping="Wrap"
+                 Grid.Row="3"
+                 Margin="0,10"
+                 Classes="h2">Horizontal Snapping</TextBlock>
+      <Border
+        BorderBrush="Green"
+        BorderThickness="1"
+        Padding="0"
+        Grid.Row="4"
+        Margin="10, 10">
+        <ScrollViewer x:Name="HorizontalSnapsScrollViewer"
+                      HorizontalSnapPointsType="{Binding SnapPointsType}"
+                      HorizontalSnapPointsAlignment="{Binding SnapPointsAlignment}"
+                      HorizontalAlignment="Stretch"
+                      Height="350"
+                      HorizontalScrollBarVisibility="Auto"
+                      VerticalScrollBarVisibility="Disabled">
+          <StackPanel AreHorizontalSnapPointsRegular="{Binding AreSnapPointsRegular}"
+                      Orientation="Horizontal"
+                      HorizontalAlignment="Stretch">
+            <Border Padding="5, 30"
+                    Width="300"
+                    BorderBrush="Red"
+                    HorizontalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         VerticalAlignment="Center"
+                         Text="Child 1"/>
+            </Border>
+            <Border Padding="5, 30"
+                    Width="300"
+                    BorderBrush="Red"
+                    VerticalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         VerticalAlignment="Center"
+                         Text="Child 2"/>
+            </Border>
+            <Border Padding="5, 20"
+                    Width="300"
+                    BorderBrush="Red"
+                    VerticalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         VerticalAlignment="Center"
+                         Text="Child 3"/>
+            </Border>
+            <Border Padding="5, 30"
+                    Width="300"
+                    BorderBrush="Red"
+                    VerticalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         VerticalAlignment="Center"
+                         Text="Child 4"/>
+            </Border>
+            <Border Padding="5, 30"
+                    Width="300"
+                    BorderBrush="Red"
+                    VerticalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         VerticalAlignment="Center"
+                         Text="Child 5"/>
+            </Border>
+            <Border Padding="5, 30"
+                    Width="300"
+                    BorderBrush="Red"
+                    VerticalAlignment="Stretch"
+                    BorderThickness="1">
+              <TextBlock FontWeight="Bold"
+                         VerticalAlignment="Center"
+                         Text="Child 6"/>
+            </Border>
+            
+          </StackPanel>
+        </ScrollViewer>
+      </Border>
+    </Grid>
+  </StackPanel>
+</UserControl>

+ 68 - 0
samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs

@@ -0,0 +1,68 @@
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Markup.Xaml;
+using MiniMvvm;
+
+namespace ControlCatalog.Pages
+{
+    public class ScrollSnapPageViewModel : ViewModelBase
+    {
+        private SnapPointsType _snapPointsType;
+        private SnapPointsAlignment _snapPointsAlignment;
+        private bool _areSnapPointsRegular;
+
+        public ScrollSnapPageViewModel()
+        {
+
+            AvailableSnapPointsType = new List<SnapPointsType>()
+            {
+                SnapPointsType.None,
+                SnapPointsType.Mandatory,
+                SnapPointsType.MandatorySingle
+            };
+
+            AvailableSnapPointsAlignment = new List<SnapPointsAlignment>()
+            {
+                SnapPointsAlignment.Near,
+                SnapPointsAlignment.Center,
+                SnapPointsAlignment.Far,
+            };
+        }
+
+        public bool AreSnapPointsRegular
+        {
+            get => _areSnapPointsRegular;
+            set => this.RaiseAndSetIfChanged(ref _areSnapPointsRegular, value);
+        }
+
+        public SnapPointsType SnapPointsType
+        {
+            get => _snapPointsType;
+            set => this.RaiseAndSetIfChanged(ref _snapPointsType, value);
+        }
+
+        public SnapPointsAlignment SnapPointsAlignment
+        {
+            get => _snapPointsAlignment;
+            set => this.RaiseAndSetIfChanged(ref _snapPointsAlignment, value);
+        }
+        public List<SnapPointsType> AvailableSnapPointsType { get; }
+        public List<SnapPointsAlignment> AvailableSnapPointsAlignment { get; }
+    }
+
+    public class ScrollSnapPage : UserControl
+    {
+        public ScrollSnapPage()
+        {
+            this.InitializeComponent();
+
+            DataContext = new ScrollSnapPageViewModel();
+        }
+
+        private void InitializeComponent()
+        {
+            AvaloniaXamlLoader.Load(this);
+        }
+    }
+}

+ 2 - 3
samples/ControlCatalog/Pages/ScrollViewerPage.xaml

@@ -3,8 +3,8 @@
              xmlns:pages="using:ControlCatalog.Pages"
              x:Class="ControlCatalog.Pages.ScrollViewerPage"
              x:DataType="pages:ScrollViewerPageViewModel">
-  <StackPanel Orientation="Vertical" Spacing="4">
-    <TextBlock Classes="h2">Allows for horizontal and vertical content scrolling.</TextBlock>
+  <StackPanel Orientation="Vertical" Spacing="20">
+    <TextBlock TextWrapping="Wrap" Classes="h2">Allows for horizontal and vertical content scrolling. Supports snapping on touch and pointer wheel scrolling.</TextBlock>
 
     <Grid ColumnDefinitions="Auto, *">
       <StackPanel Orientation="Vertical" Spacing="4">
@@ -33,6 +33,5 @@
                Source="/Assets/delicate-arch-896885_640.jpg" />
       </ScrollViewer>
     </Grid>
-
   </StackPanel>
 </UserControl>

+ 6 - 4
src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs

@@ -8,6 +8,10 @@ namespace Avalonia.Input.GestureRecognizers
         : StyledElement, // It's not an "element" in any way, shape or form, but TemplateBinding refuse to work otherwise
             IGestureRecognizer
     {
+        // Pixels per second speed that is considered to be the stop of inertial scroll
+        internal const double InertialScrollSpeedEnd = 5;
+        public const double InertialResistance = 0.15;
+
         private bool _scrolling;
         private Point _trackedRootPoint;
         private IPointer? _tracking;
@@ -116,9 +120,6 @@ namespace Avalonia.Input.GestureRecognizers
             }
         }
         
-        // Pixels per second speed that is considered to be the stop of inertial scroll
-        private const double InertialScrollSpeedEnd = 5;
-        
         public void PointerMoved(PointerEventArgs e)
         {
             if (e.Pointer == _tracking)
@@ -196,6 +197,7 @@ namespace Avalonia.Input.GestureRecognizers
                     var savedGestureId = _gestureId;
                     var st = Stopwatch.StartNew();
                     var lastTime = TimeSpan.Zero;
+                    _target!.RaiseEvent(new ScrollGestureInertiaStartingEventArgs(_gestureId, _inertia));
                     DispatcherTimer.Run(() =>
                     {
                         // Another gesture has started, finish the current one
@@ -207,7 +209,7 @@ namespace Avalonia.Input.GestureRecognizers
                         var elapsedSinceLastTick = st.Elapsed - lastTime;
                         lastTime = st.Elapsed;
 
-                        var speed = _inertia * Math.Pow(0.15, st.Elapsed.TotalSeconds);
+                        var speed = _inertia * Math.Pow(InertialResistance, st.Elapsed.TotalSeconds);
                         var distance = speed * elapsedSinceLastTick.TotalSeconds;
                         var scrollGestureEventArgs = new ScrollGestureEventArgs(_gestureId, distance);
                         _target!.RaiseEvent(scrollGestureEventArgs);

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

@@ -45,6 +45,10 @@ namespace Avalonia.Input
             RoutedEvent.Register<ScrollGestureEventArgs>(
                 "ScrollGesture", RoutingStrategies.Bubble, typeof(Gestures));
 
+        public static readonly RoutedEvent<ScrollGestureInertiaStartingEventArgs> ScrollGestureInertiaStartingEvent =
+            RoutedEvent.Register<ScrollGestureInertiaStartingEventArgs>(
+                "ScrollGestureInertiaStarting", RoutingStrategies.Bubble, typeof(Gestures));
+
         public static readonly RoutedEvent<ScrollGestureEndedEventArgs> ScrollGestureEndedEvent =
             RoutedEvent.Register<ScrollGestureEndedEventArgs>(
                 "ScrollGestureEnded", RoutingStrategies.Bubble, typeof(Gestures));

+ 12 - 0
src/Avalonia.Base/Input/ScrollGestureEventArgs.cs

@@ -30,4 +30,16 @@ namespace Avalonia.Input
             Id = id;
         }
     }
+
+    public class ScrollGestureInertiaStartingEventArgs : RoutedEventArgs
+    {
+        public int Id { get; }
+        public Vector Inertia { get; }
+
+        internal ScrollGestureInertiaStartingEventArgs(int id, Vector inertia) : base(Gestures.ScrollGestureInertiaStartingEvent)
+        {
+            Id = id;
+            Inertia = inertia;
+        }
+    }
 }

+ 89 - 2
src/Avalonia.Controls/ItemsControl.cs

@@ -12,6 +12,8 @@ using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Templates;
 using Avalonia.Data;
 using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
 using Avalonia.LogicalTree;
 using Avalonia.Metadata;
 using Avalonia.Styling;
@@ -23,7 +25,7 @@ namespace Avalonia.Controls
     /// Displays a collection of items.
     /// </summary>
     [PseudoClasses(":empty", ":singleitem")]
-    public class ItemsControl : TemplatedControl, IChildIndexProvider
+    public class ItemsControl : TemplatedControl, IChildIndexProvider, IScrollSnapPointsInfo
     {
         /// <summary>
         /// The default value for the <see cref="ItemsPanel"/> property.
@@ -72,6 +74,18 @@ namespace Avalonia.Controls
         /// </summary>
         public static readonly StyledProperty<IBinding?> DisplayMemberBindingProperty =
             AvaloniaProperty.Register<ItemsControl, IBinding?>(nameof(DisplayMemberBinding));
+        
+        /// <summary>
+        /// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreHorizontalSnapPointsRegular));
+
+        /// <summary>
+        /// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreVerticalSnapPointsRegular));
 
         /// <summary>
         /// Gets or sets the <see cref="IBinding"/> to use for binding to the display member of each item.
@@ -91,6 +105,8 @@ namespace Avalonia.Controls
         private IDataTemplate? _displayMemberItemTemplate;
         private Tuple<int, Control>? _containerBeingPrepared;
         private ScrollViewer? _scrollViewer;
+        private ItemsPresenter? _itemsPresenter;
+        private IScrollSnapPointsInfo? _scrolSnapPointInfo;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="ItemsControl"/> class.
@@ -203,6 +219,63 @@ namespace Avalonia.Controls
             remove => _childIndexChanged -= value;
         }
 
+
+        public event EventHandler<RoutedEventArgs> HorizontalSnapPointsChanged
+        {
+            add
+            {
+                if (_itemsPresenter != null)
+                {
+                    _itemsPresenter.HorizontalSnapPointsChanged += value;
+                }
+            }
+
+            remove
+            {
+                if (_itemsPresenter != null)
+                {
+                    _itemsPresenter.HorizontalSnapPointsChanged -= value;
+                }
+            }
+        }
+
+        public event EventHandler<RoutedEventArgs> VerticalSnapPointsChanged
+        {
+            add
+            {
+                if (_itemsPresenter != null)
+                {
+                    _itemsPresenter.VerticalSnapPointsChanged += value;
+                }
+            }
+
+            remove
+            {
+                if (_itemsPresenter != null)
+                {
+                    _itemsPresenter.VerticalSnapPointsChanged -= value;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets whether the horizontal snap points for the <see cref="ItemsControl"/> are equidistant from each other.
+        /// </summary>
+        public bool AreHorizontalSnapPointsRegular
+        {
+            get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
+            set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets whether the vertical snap points for the <see cref="ItemsControl"/> are equidistant from each other.
+        /// </summary>
+        public bool AreVerticalSnapPointsRegular
+        {
+            get { return GetValue(AreVerticalSnapPointsRegularProperty); }
+            set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
+        }
+
         /// <summary>
         /// Returns the container for the item at the specified index.
         /// </summary>
@@ -255,7 +328,6 @@ namespace Avalonia.Controls
         /// </summary>
         public IEnumerable<Control> GetRealizedContainers() => Presenter?.GetRealizedContainers() ?? Array.Empty<Control>();
 
-
         /// <summary>
         /// Creates or a container that can be used to display an item.
         /// </summary>
@@ -355,6 +427,9 @@ namespace Avalonia.Controls
         {
             base.OnApplyTemplate(e);
             _scrollViewer = e.NameScope.Find<ScrollViewer>("PART_ScrollViewer");
+            _itemsPresenter = e.NameScope.Find<ItemsPresenter>("PART_ItemsPresenter");
+
+            _scrolSnapPointInfo = _itemsPresenter as IScrollSnapPointsInfo;
         }
 
         /// <summary>
@@ -671,5 +746,17 @@ namespace Avalonia.Controls
             count = ItemsView.Count;
             return true;
         }
+
+        public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
+        {
+            return _itemsPresenter?.GetIrregularSnapPoints(orientation, snapPointsAlignment) ?? new List<double>();
+        }
+
+        public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
+        {
+            offset = 0;
+
+            return _itemsPresenter?.GetRegularSnapPoints(orientation, snapPointsAlignment, out offset) ?? 0;
+        }
     }
 }

+ 122 - 1
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@@ -3,13 +3,15 @@ using System.Collections.Generic;
 using System.Diagnostics;
 using Avalonia.Controls.Primitives;
 using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
 
 namespace Avalonia.Controls.Presenters
 {
     /// <summary>
     /// Presents items inside an <see cref="Avalonia.Controls.ItemsControl"/>.
     /// </summary>
-    public class ItemsPresenter : Control, ILogicalScrollable
+    public class ItemsPresenter : Control, ILogicalScrollable, IScrollSnapPointsInfo
     {
         /// <summary>
         /// Defines the <see cref="ItemsPanel"/> property.
@@ -19,8 +21,37 @@ namespace Avalonia.Controls.Presenters
 
         private PanelContainerGenerator? _generator;
         private ILogicalScrollable? _logicalScrollable;
+        private IScrollSnapPointsInfo? _scrollSnapPointsInfo;
         private EventHandler? _scrollInvalidated;
 
+        /// <summary>
+        /// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreHorizontalSnapPointsRegular));
+
+        /// <summary>
+        /// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreVerticalSnapPointsRegular));
+
+        /// <summary>
+        /// Defines the <see cref="HorizontalSnapPointsChanged"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> HorizontalSnapPointsChangedEvent =
+            RoutedEvent.Register<StackPanel, RoutedEventArgs>(
+                nameof(HorizontalSnapPointsChanged),
+                RoutingStrategies.Bubble);
+
+        /// <summary>
+        /// Defines the <see cref="VerticalSnapPointsChanged"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> VerticalSnapPointsChangedEvent =
+            RoutedEvent.Register<StackPanel, RoutedEventArgs>(
+                nameof(VerticalSnapPointsChanged),
+                RoutingStrategies.Bubble);
+
         static ItemsPresenter()
         {
             KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(
@@ -83,12 +114,48 @@ namespace Avalonia.Controls.Presenters
             }
         }
 
+        /// <summary>
+        /// Occurs when the measurements for horizontal snap points change.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs>? HorizontalSnapPointsChanged
+        {
+            add => AddHandler(HorizontalSnapPointsChangedEvent, value);
+            remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value);
+        }
+
+        /// <summary>
+        /// Occurs when the measurements for vertical snap points change.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs>? VerticalSnapPointsChanged
+        {
+            add => AddHandler(VerticalSnapPointsChangedEvent, value);
+            remove => RemoveHandler(VerticalSnapPointsChangedEvent, value);
+        }
+
         bool ILogicalScrollable.IsLogicalScrollEnabled => _logicalScrollable?.IsLogicalScrollEnabled ?? false;
         Size ILogicalScrollable.ScrollSize => _logicalScrollable?.ScrollSize ?? default;
         Size ILogicalScrollable.PageScrollSize => _logicalScrollable?.PageScrollSize ?? default;
         Size IScrollable.Extent => _logicalScrollable?.Extent ?? default;
         Size IScrollable.Viewport => _logicalScrollable?.Viewport ?? default;
 
+        /// <summary>
+        /// Gets or sets whether the horizontal snap points for the <see cref="ItemsControl"/> are equidistant from each other.
+        /// </summary>
+        public bool AreHorizontalSnapPointsRegular
+        {
+            get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
+            set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets whether the vertical snap points for the <see cref="ItemsControl"/> are equidistant from each other.
+        /// </summary>
+        public bool AreVerticalSnapPointsRegular
+        {
+            get { return GetValue(AreVerticalSnapPointsRegularProperty); }
+            set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
+        }
+
         public override sealed void ApplyTemplate()
         {
             if (Panel is null && ItemsControl is not null)
@@ -100,14 +167,36 @@ namespace Avalonia.Controls.Presenters
 
                 Panel = ItemsPanel.Build();
                 Panel.SetValue(TemplatedParentProperty, TemplatedParent);
+                _scrollSnapPointsInfo = Panel as IScrollSnapPointsInfo;
                 LogicalChildren.Add(Panel);
                 VisualChildren.Add(Panel);
 
+                if (_scrollSnapPointsInfo != null)
+                {
+                    _scrollSnapPointsInfo.AreVerticalSnapPointsRegular = AreVerticalSnapPointsRegular;
+                    _scrollSnapPointsInfo.AreHorizontalSnapPointsRegular = AreHorizontalSnapPointsRegular;
+                }
+
                 if (Panel is VirtualizingPanel v)
                     v.Attach(ItemsControl);
                 else
                     CreateSimplePanelGenerator();
 
+                if(Panel is IScrollSnapPointsInfo scrollSnapPointsInfo)
+                {
+                    scrollSnapPointsInfo.VerticalSnapPointsChanged += (s, e) =>
+                    {
+                        e.RoutedEvent = VerticalSnapPointsChangedEvent;
+                        RaiseEvent(e);
+                    };
+
+                    scrollSnapPointsInfo.HorizontalSnapPointsChanged += (s, e) =>
+                    {
+                        e.RoutedEvent = HorizontalSnapPointsChangedEvent;
+                        RaiseEvent(e);
+                    };
+                }
+
                 _logicalScrollable = Panel as ILogicalScrollable;
 
                 if (_logicalScrollable is not null)
@@ -151,6 +240,16 @@ namespace Avalonia.Controls.Presenters
                 ResetState();
                 InvalidateMeasure();
             }
+            else if(change.Property == AreHorizontalSnapPointsRegularProperty)
+            {
+                if (_scrollSnapPointsInfo != null)
+                    _scrollSnapPointsInfo.AreHorizontalSnapPointsRegular = AreHorizontalSnapPointsRegular;
+            }
+            else if (change.Property == AreVerticalSnapPointsRegularProperty)
+            {
+                if (_scrollSnapPointsInfo != null)
+                    _scrollSnapPointsInfo.AreVerticalSnapPointsRegular = AreVerticalSnapPointsRegular;
+            }
         }
 
         internal void Refresh()
@@ -204,5 +303,27 @@ namespace Avalonia.Controls.Presenters
         }
 
         private void OnLogicalScrollInvalidated(object? sender, EventArgs e) => _scrollInvalidated?.Invoke(this, e);
+
+        public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
+        {
+            if(Panel is IScrollSnapPointsInfo scrollSnapPointsInfo)
+            {
+                return scrollSnapPointsInfo.GetIrregularSnapPoints(orientation, snapPointsAlignment);
+            }
+
+            return new List<double>();
+        }
+
+        public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
+        {
+            if (Panel is IScrollSnapPointsInfo scrollSnapPointsInfo)
+            {
+                return scrollSnapPointsInfo.GetRegularSnapPoints(orientation, snapPointsAlignment, out offset);
+            }
+
+            offset = 0;
+
+            return 0;
+        }
     }
 }

+ 366 - 2
src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using Avalonia.Reactive;
 using Avalonia.Controls.Primitives;
 using Avalonia.Input;
+using Avalonia.Input.GestureRecognizers;
 using Avalonia.Utilities;
 using Avalonia.VisualTree;
 
@@ -14,6 +15,7 @@ namespace Avalonia.Controls.Presenters
     public class ScrollContentPresenter : ContentPresenter, IPresenter, IScrollable, IScrollAnchorProvider
     {
         private const double EdgeDetectionTolerance = 0.1;
+        private const int ProximityPoints = 10;
 
         /// <summary>
         /// Defines the <see cref="CanHorizontallyScroll"/> property.
@@ -57,6 +59,30 @@ namespace Avalonia.Controls.Presenters
                 o => o.Viewport,
                 (o, v) => o.Viewport = v);
 
+        /// <summary>
+        /// Defines the <see cref="HorizontalSnapPointsType"/> property.
+        /// </summary>
+        public static readonly StyledProperty<SnapPointsType> HorizontalSnapPointsTypeProperty =
+            ScrollViewer.HorizontalSnapPointsTypeProperty.AddOwner<ScrollContentPresenter>();
+
+        /// <summary>
+        /// Defines the <see cref="VerticalSnapPointsType"/> property.
+        /// </summary>
+        public static readonly StyledProperty<SnapPointsType> VerticalSnapPointsTypeProperty =
+           ScrollViewer.VerticalSnapPointsTypeProperty.AddOwner<ScrollContentPresenter>();
+
+        /// <summary>
+        /// Defines the <see cref="HorizontalSnapPointsAlignment"/> property.
+        /// </summary>
+        public static readonly StyledProperty<SnapPointsAlignment> HorizontalSnapPointsAlignmentProperty =
+            ScrollViewer.HorizontalSnapPointsAlignmentProperty.AddOwner<ScrollContentPresenter>();
+
+        /// <summary>
+        /// Defines the <see cref="VerticalSnapPointsAlignment"/> property.
+        /// </summary>
+        public static readonly StyledProperty<SnapPointsAlignment> VerticalSnapPointsAlignmentProperty =
+            ScrollViewer.VerticalSnapPointsAlignmentProperty.AddOwner<ScrollContentPresenter>();
+
         /// <summary>
         /// Defines the <see cref="IsScrollChainingEnabled"/> property.
         /// </summary>
@@ -71,10 +97,19 @@ namespace Avalonia.Controls.Presenters
         private IDisposable? _logicalScrollSubscription;
         private Size _viewport;
         private Dictionary<int, Vector>? _activeLogicalGestureScrolls;
+        private Dictionary<int, Vector>? _scrollGestureSnapPoints;
         private List<Control>? _anchorCandidates;
         private Control? _anchorElement;
         private Rect _anchorElementBounds;
         private bool _isAnchorElementDirty;
+        private bool _areVerticalSnapPointsRegular;
+        private bool _areHorizontalSnapPointsRegular;
+        private IReadOnlyList<double>? _horizontalSnapPoints;
+        private double _horizontalSnapPoint;
+        private IReadOnlyList<double>? _verticalSnapPoints;
+        private double _verticalSnapPoint;
+        private double _verticalSnapPointOffset;
+        private double _horizontalSnapPointOffset;
 
         /// <summary>
         /// Initializes static members of the <see cref="ScrollContentPresenter"/> class.
@@ -93,6 +128,7 @@ namespace Avalonia.Controls.Presenters
             AddHandler(RequestBringIntoViewEvent, BringIntoViewRequested);
             AddHandler(Gestures.ScrollGestureEvent, OnScrollGesture);
             AddHandler(Gestures.ScrollGestureEndedEvent, OnScrollGestureEnded);
+            AddHandler(Gestures.ScrollGestureInertiaStartingEvent, OnScrollGestureInertiaStartingEnded);
 
             this.GetObservable(ChildProperty).Subscribe(UpdateScrollableSubscription);
         }
@@ -142,6 +178,42 @@ namespace Avalonia.Controls.Presenters
             private set { SetAndRaise(ViewportProperty, ref _viewport, value); }
         }
 
+        /// <summary>
+        /// Gets or sets how scroll gesture reacts to the snap points along the horizontal axis.
+        /// </summary>
+        public SnapPointsType HorizontalSnapPointsType
+        {
+            get => GetValue(HorizontalSnapPointsTypeProperty);
+            set => SetValue(HorizontalSnapPointsTypeProperty, value);
+        }
+
+        /// <summary>
+        /// Gets or sets how scroll gesture reacts to the snap points along the vertical axis.
+        /// </summary>
+        public SnapPointsType VerticalSnapPointsType
+        {
+            get => GetValue(VerticalSnapPointsTypeProperty);
+            set => SetValue(VerticalSnapPointsTypeProperty, value);
+        }
+
+        /// <summary>
+        /// Gets or sets how the existing snap points are horizontally aligned versus the initial viewport.
+        /// </summary>
+        public SnapPointsAlignment HorizontalSnapPointsAlignment
+        {
+            get => GetValue(HorizontalSnapPointsAlignmentProperty);
+            set => SetValue(HorizontalSnapPointsAlignmentProperty, value);
+        }
+
+        /// <summary>
+        /// Gets or sets how the existing snap points are vertically aligned versus the initial viewport.
+        /// </summary>
+        public SnapPointsAlignment VerticalSnapPointsAlignment
+        {
+            get => GetValue(VerticalSnapPointsAlignmentProperty); 
+            set => SetValue(VerticalSnapPointsAlignmentProperty, value);
+        }
+
         /// <summary>
         ///  Gets or sets if scroll chaining is enabled. The default value is true.
         /// </summary>
@@ -424,6 +496,25 @@ namespace Avalonia.Controls.Presenters
                 }
 
                 Vector newOffset = new Vector(x, y);
+
+                if (_scrollGestureSnapPoints?.TryGetValue(e.Id, out var snapPoint) == true)
+                {
+                    double xOffset = x;
+                    double yOffset = y;
+
+                    if (HorizontalSnapPointsType != SnapPointsType.None)
+                    {
+                        xOffset = delta.X < 0 ? Math.Max(snapPoint.X, newOffset.X) : Math.Min(snapPoint.X, newOffset.X);
+                    }
+
+                    if (VerticalSnapPointsType != SnapPointsType.None)
+                    {
+                        yOffset = delta.Y < 0 ? Math.Max(snapPoint.Y, newOffset.Y) : Math.Min(snapPoint.Y, newOffset.Y);
+                    }
+
+                    newOffset = new Vector(xOffset, yOffset);
+                }
+
                 bool offsetChanged = newOffset != Offset;
                 Offset = newOffset;
 
@@ -434,7 +525,65 @@ namespace Avalonia.Controls.Presenters
         }
 
         private void OnScrollGestureEnded(object? sender, ScrollGestureEndedEventArgs e)
-            => _activeLogicalGestureScrolls?.Remove(e.Id);
+        {
+            _activeLogicalGestureScrolls?.Remove(e.Id);
+            _scrollGestureSnapPoints?.Remove(e.Id);
+
+            Offset = SnapOffset(Offset);
+        }
+
+        private void OnScrollGestureInertiaStartingEnded(object? sender, ScrollGestureInertiaStartingEventArgs e)
+        {
+            if (Content is not IScrollSnapPointsInfo)
+                return;
+
+            if (_scrollGestureSnapPoints == null)
+                _scrollGestureSnapPoints = new Dictionary<int, Vector>();
+
+            var offset = Offset;
+
+            if (HorizontalSnapPointsType != SnapPointsType.None && VerticalSnapPointsType != SnapPointsType.None)
+            {
+                return;
+            }
+
+            double xDistance = 0;
+            double yDistance = 0;
+
+            if (HorizontalSnapPointsType != SnapPointsType.None)
+            {
+                xDistance = HorizontalSnapPointsType == SnapPointsType.Mandatory ? GetDistance(e.Inertia.X) : 0;
+            }
+
+            if (VerticalSnapPointsType != SnapPointsType.None)
+            {
+                yDistance = VerticalSnapPointsType == SnapPointsType.Mandatory ? GetDistance(e.Inertia.Y) : 0;
+            }
+
+            offset = new Vector(offset.X + xDistance, offset.Y + yDistance);
+
+            System.Diagnostics.Debug.WriteLine($"{offset}");
+
+            _scrollGestureSnapPoints.Add(e.Id, SnapOffset(offset));
+
+            double GetDistance(double speed)
+            {
+                var time = Math.Log(ScrollGestureRecognizer.InertialScrollSpeedEnd / Math.Abs(speed)) / Math.Log(ScrollGestureRecognizer.InertialResistance);
+
+                double timeElapsed = 0, distance = 0, step = 0;
+
+                while (timeElapsed <= time)
+                {
+                    double s = speed * Math.Pow(ScrollGestureRecognizer.InertialResistance, timeElapsed);
+                    distance += (s * step);
+
+                    timeElapsed += 0.016f;
+                    step = 0.016f;
+                }
+
+                return distance;
+            }
+        }
 
         /// <inheritdoc/>
         protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
@@ -458,6 +607,30 @@ namespace Avalonia.Controls.Presenters
                 if (Extent.Height > Viewport.Height)
                 {
                     double height = isLogical ? scrollable!.ScrollSize.Height : 50;
+                    if(VerticalSnapPointsType == SnapPointsType.MandatorySingle && Content is IScrollSnapPointsInfo)
+                    {
+                        if(_areVerticalSnapPointsRegular)
+                        {
+                            height = _verticalSnapPoint;
+                        }
+                        else if(_verticalSnapPoints != null)
+                        {
+                            double yOffset = Offset.Y;
+                            switch (VerticalSnapPointsAlignment)
+                            {
+                                case SnapPointsAlignment.Center:
+                                    yOffset += Viewport.Height / 2;
+                                    break;
+                                case SnapPointsAlignment.Far:
+                                    yOffset += Viewport.Height;
+                                    break;
+                            }
+
+                            var snapPoint = FindNearestSnapPoint(_verticalSnapPoints, yOffset, out var lowerSnapPoint);
+
+                            height = snapPoint - lowerSnapPoint;
+                        }
+                    }
                     y += -delta.Y * height;
                     y = Math.Max(y, 0);
                     y = Math.Min(y, Extent.Height - Viewport.Height);
@@ -466,12 +639,37 @@ namespace Avalonia.Controls.Presenters
                 if (Extent.Width > Viewport.Width)
                 {
                     double width = isLogical ? scrollable!.ScrollSize.Width : 50;
+                    if (HorizontalSnapPointsType == SnapPointsType.MandatorySingle && Content is IScrollSnapPointsInfo)
+                    {
+                        if (_areHorizontalSnapPointsRegular)
+                        {
+                            width = _horizontalSnapPoint;
+                        }
+                        else if(_horizontalSnapPoints != null)
+                        {
+                            double xOffset = Offset.X;
+                            switch (VerticalSnapPointsAlignment)
+                            {
+                                case SnapPointsAlignment.Center:
+                                    xOffset += Viewport.Width / 2;
+                                    break;
+                                case SnapPointsAlignment.Far:
+                                    xOffset += Viewport.Width;
+                                    break;
+                            }
+
+                            var snapPoint = FindNearestSnapPoint(_horizontalSnapPoints, xOffset, out var lowerSnapPoint);
+
+                            width = snapPoint - lowerSnapPoint;
+                        }
+                    }
                     x += -delta.X * width;
                     x = Math.Max(x, 0);
                     x = Math.Min(x, Extent.Width - Viewport.Width);
                 }
 
-                Vector newOffset = new Vector(x, y);
+                Vector newOffset = SnapOffset(new Vector(x, y));
+
                 bool offsetChanged = newOffset != Offset;
                 Offset = newOffset;
 
@@ -485,10 +683,36 @@ namespace Avalonia.Controls.Presenters
             {
                 InvalidateArrange();
             }
+            else if (change.Property == ContentProperty)
+            {
+                if (change.OldValue is IScrollSnapPointsInfo oldSnapPointsInfo)
+                {
+                    oldSnapPointsInfo.VerticalSnapPointsChanged -= ScrollSnapPointsInfoSnapPointsChanged;
+                    oldSnapPointsInfo.HorizontalSnapPointsChanged += ScrollSnapPointsInfoSnapPointsChanged;
+                }
+
+                if (Content is IScrollSnapPointsInfo scrollSnapPointsInfo)
+                {
+                    scrollSnapPointsInfo.VerticalSnapPointsChanged += ScrollSnapPointsInfoSnapPointsChanged;
+                    scrollSnapPointsInfo.HorizontalSnapPointsChanged += ScrollSnapPointsInfoSnapPointsChanged;
+                }
+
+                UpdateSnapPoints();
+            }
+            else if (change.Property == HorizontalSnapPointsAlignmentProperty ||
+                change.Property == VerticalSnapPointsAlignmentProperty)
+            {
+                UpdateSnapPoints();
+            }
 
             base.OnPropertyChanged(change);
         }
 
+        private void ScrollSnapPointsInfoSnapPointsChanged(object? sender, Interactivity.RoutedEventArgs e)
+        {
+            UpdateSnapPoints();
+        }
+
         private void BringIntoViewRequested(object? sender, RequestBringIntoViewEventArgs e)
         {
             if (e.TargetObject is not null)
@@ -635,5 +859,145 @@ namespace Avalonia.Controls.Presenters
             bounds = p.HasValue ? new Rect(p.Value, control.Bounds.Size) : default;
             return p.HasValue;
         }
+
+        private void UpdateSnapPoints()
+        {
+            if (Content is IScrollSnapPointsInfo scrollSnapPointsInfo)
+            {
+                _areVerticalSnapPointsRegular = scrollSnapPointsInfo.AreVerticalSnapPointsRegular;
+                _areHorizontalSnapPointsRegular = scrollSnapPointsInfo.AreHorizontalSnapPointsRegular;
+
+                if (!_areVerticalSnapPointsRegular)
+                {
+                    _verticalSnapPoints = scrollSnapPointsInfo.GetIrregularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment);
+                }
+                else
+                {
+                    _verticalSnapPoints = new List<double>();
+                    _verticalSnapPoint = scrollSnapPointsInfo.GetRegularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment, out _verticalSnapPointOffset);
+
+                }
+
+                if (!_areHorizontalSnapPointsRegular)
+                {
+                    _horizontalSnapPoints = scrollSnapPointsInfo.GetIrregularSnapPoints(Layout.Orientation.Horizontal, HorizontalSnapPointsAlignment);
+                }
+                else
+                {
+                    _horizontalSnapPoints = new List<double>();
+                    _horizontalSnapPoint = scrollSnapPointsInfo.GetRegularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment, out _horizontalSnapPointOffset);
+                }
+            }
+            else
+            {
+                _horizontalSnapPoints = new List<double>();
+                _verticalSnapPoints = new List<double>();
+            }
+        }
+
+        private Vector SnapOffset(Vector offset)
+        {
+            if(Content is not IScrollSnapPointsInfo)
+                return offset;
+
+            var diff = GetAlignedDiff();
+
+            if (VerticalSnapPointsType != SnapPointsType.None)
+            {
+                offset = new Vector(offset.X, offset.Y + diff.Y);
+                double nearestSnapPoint = offset.Y;
+
+                if (_areVerticalSnapPointsRegular)
+                {
+                    var minSnapPoint = (int)(offset.Y / _verticalSnapPoint) * _verticalSnapPoint + _verticalSnapPointOffset;
+                    var maxSnapPoint = minSnapPoint + _verticalSnapPoint;
+                    var midPoint = (minSnapPoint + maxSnapPoint) / 2;
+
+                    nearestSnapPoint = offset.Y < midPoint ? minSnapPoint : maxSnapPoint;
+                }
+                else if (_verticalSnapPoints != null && _verticalSnapPoints.Count > 0)
+                {
+                    var higherSnapPoint = FindNearestSnapPoint(_verticalSnapPoints, offset.Y, out var lowerSnapPoint);
+                    var midPoint = (lowerSnapPoint + higherSnapPoint) / 2;
+
+                    nearestSnapPoint = offset.Y < midPoint ? lowerSnapPoint : higherSnapPoint;
+                }
+
+                offset = new Vector(offset.X, nearestSnapPoint - diff.Y);
+            }
+
+            if (HorizontalSnapPointsType != SnapPointsType.None)
+            {
+                offset = new Vector(offset.X + diff.X, offset.Y);
+                double nearestSnapPoint = offset.X;
+
+                if (_areHorizontalSnapPointsRegular)
+                {
+                    var minSnapPoint = (int)(offset.X / _horizontalSnapPoint) * _horizontalSnapPoint + _horizontalSnapPointOffset;
+                    var maxSnapPoint = minSnapPoint + _horizontalSnapPoint;
+                    var midPoint = (minSnapPoint + maxSnapPoint) / 2;
+
+                    nearestSnapPoint = offset.X < midPoint ? minSnapPoint : maxSnapPoint;
+                }
+                else if (_horizontalSnapPoints != null && _horizontalSnapPoints.Count > 0)
+                {
+                    var higherSnapPoint = FindNearestSnapPoint(_horizontalSnapPoints, offset.X, out var lowerSnapPoint);
+                    var midPoint = (lowerSnapPoint + higherSnapPoint) / 2;
+
+                    nearestSnapPoint = offset.X < midPoint ? lowerSnapPoint : higherSnapPoint;
+                }
+
+                offset = new Vector(nearestSnapPoint - diff.X, offset.Y);
+
+            }
+
+            Vector GetAlignedDiff()
+            {
+                var vector = offset;
+
+                switch (VerticalSnapPointsAlignment)
+                {
+                    case SnapPointsAlignment.Center:
+                        vector += new Vector(0, Viewport.Height / 2);
+                        break;
+                    case SnapPointsAlignment.Far:
+                        vector += new Vector(0, Viewport.Height);
+                        break;
+                }
+
+                switch (HorizontalSnapPointsAlignment)
+                {
+                    case SnapPointsAlignment.Center:
+                        vector += new Vector(Viewport.Width / 2, 0);
+                        break;
+                    case SnapPointsAlignment.Far:
+                        vector += new Vector(Viewport.Width, 0);
+                        break;
+                }                
+
+                return vector - offset;
+            }
+
+            return offset;
+        }
+
+        private static double FindNearestSnapPoint(IReadOnlyList<double> snapPoints, double value, out double lowerSnapPoint)
+        {
+            var point = snapPoints.BinarySearch(value, Comparer<double>.Default);
+
+            if (point < 0)
+            {
+                point = ~point;
+
+                lowerSnapPoint = snapPoints[Math.Max(0, point - 1)];
+            }
+            else
+            {
+                lowerSnapPoint = snapPoints[point];
+
+                point += 1;
+            }
+            return snapPoints[Math.Min(point, snapPoints.Count - 1)];
+        }
     }
 }

+ 50 - 0
src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs

@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+
+namespace Avalonia.Controls.Primitives
+{
+    /// <summary>
+    /// Describes snap point behavior for objects that contain and present items.
+    /// </summary>
+    public interface IScrollSnapPointsInfo
+    {
+        /// <summary>
+        /// Gets or sets a value that indicates whether the horizontal snap points for the container are equidistant from each other.
+        /// </summary>
+        bool AreHorizontalSnapPointsRegular { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value that indicates whether the vertical snap points for the container are equidistant from each other.
+        /// </summary>
+        bool AreVerticalSnapPointsRegular { get; set; }
+
+        /// <summary>
+        /// Returns the set of distances between irregular snap points for a specified orientation and alignment.
+        /// </summary>
+        /// <param name="orientation">The orientation for the desired snap point set.</param>
+        /// <param name="snapPointsAlignment">The alignment to use when applying the snap points.</param>
+        /// <returns>The read-only collection of snap point distances. Returns an empty collection when no snap points are present.</returns>
+        IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment);
+
+        /// <summary>
+        /// Gets the distance between regular snap points for a specified orientation and alignment.
+        /// </summary>
+        /// <param name="orientation">The orientation for the desired snap point set.</param>
+        /// <param name="snapPointsAlignment">The alignment to use when applying the snap points.</param>
+        /// <param name="offset">Out parameter. The offset of the first snap point.</param>
+        /// <returns>The distance between the equidistant snap points. Returns 0 when no snap points are present.</returns>
+        double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset);
+
+        /// <summary>
+        /// Occurs when the measurements for horizontal snap points change.
+        /// </summary>
+        event EventHandler<RoutedEventArgs> HorizontalSnapPointsChanged;
+
+        /// <summary>
+        /// Occurs when the measurements for vertical snap points change.
+        /// </summary>
+        event EventHandler<RoutedEventArgs> VerticalSnapPointsChanged;
+    }
+}

+ 23 - 0
src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs

@@ -0,0 +1,23 @@
+namespace Avalonia.Controls.Primitives
+{
+    /// <summary>
+    /// Specify options for snap point alignment relative to an edge. Which edge depends on the orientation of the object where the alignment is applied
+    /// </summary>
+    public enum SnapPointsAlignment
+    {
+        /// <summary>
+        /// Use snap points grouped closer to the orientation edge.
+        /// </summary>
+        Near,
+
+        /// <summary>
+        /// Use snap points that are centered in the orientation.
+        /// </summary>
+        Center,
+
+        /// <summary>
+        /// Use snap points grouped farther from the orientation edge.
+        /// </summary>
+        Far
+    }
+}

+ 23 - 0
src/Avalonia.Controls/Primitives/SnapPointsType.cs

@@ -0,0 +1,23 @@
+namespace Avalonia.Controls.Primitives
+{
+    /// <summary>
+    /// Specify how panning snap points are processed for gesture input.
+    /// </summary>
+    public enum SnapPointsType
+    {
+        /// <summary>
+        /// No snapping behavior.
+        /// </summary>
+        None,
+
+        /// <summary>
+        /// Content always stops at the snap point closest to where inertia would naturally stop along the direction of inertia.
+        /// </summary>
+        Mandatory,
+
+        /// <summary>
+        /// Content always stops at the snap point closest to the release point along the direction of inertia.
+        /// </summary>
+        MandatorySingle
+    }
+}

+ 64 - 0
src/Avalonia.Controls/ScrollViewer.cs

@@ -151,6 +151,34 @@ namespace Avalonia.Controls
                 o => o.VerticalScrollBarValue,
                 (o, v) => o.VerticalScrollBarValue = v);
 
+        /// <summary>
+        /// Defines the <see cref="HorizontalSnapPointsType"/> property.
+        /// </summary>
+        public static readonly StyledProperty<SnapPointsType> HorizontalSnapPointsTypeProperty =
+            AvaloniaProperty.Register<ScrollViewer, SnapPointsType>(
+                nameof(HorizontalSnapPointsType));
+
+        /// <summary>
+        /// Defines the <see cref="VerticalSnapPointsType"/> property.
+        /// </summary>
+        public static readonly StyledProperty<SnapPointsType> VerticalSnapPointsTypeProperty =
+            AvaloniaProperty.Register<ScrollViewer, SnapPointsType>(
+                nameof(VerticalSnapPointsType));
+
+        /// <summary>
+        /// Defines the <see cref="HorizontalSnapPointsAlignment"/> property.
+        /// </summary>
+        public static readonly AttachedProperty<SnapPointsAlignment> HorizontalSnapPointsAlignmentProperty =
+            AvaloniaProperty.RegisterAttached<ScrollViewer, Control, SnapPointsAlignment>(
+                nameof(HorizontalSnapPointsAlignment));
+
+        /// <summary>
+        /// Defines the <see cref="VerticalSnapPointsAlignment"/> property.
+        /// </summary>
+        public static readonly AttachedProperty<SnapPointsAlignment> VerticalSnapPointsAlignmentProperty =
+            AvaloniaProperty.RegisterAttached<ScrollViewer, Control, SnapPointsAlignment>(
+                nameof(VerticalSnapPointsAlignment));
+
         /// <summary>
         /// Defines the VerticalScrollBarViewportSize property.
         /// </summary>
@@ -429,6 +457,42 @@ namespace Avalonia.Controls
             private set => SetAndRaise(ScrollBar.IsExpandedProperty, ref _isExpanded, value);
         }
 
+        /// <summary>
+        /// Gets or sets how scroll gesture reacts to the snap points along the horizontal axis.
+        /// </summary>
+        public SnapPointsType HorizontalSnapPointsType
+        {
+            get => GetValue(HorizontalSnapPointsTypeProperty);
+            set => SetValue(HorizontalSnapPointsTypeProperty, value);
+        }
+
+        /// <summary>
+        /// Gets or sets how scroll gesture reacts to the snap points along the vertical axis.
+        /// </summary>
+        public SnapPointsType VerticalSnapPointsType
+        {
+            get => GetValue(VerticalSnapPointsTypeProperty);
+            set => SetValue(VerticalSnapPointsTypeProperty, value);
+        }
+
+        /// <summary>
+        /// Gets or sets how the existing snap points are horizontally aligned versus the initial viewport.
+        /// </summary>
+        public SnapPointsAlignment HorizontalSnapPointsAlignment
+        {
+            get => GetValue(HorizontalSnapPointsAlignmentProperty); 
+            set => SetValue(HorizontalSnapPointsAlignmentProperty, value);
+        }
+
+        /// <summary>
+        /// Gets or sets how the existing snap points are vertically aligned versus the initial viewport.
+        /// </summary>
+        public SnapPointsAlignment VerticalSnapPointsAlignment
+        {
+            get => GetValue(VerticalSnapPointsAlignmentProperty); 
+            set => SetValue(VerticalSnapPointsAlignmentProperty, value);
+        }
+
         /// <summary>
         /// Gets a value that indicates whether scrollbars can hide itself when user is not interacting with it.
         /// </summary>

+ 190 - 1
src/Avalonia.Controls/StackPanel.cs

@@ -4,7 +4,11 @@
 // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
 
 using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Controls.Primitives;
 using Avalonia.Input;
+using Avalonia.Interactivity;
 using Avalonia.Layout;
 
 namespace Avalonia.Controls
@@ -12,7 +16,7 @@ namespace Avalonia.Controls
     /// <summary>
     /// A panel which lays out its children horizontally or vertically.
     /// </summary>
-    public class StackPanel : Panel, INavigableContainer
+    public class StackPanel : Panel, INavigableContainer, IScrollSnapPointsInfo
     {
         /// <summary>
         /// Defines the <see cref="Spacing"/> property.
@@ -26,6 +30,34 @@ namespace Avalonia.Controls
         public static readonly StyledProperty<Orientation> OrientationProperty =
             StackLayout.OrientationProperty.AddOwner<StackPanel>();
 
+        /// <summary>
+        /// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<StackPanel, bool>(nameof(AreHorizontalSnapPointsRegular));
+
+        /// <summary>
+        /// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<StackPanel, bool>(nameof(AreVerticalSnapPointsRegular));
+
+        /// <summary>
+        /// Defines the <see cref="HorizontalSnapPointsChanged"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> HorizontalSnapPointsChangedEvent =
+            RoutedEvent.Register<StackPanel, RoutedEventArgs>(
+                nameof(HorizontalSnapPointsChanged),
+                RoutingStrategies.Bubble);
+
+        /// <summary>
+        /// Defines the <see cref="VerticalSnapPointsChanged"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> VerticalSnapPointsChangedEvent =
+            RoutedEvent.Register<StackPanel, RoutedEventArgs>(
+                nameof(VerticalSnapPointsChanged),
+                RoutingStrategies.Bubble);
+
         /// <summary>
         /// Initializes static members of the <see cref="StackPanel"/> class.
         /// </summary>
@@ -53,6 +85,42 @@ namespace Avalonia.Controls
             set { SetValue(OrientationProperty, value); }
         }
 
+        /// <summary>
+        /// Occurs when the measurements for horizontal snap points change.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs>? HorizontalSnapPointsChanged
+        {
+            add => AddHandler(HorizontalSnapPointsChangedEvent, value);
+            remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value);
+        }
+
+        /// <summary>
+        /// Occurs when the measurements for vertical snap points change.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs>? VerticalSnapPointsChanged
+        {
+            add => AddHandler(VerticalSnapPointsChangedEvent, value);
+            remove => RemoveHandler(VerticalSnapPointsChangedEvent, value);
+        }
+
+        /// <summary>
+        /// Gets or sets whether the horizontal snap points for the <see cref="StackPanel"/> are equidistant from each other.
+        /// </summary>
+        public bool AreHorizontalSnapPointsRegular
+        {
+            get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
+            set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets whether the vertical snap points for the <see cref="StackPanel"/> are equidistant from each other.
+        /// </summary>
+        public bool AreVerticalSnapPointsRegular
+        {
+            get { return GetValue(AreVerticalSnapPointsRegularProperty); }
+            set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
+        }
+
         /// <summary>
         /// Gets the next control in the specified direction.
         /// </summary>
@@ -274,6 +342,8 @@ namespace Avalonia.Controls
                 ArrangeChild(child, rcChild, finalSize, Orientation);
             }
 
+            RaiseEvent(new RoutedEventArgs(Orientation == Orientation.Horizontal ? HorizontalSnapPointsChangedEvent : VerticalSnapPointsChangedEvent));
+
             return finalSize;
         }
 
@@ -285,5 +355,124 @@ namespace Avalonia.Controls
         {
             child.Arrange(rect);
         }
+
+        /// <inheritdoc/>
+        public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
+        {
+            var snapPoints = new List<double>();
+
+            switch (orientation)
+            {
+                case Orientation.Horizontal:
+                    if (AreHorizontalSnapPointsRegular)
+                        throw new InvalidOperationException();
+                    if (Orientation == Orientation.Horizontal)
+                    {
+                        foreach(var child in VisualChildren)
+                        {
+                            double snapPoint = 0;
+
+                            switch (snapPointsAlignment)
+                            {
+                                case SnapPointsAlignment.Near:
+                                    snapPoint = child.Bounds.Left;
+                                    break;
+                                case SnapPointsAlignment.Center:
+                                    snapPoint = child.Bounds.Center.X;
+                                    break;
+                                case SnapPointsAlignment.Far:
+                                    snapPoint = child.Bounds.Right;
+                                    break;
+                            }
+
+                            snapPoints.Add(snapPoint);
+                        }
+                    }
+                    break;
+                case Orientation.Vertical:
+                    if (AreVerticalSnapPointsRegular)
+                        throw new InvalidOperationException();
+                    if (Orientation == Orientation.Vertical)
+                    {
+                        foreach (var child in VisualChildren)
+                        {
+                            double snapPoint = 0;
+
+                            switch (snapPointsAlignment)
+                            {
+                                case SnapPointsAlignment.Near:
+                                    snapPoint = child.Bounds.Top;
+                                    break;
+                                case SnapPointsAlignment.Center:
+                                    snapPoint = child.Bounds.Center.Y;
+                                    break;
+                                case SnapPointsAlignment.Far:
+                                    snapPoint = child.Bounds.Bottom;
+                                    break;
+                            }
+
+                            snapPoints.Add(snapPoint);
+                        }
+                    }
+                    break;
+            }
+
+            return snapPoints;
+        }
+
+        /// <inheritdoc/>
+        public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
+        {
+            offset = 0f;
+            var firstChild = VisualChildren.FirstOrDefault();
+
+            if(firstChild == null)
+            {
+                return 0;
+            }
+
+            double snapPoint = 0;
+
+            switch (Orientation)
+            {
+                case Orientation.Horizontal:
+                    if (!AreHorizontalSnapPointsRegular)
+                        throw new InvalidOperationException();
+
+                    snapPoint = firstChild.Bounds.Width;
+                    switch (snapPointsAlignment)
+                    {
+                        case SnapPointsAlignment.Near:
+                            offset = firstChild.Bounds.Left;
+                            break;
+                        case SnapPointsAlignment.Center:
+                            offset = firstChild.Bounds.Center.X;
+                            break;
+                        case SnapPointsAlignment.Far:
+                            offset = firstChild.Bounds.Right;
+                            break;
+                    }
+                    break;
+                case Orientation.Vertical:
+                    if (!AreVerticalSnapPointsRegular)
+                        throw new InvalidOperationException();
+                    snapPoint = firstChild.Bounds.Height;
+                    switch (snapPointsAlignment)
+                    {
+                        case SnapPointsAlignment.Near:
+                            offset = firstChild.Bounds.Top;
+                            break;
+                        case SnapPointsAlignment.Center:
+                            offset = firstChild.Bounds.Center.Y;
+                            break;
+                        case SnapPointsAlignment.Far:
+                            offset = firstChild.Bounds.Bottom;
+                            break;
+                    }
+                    break;
+            }
+
+            return snapPoint + Spacing;
+        }
     }
 }

+ 231 - 1
src/Avalonia.Controls/VirtualizingStackPanel.cs

@@ -3,8 +3,11 @@ using System.Collections.Generic;
 using System.Collections.Specialized;
 using System.Diagnostics;
 using System.Linq;
+using System.Reflection;
+using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Utils;
 using Avalonia.Input;
+using Avalonia.Interactivity;
 using Avalonia.Layout;
 using Avalonia.Utilities;
 using Avalonia.VisualTree;
@@ -14,7 +17,7 @@ namespace Avalonia.Controls
     /// <summary>
     /// Arranges and virtualizes content on a single line that is oriented either horizontally or vertically.
     /// </summary>
-    public class VirtualizingStackPanel : VirtualizingPanel
+    public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo
     {
         /// <summary>
         /// Defines the <see cref="Orientation"/> property.
@@ -22,6 +25,34 @@ namespace Avalonia.Controls
         public static readonly StyledProperty<Orientation> OrientationProperty =
             StackLayout.OrientationProperty.AddOwner<VirtualizingStackPanel>();
 
+        /// <summary>
+        /// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<VirtualizingStackPanel, bool>(nameof(AreHorizontalSnapPointsRegular));
+
+        /// <summary>
+        /// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
+            AvaloniaProperty.Register<VirtualizingStackPanel, bool>(nameof(AreVerticalSnapPointsRegular));
+
+        /// <summary>
+        /// Defines the <see cref="HorizontalSnapPointsChanged"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> HorizontalSnapPointsChangedEvent =
+            RoutedEvent.Register<VirtualizingStackPanel, RoutedEventArgs>(
+                nameof(HorizontalSnapPointsChanged),
+                RoutingStrategies.Bubble);
+
+        /// <summary>
+        /// Defines the <see cref="VerticalSnapPointsChanged"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> VerticalSnapPointsChangedEvent =
+            RoutedEvent.Register<VirtualizingStackPanel, RoutedEventArgs>(
+                nameof(VerticalSnapPointsChanged),
+                RoutingStrategies.Bubble);
+
         private static readonly AttachedProperty<bool> ItemIsOwnContainerProperty =
             AvaloniaProperty.RegisterAttached<VirtualizingStackPanel, Control, bool>("ItemIsOwnContainer");
 
@@ -62,6 +93,42 @@ namespace Avalonia.Controls
             set => SetValue(OrientationProperty, value);
         }
 
+        /// <summary>
+        /// Occurs when the measurements for horizontal snap points change.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs>? HorizontalSnapPointsChanged
+        {
+            add => AddHandler(HorizontalSnapPointsChangedEvent, value);
+            remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value);
+        }
+
+        /// <summary>
+        /// Occurs when the measurements for vertical snap points change.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs>? VerticalSnapPointsChanged
+        {
+            add => AddHandler(VerticalSnapPointsChangedEvent, value);
+            remove => RemoveHandler(VerticalSnapPointsChangedEvent, value);
+        }
+
+        /// <summary>
+        /// Gets or sets whether the horizontal snap points for the <see cref="VirtualizingStackPanel"/> are equidistant from each other.
+        /// </summary>
+        public bool AreHorizontalSnapPointsRegular
+        {
+            get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
+            set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets whether the vertical snap points for the <see cref="VirtualizingStackPanel"/> are equidistant from each other.
+        /// </summary>
+        public bool AreVerticalSnapPointsRegular
+        {
+            get { return GetValue(AreVerticalSnapPointsRegularProperty); }
+            set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
+        }
+
         protected override Size MeasureOverride(Size availableSize)
         {
             if (!IsEffectivelyVisible)
@@ -145,6 +212,8 @@ namespace Avalonia.Controls
             finally
             {
                 _isInLayout = false;
+
+                RaiseEvent(new RoutedEventArgs(Orientation == Orientation.Horizontal ? HorizontalSnapPointsChangedEvent : VerticalSnapPointsChangedEvent));
             }
         }
 
@@ -622,6 +691,167 @@ namespace Avalonia.Controls
                 Invalidate(c);
         }
 
+        /// <inheritdoc/>
+        public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
+        {
+            var snapPoints = new List<double>();
+
+            switch (orientation)
+            {
+                case Orientation.Horizontal:
+                    if (AreHorizontalSnapPointsRegular)
+                        throw new InvalidOperationException();
+                    if (Orientation == Orientation.Horizontal)
+                    {
+                        var averageElementSize = EstimateElementSizeU();
+                        double snapPoint = 0;
+                        for (var i = 0; i < Items.Count; i++)
+                        {
+                            var container = ContainerFromIndex(i);
+                            if (container != null)
+                            {
+                                switch (snapPointsAlignment)
+                                {
+                                    case SnapPointsAlignment.Near:
+                                        snapPoint = container.Bounds.Left;
+                                        break;
+                                    case SnapPointsAlignment.Center:
+                                        snapPoint = container.Bounds.Center.X;
+                                        break;
+                                    case SnapPointsAlignment.Far:
+                                        snapPoint = container.Bounds.Right;
+                                        break;
+                                }
+                            }
+                            else
+                            {
+                                if (snapPoint == 0)
+                                {
+                                    switch (snapPointsAlignment)
+                                    {
+                                        case SnapPointsAlignment.Center:
+                                            snapPoint = averageElementSize / 2;
+                                            break;
+                                        case SnapPointsAlignment.Far:
+                                            snapPoint = averageElementSize;
+                                            break;
+                                    }
+                                }
+                                else
+                                    snapPoint += averageElementSize;
+                            }
+
+                            snapPoints.Add(snapPoint);
+                        }
+                    }
+                    break;
+                case Orientation.Vertical:
+                    if (AreVerticalSnapPointsRegular)
+                        throw new InvalidOperationException();
+                    if (Orientation == Orientation.Vertical)
+                    {
+                        var averageElementSize = EstimateElementSizeU();
+                        double snapPoint = 0;
+                        for (var i = 0; i < Items.Count; i++)
+                        {
+                            var container = ContainerFromIndex(i);
+                            if (container != null)
+                            {
+                                switch (snapPointsAlignment)
+                                {
+                                    case SnapPointsAlignment.Near:
+                                        snapPoint = container.Bounds.Top;
+                                        break;
+                                    case SnapPointsAlignment.Center:
+                                        snapPoint = container.Bounds.Center.Y;
+                                        break;
+                                    case SnapPointsAlignment.Far:
+                                        snapPoint = container.Bounds.Bottom;
+                                        break;
+                                }
+                            }
+                            else
+                            {
+                                if (snapPoint == 0)
+                                {
+                                    switch (snapPointsAlignment)
+                                    {
+                                        case SnapPointsAlignment.Center:
+                                            snapPoint = averageElementSize / 2;
+                                            break;
+                                        case SnapPointsAlignment.Far:
+                                            snapPoint = averageElementSize;
+                                            break;
+                                    }
+                                }
+                                else
+                                    snapPoint += averageElementSize;
+                            }
+
+                            snapPoints.Add(snapPoint);
+                        }
+                    }
+                    break;
+            }
+
+            return snapPoints;
+        }
+
+        /// <inheritdoc/>
+        public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
+        {
+            offset = 0f;
+            var firstRealizedChild = _realizedElements?.Elements.FirstOrDefault();
+
+            if (firstRealizedChild == null)
+            {
+                return 0;
+            }
+
+            double snapPoint = 0;
+
+            switch (Orientation)
+            {
+                case Orientation.Horizontal:
+                    if (!AreHorizontalSnapPointsRegular)
+                        throw new InvalidOperationException();
+
+                    snapPoint = firstRealizedChild.Bounds.Width;
+                    switch (snapPointsAlignment)
+                    {
+                        case SnapPointsAlignment.Near:
+                            offset = 0;
+                            break;
+                        case SnapPointsAlignment.Center:
+                            offset = (firstRealizedChild.Bounds.Right - firstRealizedChild.Bounds.Left) / 2;
+                            break;
+                        case SnapPointsAlignment.Far:
+                            offset = firstRealizedChild.Bounds.Width;
+                            break;
+                    }
+                    break;
+                case Orientation.Vertical:
+                    if (!AreVerticalSnapPointsRegular)
+                        throw new InvalidOperationException();
+                    snapPoint = firstRealizedChild.Bounds.Height;
+                    switch (snapPointsAlignment)
+                    {
+                        case SnapPointsAlignment.Near:
+                            offset = 0;
+                            break;
+                        case SnapPointsAlignment.Center:
+                            offset = (firstRealizedChild.Bounds.Bottom - firstRealizedChild.Bounds.Top) / 2;
+                            break;
+                        case SnapPointsAlignment.Far:
+                            offset = firstRealizedChild.Bounds.Height;
+                            break;
+                    }
+                    break;
+            }
+
+            return snapPoint;
+        }
+
         /// <summary>
         /// Stores the realized element state for a <see cref="VirtualizingStackPanel"/>.
         /// </summary>

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

@@ -9,6 +9,8 @@
                 CornerRadius="{TemplateBinding CornerRadius}"
                 Padding="{TemplateBinding Padding}">
           <ItemsPresenter Name="PART_ItemsPresenter"
+                          AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
+                          AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
                           ItemsPanel="{TemplateBinding ItemsPanel}"/>
         </Border>
       </ControlTemplate>

+ 4 - 0
src/Avalonia.Themes.Fluent/Controls/ListBox.xaml

@@ -29,11 +29,15 @@
                 BorderThickness="{TemplateBinding BorderThickness}"
                 CornerRadius="{TemplateBinding CornerRadius}">
           <ScrollViewer Name="PART_ScrollViewer"
+                        VerticalSnapPointsType="{TemplateBinding (ScrollViewer.VerticalSnapPointsType)}"
+                        HorizontalSnapPointsType="{TemplateBinding (ScrollViewer.HorizontalSnapPointsType)}"
                         HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
                         VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}"
                         IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}"
                         AllowAutoHide="{TemplateBinding (ScrollViewer.AllowAutoHide)}">
             <ItemsPresenter Name="PART_ItemsPresenter"
+                            AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
+                            AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
                             ItemsPanel="{TemplateBinding ItemsPanel}"
                             Margin="{TemplateBinding Padding}"/>
           </ScrollViewer>

+ 4 - 0
src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml

@@ -34,6 +34,10 @@
                                   Content="{TemplateBinding Content}"
                                   Extent="{TemplateBinding Extent, Mode=TwoWay}"
                                   Padding="{TemplateBinding Padding}"
+                                  HorizontalSnapPointsType="{TemplateBinding HorizontalSnapPointsType}"
+                                  VerticalSnapPointsType="{TemplateBinding VerticalSnapPointsType}"
+                                  HorizontalSnapPointsAlignment="{TemplateBinding HorizontalSnapPointsAlignment}"
+                                  VerticalSnapPointsAlignment="{TemplateBinding VerticalSnapPointsAlignment}"
                                   Offset="{TemplateBinding Offset, Mode=TwoWay}"
                                   Viewport="{TemplateBinding Viewport, Mode=TwoWay}"
                                   IsScrollChainingEnabled="{TemplateBinding IsScrollChainingEnabled}">

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

@@ -10,6 +10,8 @@
                 BorderThickness="{TemplateBinding BorderThickness}"
                 CornerRadius="{TemplateBinding CornerRadius}">
           <ItemsPresenter Name="PART_ItemsPresenter"
+                          AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
+                          AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
                           ItemsPanel="{TemplateBinding ItemsPanel}" />
         </Border>
       </ControlTemplate>

+ 5 - 1
src/Avalonia.Themes.Simple/Controls/ListBox.xaml

@@ -20,9 +20,13 @@
                         Background="{TemplateBinding Background}"
                         HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
                         IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}"
-                        VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
+                        VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}"
+                        VerticalSnapPointsType="{TemplateBinding (ScrollViewer.VerticalSnapPointsType)}"
+                        HorizontalSnapPointsType="{TemplateBinding (ScrollViewer.HorizontalSnapPointsType)}">
             <ItemsPresenter Name="PART_ItemsPresenter"
                             Margin="{TemplateBinding Padding}"
+                            AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
+                            AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
                             ItemsPanel="{TemplateBinding ItemsPanel}" />
           </ScrollViewer>
         </Border>

+ 5 - 1
src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml

@@ -13,7 +13,11 @@
                                   Background="{TemplateBinding Background}"
                                   CanHorizontallyScroll="{TemplateBinding CanHorizontallyScroll}"
                                   CanVerticallyScroll="{TemplateBinding CanVerticallyScroll}"
-                                  Content="{TemplateBinding Content}"
+                                  Content="{TemplateBinding Content}"                                  
+                                  HorizontalSnapPointsType="{TemplateBinding HorizontalSnapPointsType}"
+                                  VerticalSnapPointsType="{TemplateBinding VerticalSnapPointsType}"
+                                  HorizontalSnapPointsAlignment="{TemplateBinding HorizontalSnapPointsAlignment}"
+                                  VerticalSnapPointsAlignment="{TemplateBinding VerticalSnapPointsAlignment}"
                                   Extent="{TemplateBinding Extent,
                                                            Mode=TwoWay}"
                                   IsScrollChainingEnabled="{TemplateBinding IsScrollChainingEnabled}"