diff --git a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs index 790439245a..63ba869f1f 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; @@ -97,9 +101,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) @@ -176,6 +177,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 @@ -187,7 +189,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/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 0cfe4bada1..49c6fdbaaa 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,30 @@ namespace Avalonia.Controls.Presenters private set { SetAndRaise(ViewportProperty, ref _viewport, value); } } + public SnapPointsType HorizontalSnapPointsType + { + get => GetValue(HorizontalSnapPointsTypeProperty); + set => SetValue(HorizontalSnapPointsTypeProperty, value); + } + + public SnapPointsType VerticalSnapPointsType + { + get => GetValue(VerticalSnapPointsTypeProperty); + set => SetValue(VerticalSnapPointsTypeProperty, value); + } + + public SnapPointsAlignment HorizontalSnapPointsAlignment + { + get => GetValue(HorizontalSnapPointsAlignmentProperty); + set => SetValue(HorizontalSnapPointsAlignmentProperty, value); + } + + 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 +484,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 +513,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) @@ -472,6 +609,7 @@ namespace Avalonia.Controls.Presenters } Vector newOffset = new Vector(x, y); + bool offsetChanged = newOffset != Offset; Offset = newOffset; @@ -485,10 +623,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 +799,110 @@ 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, HorizontalSnapPointsAlignment); + } + 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; + + if (VerticalSnapPointsType != SnapPointsType.None) + { + 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); + } + + if (HorizontalSnapPointsType != SnapPointsType.None) + { + 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, offset.Y); + + } + + double FindNearestSnapPoint(IReadOnlyList snapPoints, double value, out double lowerSnapPoint) + { + var point = snapPoints.BinarySearch(value, Comparer.Default); + + lowerSnapPoint = 0; + + if (point < 0) + { + point = ~point; + } + + lowerSnapPoint = snapPoints[Math.Max(0, point - 1)]; + return snapPoints[Math.Min(point, snapPoints.Count - 1)]; + } + + return offset; + } } } diff --git a/src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs b/src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs new file mode 100644 index 0000000000..2aa2382dc0 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using Avalonia.Interactivity; +using Avalonia.Layout; + +namespace Avalonia.Controls.Primitives +{ + public interface IScrollSnapPointsInfo + { + bool AreHorizontalSnapPointsRegular { get; } + bool AreVerticalSnapPointsRegular { get; } + + IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment); + double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset); + + event EventHandler HorizontalSnapPointsChanged; + 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..9f0125f1c4 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Controls.Primitives +{ + public enum SnapPointsAlignment + { + Near, + Center, + Far + } +} diff --git a/src/Avalonia.Controls/Primitives/SnapPointsType.cs b/src/Avalonia.Controls/Primitives/SnapPointsType.cs new file mode 100644 index 0000000000..7e43f4c191 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/SnapPointsType.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Controls.Primitives +{ + public enum SnapPointsType + { + None, + Mandatory, + MandatorySingle + } +} diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index 503187e2d3..215e2dd395 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 StyledProperty HorizontalSnapPointsAlignmentProperty = + AvaloniaProperty.Register( + nameof(HorizontalSnapPointsAlignment)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty VerticalSnapPointsAlignmentProperty = + AvaloniaProperty.Register( + nameof(VerticalSnapPointsAlignment)); + /// /// Defines the VerticalScrollBarViewportSize property. /// @@ -421,6 +449,30 @@ namespace Avalonia.Controls private set => SetAndRaise(ScrollBar.IsExpandedProperty, ref _isExpanded, value); } + public SnapPointsType HorizontalSnapPointsType + { + get => GetValue(HorizontalSnapPointsTypeProperty); + set => SetValue(HorizontalSnapPointsTypeProperty, value); + } + + public SnapPointsType VerticalSnapPointsType + { + get => GetValue(VerticalSnapPointsTypeProperty); + set => SetValue(VerticalSnapPointsTypeProperty, value); + } + + public SnapPointsAlignment HorizontalSnapPointsAlignment + { + get => GetValue(HorizontalSnapPointsAlignmentProperty); + set => SetValue(HorizontalSnapPointsAlignmentProperty, value); + } + + 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.Themes.Fluent/Controls/ScrollViewer.xaml b/src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml index 71df4d419f..f13f775695 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/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}"