diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 166b98436e..83776ec2c1 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -144,9 +144,12 @@ - + + + + diff --git a/samples/ControlCatalog/Pages/ScrollSnapPage.xaml b/samples/ControlCatalog/Pages/ScrollSnapPage.xaml new file mode 100644 index 0000000000..e7e1060f31 --- /dev/null +++ b/samples/ControlCatalog/Pages/ScrollSnapPage.xaml @@ -0,0 +1,222 @@ + + + Scrollviewer can snap supported content both vertically and horizontally. Snapping occurs from scrolling with touch or pen, or using the pointer wheel. + + + + + + + + + + + + + + + + Vertical Snapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Horizontal Snapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs b/samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs new file mode 100644 index 0000000000..384dc67c66 --- /dev/null +++ b/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.None, + SnapPointsType.Mandatory, + SnapPointsType.MandatorySingle + }; + + AvailableSnapPointsAlignment = new List() + { + 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 AvailableSnapPointsType { get; } + public List AvailableSnapPointsAlignment { get; } + } + + public class ScrollSnapPage : UserControl + { + public ScrollSnapPage() + { + this.InitializeComponent(); + + DataContext = new ScrollSnapPageViewModel(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml index 5042d7823b..fa8714959b 100644 --- a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml +++ b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml @@ -3,8 +3,8 @@ xmlns:pages="using:ControlCatalog.Pages" x:Class="ControlCatalog.Pages.ScrollViewerPage" x:DataType="pages:ScrollViewerPageViewModel"> - - Allows for horizontal and vertical content scrolling. + + Allows for horizontal and vertical content scrolling. Supports snapping on touch and pointer wheel scrolling. @@ -33,6 +33,5 @@ Source="/Assets/delicate-arch-896885_640.jpg" /> - diff --git a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs index fdab2df0bf..7c1ee13eed 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs +++ b/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); diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs index a9e42c2374..167f61eead 100644 --- a/src/Avalonia.Base/Input/Gestures.cs +++ b/src/Avalonia.Base/Input/Gestures.cs @@ -45,6 +45,10 @@ namespace Avalonia.Input RoutedEvent.Register( "ScrollGesture", RoutingStrategies.Bubble, typeof(Gestures)); + public static readonly RoutedEvent ScrollGestureInertiaStartingEvent = + RoutedEvent.Register( + "ScrollGestureInertiaStarting", RoutingStrategies.Bubble, typeof(Gestures)); + public static readonly RoutedEvent ScrollGestureEndedEvent = RoutedEvent.Register( "ScrollGestureEnded", RoutingStrategies.Bubble, typeof(Gestures)); diff --git a/src/Avalonia.Base/Input/ScrollGestureEventArgs.cs b/src/Avalonia.Base/Input/ScrollGestureEventArgs.cs index f1a0887b60..55aaadff71 100644 --- a/src/Avalonia.Base/Input/ScrollGestureEventArgs.cs +++ b/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; + } + } } diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index d19a04eb21..db49da85e8 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/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. /// [PseudoClasses(":empty", ":singleitem")] - public class ItemsControl : TemplatedControl, IChildIndexProvider + public class ItemsControl : TemplatedControl, IChildIndexProvider, IScrollSnapPointsInfo { /// /// The default value for the property. @@ -72,6 +74,18 @@ namespace Avalonia.Controls /// public static readonly StyledProperty DisplayMemberBindingProperty = AvaloniaProperty.Register(nameof(DisplayMemberBinding)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty AreHorizontalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreHorizontalSnapPointsRegular)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty AreVerticalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreVerticalSnapPointsRegular)); /// /// Gets or sets the to use for binding to the display member of each item. @@ -91,6 +105,8 @@ namespace Avalonia.Controls private IDataTemplate? _displayMemberItemTemplate; private Tuple? _containerBeingPrepared; private ScrollViewer? _scrollViewer; + private ItemsPresenter? _itemsPresenter; + private IScrollSnapPointsInfo? _scrolSnapPointInfo; /// /// Initializes a new instance of the class. @@ -203,6 +219,63 @@ namespace Avalonia.Controls remove => _childIndexChanged -= value; } + + public event EventHandler HorizontalSnapPointsChanged + { + add + { + if (_itemsPresenter != null) + { + _itemsPresenter.HorizontalSnapPointsChanged += value; + } + } + + remove + { + if (_itemsPresenter != null) + { + _itemsPresenter.HorizontalSnapPointsChanged -= value; + } + } + } + + public event EventHandler VerticalSnapPointsChanged + { + add + { + if (_itemsPresenter != null) + { + _itemsPresenter.VerticalSnapPointsChanged += value; + } + } + + remove + { + if (_itemsPresenter != null) + { + _itemsPresenter.VerticalSnapPointsChanged -= value; + } + } + } + + /// + /// Gets or sets whether the horizontal snap points for the are equidistant from each other. + /// + public bool AreHorizontalSnapPointsRegular + { + get { return GetValue(AreHorizontalSnapPointsRegularProperty); } + set { SetValue(AreHorizontalSnapPointsRegularProperty, value); } + } + + /// + /// Gets or sets whether the vertical snap points for the are equidistant from each other. + /// + public bool AreVerticalSnapPointsRegular + { + get { return GetValue(AreVerticalSnapPointsRegularProperty); } + set { SetValue(AreVerticalSnapPointsRegularProperty, value); } + } + /// /// Returns the container for the item at the specified index. /// @@ -255,7 +328,6 @@ namespace Avalonia.Controls /// public IEnumerable GetRealizedContainers() => Presenter?.GetRealizedContainers() ?? Array.Empty(); - /// /// Creates or a container that can be used to display an item. /// @@ -355,6 +427,9 @@ namespace Avalonia.Controls { base.OnApplyTemplate(e); _scrollViewer = e.NameScope.Find("PART_ScrollViewer"); + _itemsPresenter = e.NameScope.Find("PART_ItemsPresenter"); + + _scrolSnapPointInfo = _itemsPresenter as IScrollSnapPointsInfo; } /// @@ -671,5 +746,17 @@ namespace Avalonia.Controls count = ItemsView.Count; return true; } + + public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) + { + return _itemsPresenter?.GetIrregularSnapPoints(orientation, snapPointsAlignment) ?? new List(); + } + + public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset) + { + offset = 0; + + return _itemsPresenter?.GetRegularSnapPoints(orientation, snapPointsAlignment, out offset) ?? 0; + } } } diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index e0252feed5..8594b584fa 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/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 { /// /// Presents items inside an . /// - public class ItemsPresenter : Control, ILogicalScrollable + public class ItemsPresenter : Control, ILogicalScrollable, IScrollSnapPointsInfo { /// /// Defines the property. @@ -19,8 +21,37 @@ namespace Avalonia.Controls.Presenters private PanelContainerGenerator? _generator; private ILogicalScrollable? _logicalScrollable; + private IScrollSnapPointsInfo? _scrollSnapPointsInfo; private EventHandler? _scrollInvalidated; + /// + /// Defines the property. + /// + public static readonly StyledProperty AreHorizontalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreHorizontalSnapPointsRegular)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty AreVerticalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreVerticalSnapPointsRegular)); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent HorizontalSnapPointsChangedEvent = + RoutedEvent.Register( + nameof(HorizontalSnapPointsChanged), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent VerticalSnapPointsChangedEvent = + RoutedEvent.Register( + nameof(VerticalSnapPointsChanged), + RoutingStrategies.Bubble); + static ItemsPresenter() { KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue( @@ -83,12 +114,48 @@ namespace Avalonia.Controls.Presenters } } + /// + /// Occurs when the measurements for horizontal snap points change. + /// + public event EventHandler? HorizontalSnapPointsChanged + { + add => AddHandler(HorizontalSnapPointsChangedEvent, value); + remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value); + } + + /// + /// Occurs when the measurements for vertical snap points change. + /// + public event EventHandler? 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; + /// + /// Gets or sets whether the horizontal snap points for the are equidistant from each other. + /// + public bool AreHorizontalSnapPointsRegular + { + get { return GetValue(AreHorizontalSnapPointsRegularProperty); } + set { SetValue(AreHorizontalSnapPointsRegularProperty, value); } + } + + /// + /// Gets or sets whether the vertical snap points for the are equidistant from each other. + /// + 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 GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) + { + if(Panel is IScrollSnapPointsInfo scrollSnapPointsInfo) + { + return scrollSnapPointsInfo.GetIrregularSnapPoints(orientation, snapPointsAlignment); + } + + return new List(); + } + + 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; + } } } diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 0cfe4bada1..7d5b5e1490 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/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; /// /// Defines the property. @@ -57,6 +59,30 @@ namespace Avalonia.Controls.Presenters o => o.Viewport, (o, v) => o.Viewport = v); + /// + /// Defines the property. + /// + public static readonly StyledProperty HorizontalSnapPointsTypeProperty = + ScrollViewer.HorizontalSnapPointsTypeProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty VerticalSnapPointsTypeProperty = + ScrollViewer.VerticalSnapPointsTypeProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty HorizontalSnapPointsAlignmentProperty = + ScrollViewer.HorizontalSnapPointsAlignmentProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty VerticalSnapPointsAlignmentProperty = + ScrollViewer.VerticalSnapPointsAlignmentProperty.AddOwner(); + /// /// Defines the property. /// @@ -71,10 +97,19 @@ namespace Avalonia.Controls.Presenters private IDisposable? _logicalScrollSubscription; private Size _viewport; private Dictionary? _activeLogicalGestureScrolls; + private Dictionary? _scrollGestureSnapPoints; private List? _anchorCandidates; private Control? _anchorElement; private Rect _anchorElementBounds; private bool _isAnchorElementDirty; + private bool _areVerticalSnapPointsRegular; + private bool _areHorizontalSnapPointsRegular; + private IReadOnlyList? _horizontalSnapPoints; + private double _horizontalSnapPoint; + private IReadOnlyList? _verticalSnapPoints; + private double _verticalSnapPoint; + private double _verticalSnapPointOffset; + private double _horizontalSnapPointOffset; /// /// Initializes static members of the 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); } } + /// + /// Gets or sets how scroll gesture reacts to the snap points along the horizontal axis. + /// + public SnapPointsType HorizontalSnapPointsType + { + get => GetValue(HorizontalSnapPointsTypeProperty); + set => SetValue(HorizontalSnapPointsTypeProperty, value); + } + + /// + /// Gets or sets how scroll gesture reacts to the snap points along the vertical axis. + /// + public SnapPointsType VerticalSnapPointsType + { + get => GetValue(VerticalSnapPointsTypeProperty); + set => SetValue(VerticalSnapPointsTypeProperty, value); + } + + /// + /// Gets or sets how the existing snap points are horizontally aligned versus the initial viewport. + /// + public SnapPointsAlignment HorizontalSnapPointsAlignment + { + get => GetValue(HorizontalSnapPointsAlignmentProperty); + set => SetValue(HorizontalSnapPointsAlignmentProperty, value); + } + + /// + /// Gets or sets how the existing snap points are vertically aligned versus the initial viewport. + /// + public SnapPointsAlignment VerticalSnapPointsAlignment + { + get => GetValue(VerticalSnapPointsAlignmentProperty); + set => SetValue(VerticalSnapPointsAlignmentProperty, value); + } + /// /// Gets or sets if scroll chaining is enabled. The default value is true. /// @@ -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(); + + 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; + } + } /// 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(); + _verticalSnapPoint = scrollSnapPointsInfo.GetRegularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment, out _verticalSnapPointOffset); + + } + + if (!_areHorizontalSnapPointsRegular) + { + _horizontalSnapPoints = scrollSnapPointsInfo.GetIrregularSnapPoints(Layout.Orientation.Horizontal, HorizontalSnapPointsAlignment); + } + else + { + _horizontalSnapPoints = new List(); + _horizontalSnapPoint = scrollSnapPointsInfo.GetRegularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment, out _horizontalSnapPointOffset); + } + } + else + { + _horizontalSnapPoints = new List(); + _verticalSnapPoints = new List(); + } + } + + 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 snapPoints, double value, out double lowerSnapPoint) + { + var point = snapPoints.BinarySearch(value, Comparer.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)]; + } } } diff --git a/src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs b/src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs new file mode 100644 index 0000000000..7b33db0df2 --- /dev/null +++ b/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 +{ + /// + /// Describes snap point behavior for objects that contain and present items. + /// + public interface IScrollSnapPointsInfo + { + /// + /// Gets or sets a value that indicates whether the horizontal snap points for the container are equidistant from each other. + /// + bool AreHorizontalSnapPointsRegular { get; set; } + + /// + /// Gets or sets a value that indicates whether the vertical snap points for the container are equidistant from each other. + /// + bool AreVerticalSnapPointsRegular { get; set; } + + /// + /// Returns the set of distances between irregular snap points for a specified orientation and alignment. + /// + /// The orientation for the desired snap point set. + /// The alignment to use when applying the snap points. + /// The read-only collection of snap point distances. Returns an empty collection when no snap points are present. + IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment); + + /// + /// Gets the distance between regular snap points for a specified orientation and alignment. + /// + /// The orientation for the desired snap point set. + /// The alignment to use when applying the snap points. + /// Out parameter. The offset of the first snap point. + /// The distance between the equidistant snap points. Returns 0 when no snap points are present. + double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset); + + /// + /// Occurs when the measurements for horizontal snap points change. + /// + event EventHandler HorizontalSnapPointsChanged; + + /// + /// Occurs when the measurements for vertical snap points change. + /// + event EventHandler VerticalSnapPointsChanged; + } +} diff --git a/src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs b/src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs new file mode 100644 index 0000000000..77b93c50a0 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs @@ -0,0 +1,23 @@ +namespace Avalonia.Controls.Primitives +{ + /// + /// Specify options for snap point alignment relative to an edge. Which edge depends on the orientation of the object where the alignment is applied + /// + public enum SnapPointsAlignment + { + /// + /// Use snap points grouped closer to the orientation edge. + /// + Near, + + /// + /// Use snap points that are centered in the orientation. + /// + Center, + + /// + /// Use snap points grouped farther from the orientation edge. + /// + Far + } +} diff --git a/src/Avalonia.Controls/Primitives/SnapPointsType.cs b/src/Avalonia.Controls/Primitives/SnapPointsType.cs new file mode 100644 index 0000000000..130fb85f77 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/SnapPointsType.cs @@ -0,0 +1,23 @@ +namespace Avalonia.Controls.Primitives +{ + /// + /// Specify how panning snap points are processed for gesture input. + /// + public enum SnapPointsType + { + /// + /// No snapping behavior. + /// + None, + + /// + /// Content always stops at the snap point closest to where inertia would naturally stop along the direction of inertia. + /// + Mandatory, + + /// + /// Content always stops at the snap point closest to the release point along the direction of inertia. + /// + MandatorySingle + } +} diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index d16cd209c0..1c23919d0e 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -151,6 +151,34 @@ namespace Avalonia.Controls o => o.VerticalScrollBarValue, (o, v) => o.VerticalScrollBarValue = v); + /// + /// Defines the property. + /// + public static readonly StyledProperty HorizontalSnapPointsTypeProperty = + AvaloniaProperty.Register( + nameof(HorizontalSnapPointsType)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty VerticalSnapPointsTypeProperty = + AvaloniaProperty.Register( + nameof(VerticalSnapPointsType)); + + /// + /// Defines the property. + /// + public static readonly AttachedProperty HorizontalSnapPointsAlignmentProperty = + AvaloniaProperty.RegisterAttached( + nameof(HorizontalSnapPointsAlignment)); + + /// + /// Defines the property. + /// + public static readonly AttachedProperty VerticalSnapPointsAlignmentProperty = + AvaloniaProperty.RegisterAttached( + nameof(VerticalSnapPointsAlignment)); + /// /// Defines the VerticalScrollBarViewportSize property. /// @@ -429,6 +457,42 @@ namespace Avalonia.Controls private set => SetAndRaise(ScrollBar.IsExpandedProperty, ref _isExpanded, value); } + /// + /// Gets or sets how scroll gesture reacts to the snap points along the horizontal axis. + /// + public SnapPointsType HorizontalSnapPointsType + { + get => GetValue(HorizontalSnapPointsTypeProperty); + set => SetValue(HorizontalSnapPointsTypeProperty, value); + } + + /// + /// Gets or sets how scroll gesture reacts to the snap points along the vertical axis. + /// + public SnapPointsType VerticalSnapPointsType + { + get => GetValue(VerticalSnapPointsTypeProperty); + set => SetValue(VerticalSnapPointsTypeProperty, value); + } + + /// + /// Gets or sets how the existing snap points are horizontally aligned versus the initial viewport. + /// + public SnapPointsAlignment HorizontalSnapPointsAlignment + { + get => GetValue(HorizontalSnapPointsAlignmentProperty); + set => SetValue(HorizontalSnapPointsAlignmentProperty, value); + } + + /// + /// Gets or sets how the existing snap points are vertically aligned versus the initial viewport. + /// + public SnapPointsAlignment VerticalSnapPointsAlignment + { + get => GetValue(VerticalSnapPointsAlignmentProperty); + set => SetValue(VerticalSnapPointsAlignmentProperty, value); + } + /// /// Gets a value that indicates whether scrollbars can hide itself when user is not interacting with it. /// diff --git a/src/Avalonia.Controls/StackPanel.cs b/src/Avalonia.Controls/StackPanel.cs index 964a1055df..aa63ac975e 100644 --- a/src/Avalonia.Controls/StackPanel.cs +++ b/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 /// /// A panel which lays out its children horizontally or vertically. /// - public class StackPanel : Panel, INavigableContainer + public class StackPanel : Panel, INavigableContainer, IScrollSnapPointsInfo { /// /// Defines the property. @@ -26,6 +30,34 @@ namespace Avalonia.Controls public static readonly StyledProperty OrientationProperty = StackLayout.OrientationProperty.AddOwner(); + /// + /// Defines the property. + /// + public static readonly StyledProperty AreHorizontalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreHorizontalSnapPointsRegular)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty AreVerticalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreVerticalSnapPointsRegular)); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent HorizontalSnapPointsChangedEvent = + RoutedEvent.Register( + nameof(HorizontalSnapPointsChanged), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent VerticalSnapPointsChangedEvent = + RoutedEvent.Register( + nameof(VerticalSnapPointsChanged), + RoutingStrategies.Bubble); + /// /// Initializes static members of the class. /// @@ -53,6 +85,42 @@ namespace Avalonia.Controls set { SetValue(OrientationProperty, value); } } + /// + /// Occurs when the measurements for horizontal snap points change. + /// + public event EventHandler? HorizontalSnapPointsChanged + { + add => AddHandler(HorizontalSnapPointsChangedEvent, value); + remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value); + } + + /// + /// Occurs when the measurements for vertical snap points change. + /// + public event EventHandler? VerticalSnapPointsChanged + { + add => AddHandler(VerticalSnapPointsChangedEvent, value); + remove => RemoveHandler(VerticalSnapPointsChangedEvent, value); + } + + /// + /// Gets or sets whether the horizontal snap points for the are equidistant from each other. + /// + public bool AreHorizontalSnapPointsRegular + { + get { return GetValue(AreHorizontalSnapPointsRegularProperty); } + set { SetValue(AreHorizontalSnapPointsRegularProperty, value); } + } + + /// + /// Gets or sets whether the vertical snap points for the are equidistant from each other. + /// + public bool AreVerticalSnapPointsRegular + { + get { return GetValue(AreVerticalSnapPointsRegularProperty); } + set { SetValue(AreVerticalSnapPointsRegularProperty, value); } + } + /// /// Gets the next control in the specified direction. /// @@ -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); } + + /// + public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) + { + var snapPoints = new List(); + + 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; + } + + /// + 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; + } } } diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index f2b42b0b7e..c5276741b6 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/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 /// /// Arranges and virtualizes content on a single line that is oriented either horizontally or vertically. /// - public class VirtualizingStackPanel : VirtualizingPanel + public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo { /// /// Defines the property. @@ -22,6 +25,34 @@ namespace Avalonia.Controls public static readonly StyledProperty OrientationProperty = StackLayout.OrientationProperty.AddOwner(); + /// + /// Defines the property. + /// + public static readonly StyledProperty AreHorizontalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreHorizontalSnapPointsRegular)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty AreVerticalSnapPointsRegularProperty = + AvaloniaProperty.Register(nameof(AreVerticalSnapPointsRegular)); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent HorizontalSnapPointsChangedEvent = + RoutedEvent.Register( + nameof(HorizontalSnapPointsChanged), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent VerticalSnapPointsChangedEvent = + RoutedEvent.Register( + nameof(VerticalSnapPointsChanged), + RoutingStrategies.Bubble); + private static readonly AttachedProperty ItemIsOwnContainerProperty = AvaloniaProperty.RegisterAttached("ItemIsOwnContainer"); @@ -62,6 +93,42 @@ namespace Avalonia.Controls set => SetValue(OrientationProperty, value); } + /// + /// Occurs when the measurements for horizontal snap points change. + /// + public event EventHandler? HorizontalSnapPointsChanged + { + add => AddHandler(HorizontalSnapPointsChangedEvent, value); + remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value); + } + + /// + /// Occurs when the measurements for vertical snap points change. + /// + public event EventHandler? VerticalSnapPointsChanged + { + add => AddHandler(VerticalSnapPointsChangedEvent, value); + remove => RemoveHandler(VerticalSnapPointsChangedEvent, value); + } + + /// + /// Gets or sets whether the horizontal snap points for the are equidistant from each other. + /// + public bool AreHorizontalSnapPointsRegular + { + get { return GetValue(AreHorizontalSnapPointsRegularProperty); } + set { SetValue(AreHorizontalSnapPointsRegularProperty, value); } + } + + /// + /// Gets or sets whether the vertical snap points for the are equidistant from each other. + /// + 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); } + /// + public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) + { + var snapPoints = new List(); + + 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; + } + + /// + 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; + } + /// /// Stores the realized element state for a . /// diff --git a/src/Avalonia.Themes.Fluent/Controls/ItemsControl.xaml b/src/Avalonia.Themes.Fluent/Controls/ItemsControl.xaml index 1306985f5f..19a29b9466 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ItemsControl.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ItemsControl.xaml @@ -9,6 +9,8 @@ CornerRadius="{TemplateBinding CornerRadius}" Padding="{TemplateBinding Padding}"> diff --git a/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml b/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml index ec0b876c71..4b9fb76b8a 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml @@ -29,11 +29,15 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"> diff --git a/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml b/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml index 15cd9428f3..7a8c15bf60 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml +++ b/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}"> diff --git a/src/Avalonia.Themes.Simple/Controls/ItemsControl.xaml b/src/Avalonia.Themes.Simple/Controls/ItemsControl.xaml index d85cf193fa..8cf4e0be08 100644 --- a/src/Avalonia.Themes.Simple/Controls/ItemsControl.xaml +++ b/src/Avalonia.Themes.Simple/Controls/ItemsControl.xaml @@ -10,6 +10,8 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"> diff --git a/src/Avalonia.Themes.Simple/Controls/ListBox.xaml b/src/Avalonia.Themes.Simple/Controls/ListBox.xaml index e1257ef10f..eaa1f914ca 100644 --- a/src/Avalonia.Themes.Simple/Controls/ListBox.xaml +++ b/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)}"> diff --git a/src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml b/src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml index e11fbb6f15..7029792467 100644 --- a/src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml +++ b/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}"