From 078185d7c057e460b5b4f06e78ebaeca30692192 Mon Sep 17 00:00:00 2001 From: Tom Edwards <109803929+TomEdwardsEnscape@users.noreply.github.com> Date: Mon, 20 Nov 2023 07:17:53 +0100 Subject: [PATCH] Deferred scrolling (#13644) * Implemented deferred scrolling * Set IsDeferredScrollingEnabled in themes, where relevant --- .../Pages/ScrollViewerPage.xaml | 3 + .../Themes/Fluent/ColorView.xaml | 1 + .../Themes/Simple/ColorView.xaml | 1 + src/Avalonia.Controls/Primitives/ScrollBar.cs | 1 + src/Avalonia.Controls/Primitives/Track.cs | 67 ++++++++++++++++--- src/Avalonia.Controls/ScrollViewer.cs | 26 +++++++ .../Controls/ComboBox.xaml | 3 +- .../Controls/ListBox.xaml | 1 + .../Controls/ScrollBar.xaml | 2 + .../Controls/TreeView.xaml | 1 + .../Controls/ComboBox.xaml | 3 +- .../Controls/ListBox.xaml | 1 + .../Controls/ScrollBar.xaml | 2 + .../Controls/TreeView.xaml | 1 + .../ScrollViewerTests.cs | 41 ++++++++++++ 15 files changed, 143 insertions(+), 11 deletions(-) diff --git a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml index 4af61c3399..f931542a04 100644 --- a/samples/ControlCatalog/Pages/ScrollViewerPage.xaml +++ b/samples/ControlCatalog/Pages/ScrollViewerPage.xaml @@ -17,6 +17,9 @@ Content="Allow auto hide" /> + diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml index 9ca55566f3..e4a8ecace4 100644 --- a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml +++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorView.xaml @@ -96,6 +96,7 @@ HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}" VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}" IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}" + IsDeferredScrollingEnabled="{TemplateBinding (ScrollViewer.IsDeferredScrollingEnabled)}" AllowAutoHide="{TemplateBinding (ScrollViewer.AllowAutoHide)}"> Bind(p, owner.GetObservable(ScrollViewer.ScrollBarMaximumProperty, ExtractOrdinate), BindingPriority.Template)), IfUnset(ValueProperty, p => Bind(p, owner.GetObservable(ScrollViewer.OffsetProperty, ExtractOrdinate), BindingPriority.Template)), + IfUnset(ScrollViewer.IsDeferredScrollingEnabledProperty, p => Bind(p, owner.GetObservable(ScrollViewer.IsDeferredScrollingEnabledProperty), BindingPriority.Template)), IfUnset(ViewportSizeProperty, p => Bind(p, owner.GetObservable(ScrollViewer.ViewportProperty, ExtractOrdinate), BindingPriority.Template)), IfUnset(VisibilityProperty, p => Bind(p, owner.GetObservable(visibilitySource), BindingPriority.Template)), IfUnset(AllowAutoHideProperty, p => Bind(p, owner.GetObservable(ScrollViewer.AllowAutoHideProperty), BindingPriority.Template)), diff --git a/src/Avalonia.Controls/Primitives/Track.cs b/src/Avalonia.Controls/Primitives/Track.cs index 2152f4a2dd..1509b027c6 100644 --- a/src/Avalonia.Controls/Primitives/Track.cs +++ b/src/Avalonia.Controls/Primitives/Track.cs @@ -45,6 +45,10 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty IgnoreThumbDragProperty = AvaloniaProperty.Register(nameof(IgnoreThumbDrag)); + public static readonly StyledProperty DeferThumbDragProperty = + AvaloniaProperty.Register(nameof(DeferThumbDrag)); + + private VectorEventArgs? _deferredThumbDrag; private Vector _lastDrag; static Track() @@ -78,6 +82,11 @@ namespace Avalonia.Controls.Primitives set => SetValue(ValueProperty, value); } + /// + /// Gets the value of the 's current position. This can differ from when is true. + /// + private double ThumbValue => Value + (_deferredThumbDrag == null ? 0 : ValueFromDistance(_deferredThumbDrag.Vector.X, _deferredThumbDrag.Vector.Y)); + public double ViewportSize { get => GetValue(ViewportSizeProperty); @@ -121,6 +130,12 @@ namespace Avalonia.Controls.Primitives set => SetValue(IgnoreThumbDragProperty, value); } + public bool DeferThumbDrag + { + get => GetValue(DeferThumbDragProperty); + set => SetValue(DeferThumbDragProperty, value); + } + private double ThumbCenterOffset { get; set; } private double Density { get; set; } @@ -139,11 +154,11 @@ namespace Avalonia.Controls.Primitives // Find distance from center of thumb to given point. if (Orientation == Orientation.Horizontal) { - val = Value + ValueFromDistance(point.X - ThumbCenterOffset, point.Y - (Bounds.Height * 0.5)); + val = ThumbValue + ValueFromDistance(point.X - ThumbCenterOffset, point.Y - (Bounds.Height * 0.5)); } else { - val = Value + ValueFromDistance(point.X - (Bounds.Width * 0.5), point.Y - ThumbCenterOffset); + val = ThumbValue + ValueFromDistance(point.X - (Bounds.Width * 0.5), point.Y - ThumbCenterOffset); } return Math.Max(Minimum, Math.Min(Maximum, val)); @@ -303,6 +318,13 @@ namespace Avalonia.Controls.Primitives { UpdatePseudoClasses(change.GetNewValue()); } + else if (change.Property == DeferThumbDragProperty) + { + if (!change.GetNewValue()) + { + ApplyDeferredThumbDrag(); + } + } } private Vector CalculateThumbAdjustment(Thumb thumb, Rect newThumbBounds) @@ -327,7 +349,7 @@ namespace Avalonia.Controls.Primitives { double min = Minimum; double range = Math.Max(0.0, Maximum - min); - double offset = Math.Min(range, Value - min); + double offset = Math.Min(range, ThumbValue - min); double trackLength; @@ -360,7 +382,7 @@ namespace Avalonia.Controls.Primitives { var min = Minimum; var range = Math.Max(0.0, Maximum - min); - var offset = Math.Min(range, Value - min); + var offset = Math.Min(range, ThumbValue - min); var extent = Math.Max(0.0, range) + viewportSize; var trackLength = isVertical ? arrangeSize.Height : arrangeSize.Width; double thumbMinLength = 10; @@ -419,7 +441,7 @@ namespace Avalonia.Controls.Primitives if (oldThumb != null) { oldThumb.DragDelta -= ThumbDragged; - + oldThumb.DragCompleted -= ThumbDragCompleted; LogicalChildren.Remove(oldThumb); VisualChildren.Remove(oldThumb); } @@ -427,6 +449,7 @@ namespace Avalonia.Controls.Primitives if (newThumb != null) { newThumb.DragDelta += ThumbDragged; + newThumb.DragCompleted += ThumbDragCompleted; LogicalChildren.Add(newThumb); VisualChildren.Add(newThumb); } @@ -455,17 +478,43 @@ namespace Avalonia.Controls.Primitives if (IgnoreThumbDrag) return; - var value = Value; + if (DeferThumbDrag) + { + _deferredThumbDrag = e; + InvalidateArrange(); + } + else + { + ApplyThumbDrag(e); + } + + } + + private void ApplyThumbDrag(VectorEventArgs e) + { var delta = ValueFromDistance(e.Vector.X, e.Vector.Y); var factor = e.Vector / delta; + var oldValue = Value; SetCurrentValue(ValueProperty, MathUtilities.Clamp( - value + delta, + Value + delta, Minimum, Maximum)); - + // Record the part of the drag that actually had effect as the last drag delta. - _lastDrag = (Value - value) * factor; + // Due to clamping, we need to compare the two values instead of using the drag delta. + _lastDrag = (Value - oldValue) * factor; + } + + private void ThumbDragCompleted(object? sender, EventArgs e) => ApplyDeferredThumbDrag(); + + private void ApplyDeferredThumbDrag() + { + if (_deferredThumbDrag != null) + { + ApplyThumbDrag(_deferredThumbDrag); + _deferredThumbDrag = null; + } } private void ShowChildren(bool visible) diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index 649b01dd2e..3921e0f072 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -140,6 +140,13 @@ namespace Avalonia.Controls nameof(IsScrollInertiaEnabled), defaultValue: true); + /// + /// Defines the property. + /// + public static readonly AttachedProperty IsDeferredScrollingEnabledProperty = + AvaloniaProperty.RegisterAttached( + nameof(IsDeferredScrollingEnabled)); + /// /// Defines the event. /// @@ -370,6 +377,15 @@ namespace Avalonia.Controls set => SetValue(IsScrollInertiaEnabledProperty, value); } + /// + /// Gets or sets whether dragging of elements should update the only when the user releases the mouse. + /// + public bool IsDeferredScrollingEnabled + { + get => GetValue(IsDeferredScrollingEnabledProperty); + set => SetValue(IsDeferredScrollingEnabledProperty, value); + } + /// /// Scrolls the content up one line. /// @@ -626,6 +642,16 @@ namespace Avalonia.Controls control.SetValue(IsScrollInertiaEnabledProperty, value); } + /// + /// Gets whether dragging of elements should update the only when the user releases the mouse. + /// + public static bool GetIsDeferredScrollingEnabled(Control control) => control.GetValue(IsDeferredScrollingEnabledProperty); + + /// + /// Sets whether dragging of elements should update the only when the user releases the mouse. + /// + public static void SetIsDeferredScrollingEnabled(Control control, bool value) => control.SetValue(IsDeferredScrollingEnabledProperty, value); + /// public void RegisterAnchorCandidate(Control element) { diff --git a/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml b/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml index 18bf01ece7..cc472fd72f 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml @@ -127,7 +127,8 @@ HorizontalAlignment="Stretch" CornerRadius="{DynamicResource OverlayCornerRadius}"> + VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" + IsDeferredScrollingEnabled="{TemplateBinding (ScrollViewer.IsDeferredScrollingEnabled)}"> diff --git a/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml b/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml index 3757f685bf..886846a589 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ListBox.xaml @@ -37,6 +37,7 @@ VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}" IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}" IsScrollInertiaEnabled="{TemplateBinding (ScrollViewer.IsScrollInertiaEnabled)}" + IsDeferredScrollingEnabled="{TemplateBinding (ScrollViewer.IsDeferredScrollingEnabled)}" AllowAutoHide="{TemplateBinding (ScrollViewer.AllowAutoHide)}" BringIntoViewOnFocusChange="{TemplateBinding (ScrollViewer.BringIntoViewOnFocusChange)}"> @@ -223,6 +224,7 @@ Minimum="{TemplateBinding Minimum}" Maximum="{TemplateBinding Maximum}" Value="{TemplateBinding Value, Mode=TwoWay}" + DeferThumbDrag="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}" ViewportSize="{TemplateBinding ViewportSize}" Orientation="{TemplateBinding Orientation}"> diff --git a/src/Avalonia.Themes.Fluent/Controls/TreeView.xaml b/src/Avalonia.Themes.Fluent/Controls/TreeView.xaml index 0fdcaf39f8..24854694a8 100644 --- a/src/Avalonia.Themes.Fluent/Controls/TreeView.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/TreeView.xaml @@ -31,6 +31,7 @@ HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}" VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}" IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}" + IsDeferredScrollingEnabled="{TemplateBinding (ScrollViewer.IsDeferredScrollingEnabled)}" AllowAutoHide="{TemplateBinding (ScrollViewer.AllowAutoHide)}" BringIntoViewOnFocusChange="{TemplateBinding (ScrollViewer.BringIntoViewOnFocusChange)}"> + VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" + IsDeferredScrollingEnabled="{TemplateBinding (ScrollViewer.IsDeferredScrollingEnabled)}"> diff --git a/src/Avalonia.Themes.Simple/Controls/ListBox.xaml b/src/Avalonia.Themes.Simple/Controls/ListBox.xaml index 73c27db756..f0c6a88e7a 100644 --- a/src/Avalonia.Themes.Simple/Controls/ListBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/ListBox.xaml @@ -22,6 +22,7 @@ Background="{TemplateBinding Background}" HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}" IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}" + IsDeferredScrollingEnabled="{TemplateBinding (ScrollViewer.IsDeferredScrollingEnabled)}" VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}" VerticalSnapPointsType="{TemplateBinding (ScrollViewer.VerticalSnapPointsType)}" HorizontalSnapPointsType="{TemplateBinding (ScrollViewer.HorizontalSnapPointsType)}"> diff --git a/src/Avalonia.Themes.Simple/Controls/ScrollBar.xaml b/src/Avalonia.Themes.Simple/Controls/ScrollBar.xaml index 8b647c8299..e8dfd3736f 100644 --- a/src/Avalonia.Themes.Simple/Controls/ScrollBar.xaml +++ b/src/Avalonia.Themes.Simple/Controls/ScrollBar.xaml @@ -27,6 +27,7 @@ Minimum="{TemplateBinding Minimum}" Orientation="{TemplateBinding Orientation}" ViewportSize="{TemplateBinding ViewportSize}" + DeferThumbDrag="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}" Value="{TemplateBinding Value, Mode=TwoWay}"> @@ -77,6 +78,7 @@ Minimum="{TemplateBinding Minimum}" Orientation="{TemplateBinding Orientation}" ViewportSize="{TemplateBinding ViewportSize}" + DeferThumbDrag="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}" Value="{TemplateBinding Value, Mode=TwoWay}"> diff --git a/src/Avalonia.Themes.Simple/Controls/TreeView.xaml b/src/Avalonia.Themes.Simple/Controls/TreeView.xaml index d76bf6c393..f95ed68fdc 100644 --- a/src/Avalonia.Themes.Simple/Controls/TreeView.xaml +++ b/src/Avalonia.Themes.Simple/Controls/TreeView.xaml @@ -20,6 +20,7 @@ Background="{TemplateBinding Background}" HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}" IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}" + IsDeferredScrollingEnabled="{TemplateBinding (ScrollViewer.IsDeferredScrollingEnabled)}" VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}"> (CreateTemplate), + IsDeferredScrollingEnabled = true, + Content = content, + }; + var root = new TestRoot(target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + // We're working in absolute coordinates (i.e. relative to the root) and clicking on + // the center of the vertical thumb. + var thumb = GetVerticalThumb(target); + var p = GetRootPoint(thumb, thumb.Bounds.Center); + + Assert.Equal(Vector.Zero, target.Offset); + Assert.Equal(0, thumb.Bounds.Top); + + // Press the mouse button in the center of the thumb. + _mouse.Down(thumb, position: p); + root.LayoutManager.ExecuteLayoutPass(); + + // Drag the thumb down 100 pixels. + _mouse.Move(thumb, p += new Vector(0, 100)); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(Vector.Zero, target.Offset); // no change to scroll... + Assert.Equal(100, thumb.Bounds.Top); // ...but the Thumb has moved + + // Release the mouse + _mouse.Up(thumb, position: p); + + Assert.Equal(new Vector(0, 200), target.Offset); + Assert.Equal(100, thumb.Bounds.Top); + } + [Fact] public void BringIntoViewOnFocusChange_Scrolls_Child_Control_Into_View_When_Focused() { @@ -493,6 +533,7 @@ namespace Avalonia.Controls.UnitTests [!!Track.ValueProperty] = scrollBar[!!RangeBase.ValueProperty], [!Track.ViewportSizeProperty] = scrollBar[!ScrollBar.ViewportSizeProperty], [!Track.OrientationProperty] = scrollBar[!ScrollBar.OrientationProperty], + [!Track.DeferThumbDragProperty] = scrollBar.TemplatedParent[!ScrollViewer.IsDeferredScrollingEnabledProperty], Thumb = new Thumb { Template = new FuncControlTemplate(CreateThumbTemplate),