Browse Source

Add support for snap points to scroll viewer

pull/9961/head
Emmanuel Hansen 4 years ago
parent
commit
4390f197d4
  1. 10
      src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs
  2. 4
      src/Avalonia.Base/Input/Gestures.cs
  3. 12
      src/Avalonia.Base/Input/ScrollGestureEventArgs.cs
  4. 271
      src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs
  5. 19
      src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs
  6. 9
      src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs
  7. 9
      src/Avalonia.Controls/Primitives/SnapPointsType.cs
  8. 52
      src/Avalonia.Controls/ScrollViewer.cs
  9. 4
      src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml
  10. 6
      src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml

10
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);

4
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
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;
}
}
}

271
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,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);
}
/// <summary>
/// Gets or sets if scroll chaining is enabled. The default value is true.
/// </summary>
@ -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<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)
@ -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<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;
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<double> snapPoints, double value, out double lowerSnapPoint)
{
var point = snapPoints.BinarySearch(value, Comparer<double>.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;
}
}
}

19
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<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment);
double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset);
event EventHandler<RoutedEventArgs> HorizontalSnapPointsChanged;
event EventHandler<RoutedEventArgs> VerticalSnapPointsChanged;
}
}

9
src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs

@ -0,0 +1,9 @@
namespace Avalonia.Controls.Primitives
{
public enum SnapPointsAlignment
{
Near,
Center,
Far
}
}

9
src/Avalonia.Controls/Primitives/SnapPointsType.cs

@ -0,0 +1,9 @@
namespace Avalonia.Controls.Primitives
{
public enum SnapPointsType
{
None,
Mandatory,
MandatorySingle
}
}

52
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 StyledProperty<SnapPointsAlignment> HorizontalSnapPointsAlignmentProperty =
AvaloniaProperty.Register<ScrollViewer, SnapPointsAlignment>(
nameof(HorizontalSnapPointsAlignment));
/// <summary>
/// Defines the <see cref="VerticalSnapPointsAlignment"/> property.
/// </summary>
public static readonly StyledProperty<SnapPointsAlignment> VerticalSnapPointsAlignmentProperty =
AvaloniaProperty.Register<ScrollViewer, SnapPointsAlignment>(
nameof(VerticalSnapPointsAlignment));
/// <summary>
/// Defines the VerticalScrollBarViewportSize property.
/// </summary>
@ -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);
}
/// <summary>
/// Gets a value that indicates whether scrollbars can hide itself when user is not interacting with it.
/// </summary>

4
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}">

6
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}"

Loading…
Cancel
Save