diff --git a/src/Avalonia.Controls/Primitives/Thumb.cs b/src/Avalonia.Controls/Primitives/Thumb.cs index 9854bdbea6..c5d73dc00f 100644 --- a/src/Avalonia.Controls/Primitives/Thumb.cs +++ b/src/Avalonia.Controls/Primitives/Thumb.cs @@ -4,7 +4,6 @@ using Avalonia.Controls.Automation.Peers; using Avalonia.Controls.Metadata; using Avalonia.Input; using Avalonia.Interactivity; -using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives { @@ -47,6 +46,12 @@ namespace Avalonia.Controls.Primitives remove { RemoveHandler(DragCompletedEvent, value); } } + internal void AdjustDrag(Vector v) + { + if (_lastPoint.HasValue) + _lastPoint = _lastPoint.Value + v; + } + protected override AutomationPeer OnCreateAutomationPeer() => new ThumbAutomationPeer(this); protected virtual void OnDragStarted(VectorEventArgs e) @@ -85,22 +90,20 @@ namespace Avalonia.Controls.Primitives { if (_lastPoint.HasValue) { - var point = e.GetPosition(null); var ev = new VectorEventArgs { RoutedEvent = DragDeltaEvent, - Vector = point - _lastPoint.Value, + Vector = e.GetPosition(this) - _lastPoint.Value, }; RaiseEvent(ev); - _lastPoint = point; } } protected override void OnPointerPressed(PointerPressedEventArgs e) { e.Handled = true; - _lastPoint = e.GetPosition(null); + _lastPoint = e.GetPosition(this); var ev = new VectorEventArgs { @@ -123,7 +126,7 @@ namespace Avalonia.Controls.Primitives var ev = new VectorEventArgs { RoutedEvent = DragCompletedEvent, - Vector = (Vector)e.GetPosition(null), + Vector = (Vector)e.GetPosition(this), }; RaiseEvent(ev); diff --git a/src/Avalonia.Controls/Primitives/Track.cs b/src/Avalonia.Controls/Primitives/Track.cs index fe4912a33c..f9aa5fc7a0 100644 --- a/src/Avalonia.Controls/Primitives/Track.cs +++ b/src/Avalonia.Controls/Primitives/Track.cs @@ -45,6 +45,8 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty IgnoreThumbDragProperty = AvaloniaProperty.Register(nameof(IgnoreThumbDrag)); + private Vector _lastDrag; + static Track() { ThumbProperty.Changed.AddClassHandler((x, e) => x.ThumbChanged(e)); @@ -245,7 +247,10 @@ namespace Avalonia.Controls.Primitives if (Thumb != null) { - Thumb.Arrange(new Rect(offset, pieceSize)); + var bounds = new Rect(offset, pieceSize); + var adjust = CalculateThumbAdjustment(Thumb, bounds); + Thumb.Arrange(bounds); + Thumb.AdjustDrag(adjust); } ThumbCenterOffset = offset.Y + (thumbLength * 0.5); @@ -277,12 +282,16 @@ namespace Avalonia.Controls.Primitives if (Thumb != null) { - Thumb.Arrange(new Rect(offset, pieceSize)); + var bounds = new Rect(offset, pieceSize); + var adjust = CalculateThumbAdjustment(Thumb, bounds); + Thumb.Arrange(bounds); + Thumb.AdjustDrag(adjust); } ThumbCenterOffset = offset.X + (thumbLength * 0.5); } + _lastDrag = default; return arrangeSize; } @@ -296,6 +305,12 @@ namespace Avalonia.Controls.Primitives } } + private Vector CalculateThumbAdjustment(Thumb thumb, Rect newThumbBounds) + { + var thumbDelta = newThumbBounds.Position - thumb.Bounds.Position; + return _lastDrag - thumbDelta; + } + private static void CoerceLength(ref double componentLength, double trackLength) { if (componentLength < 0) @@ -440,10 +455,17 @@ namespace Avalonia.Controls.Primitives if (IgnoreThumbDrag) return; + var value = Value; + var delta = ValueFromDistance(e.Vector.X, e.Vector.Y); + var factor = e.Vector / delta; + SetCurrentValue(ValueProperty, MathUtilities.Clamp( - Value + ValueFromDistance(e.Vector.X, e.Vector.Y), + value + delta, Minimum, Maximum)); + + // Record the part of the drag that actually had effect as the last drag delta. + _lastDrag = (Value - value) * factor; } private void ShowChildren(bool visible) diff --git a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs index a7bde551e9..82c4b5f8f0 100644 --- a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; +using System.Linq; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Layout; +using Avalonia.Media; using Avalonia.UnitTests; +using Avalonia.VisualTree; using Moq; using Xunit; @@ -12,6 +15,8 @@ namespace Avalonia.Controls.UnitTests { public class ScrollViewerTests { + private readonly MouseTestHelper _mouse = new(); + [Fact] public void Content_Is_Created() { @@ -249,6 +254,121 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Vector(20, 20), target.Offset); } + [Fact] + public void Scroll_Does_Not_Jump_When_Viewport_Becomes_Smaller_While_Dragging_ScrollBar_Thumb() + { + var content = new TestContent + { + MeasureSize = new Size(1000, 10000), + }; + + var target = new ScrollViewer + { + Template = new FuncControlTemplate(CreateTemplate), + Content = content, + }; + var root = new TestRoot(target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.Equal(new Size(1000, 10000), target.Extent); + Assert.Equal(new Size(1000, 1000), target.Viewport); + + // 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); + + // Press the mouse button in the center of the thumb. + _mouse.Down(thumb, position: p); + root.LayoutManager.ExecuteLayoutPass(); + + // Drag the thumb down 300 pixels. + _mouse.Move(thumb, p += new Vector(0, 300)); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(new Vector(0, 3000), target.Offset); + Assert.Equal(300, thumb.Bounds.Top); + + // Now the extent changes from 10,000 to 5000. + content.MeasureSize /= 2; + content.InvalidateMeasure(); + root.LayoutManager.ExecuteLayoutPass(); + + // Due to the extent change, the thumb moves down but the value remains the same. + Assert.Equal(600, thumb.Bounds.Top); + Assert.Equal(new Vector(0, 3000), target.Offset); + + // Drag the thumb down another 100 pixels. + _mouse.Move(thumb, p += new Vector(0, 100)); + root.LayoutManager.ExecuteLayoutPass(); + + // The drag should not cause the offset/thumb to jump *up* to the current absolute + // mouse position, i.e. it should move down in the direction of the drag even if the + // absolute mouse position is now above the thumb. + Assert.Equal(700, thumb.Bounds.Top); + Assert.Equal(new Vector(0, 3500), target.Offset); + } + + [Fact] + public void Thumb_Does_Not_Become_Detached_From_Mouse_Position_When_Scrolling_Past_The_Start() + { + var content = new TestContent(); + var target = new ScrollViewer + { + Template = new FuncControlTemplate(CreateTemplate), + Content = content, + }; + var root = new TestRoot(target); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.Equal(new Size(1000, 2000), target.Extent); + Assert.Equal(new Size(1000, 1000), target.Viewport); + + // 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); + + // 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(new Vector(0, 200), target.Offset); + Assert.Equal(100, thumb.Bounds.Top); + + // Drag the thumb up 200 pixels - 100 pixels past the top of the scrollbar. + _mouse.Move(thumb, p -= new Vector(0, 200)); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(new Vector(0, 0), target.Offset); + Assert.Equal(0, thumb.Bounds.Top); + + // Drag the thumb back down 200 pixels. + _mouse.Move(thumb, p += new Vector(0, 200)); + root.LayoutManager.ExecuteLayoutPass(); + + // We should now be back in the state after we first scrolled down 100 pixels. + Assert.Equal(new Vector(0, 200), target.Offset); + Assert.Equal(100, thumb.Bounds.Top); + } + + private Point GetRootPoint(Visual control, Point p) + { + if (control.GetVisualRoot() is Visual root && + control.TransformToVisual(root) is Matrix m) + { + return p.Transform(m); + } + + throw new InvalidOperationException("Could not get the point in root coordinates."); + } + private Control CreateTemplate(ScrollViewer control, INameScope scope) { return new Grid @@ -273,6 +393,7 @@ namespace Avalonia.Controls.UnitTests { Name = "PART_HorizontalScrollBar", Orientation = Orientation.Horizontal, + Template = new FuncControlTemplate(CreateScrollBarTemplate), [~ScrollBar.VisibilityProperty] = control[~ScrollViewer.HorizontalScrollBarVisibilityProperty], [Grid.RowProperty] = 1, }.RegisterInNameScope(scope), @@ -280,6 +401,7 @@ namespace Avalonia.Controls.UnitTests { Name = "PART_VerticalScrollBar", Orientation = Orientation.Vertical, + Template = new FuncControlTemplate(CreateScrollBarTemplate), [~ScrollBar.VisibilityProperty] = control[~ScrollViewer.VerticalScrollBarVisibilityProperty], [Grid.ColumnProperty] = 1, }.RegisterInNameScope(scope), @@ -287,6 +409,44 @@ namespace Avalonia.Controls.UnitTests }; } + private Control CreateScrollBarTemplate(ScrollBar scrollBar, INameScope scope) + { + return new Border + { + Child = new Track + { + Name = "track", + IsDirectionReversed = true, + [!Track.MinimumProperty] = scrollBar[!RangeBase.MinimumProperty], + [!Track.MaximumProperty] = scrollBar[!RangeBase.MaximumProperty], + [!!Track.ValueProperty] = scrollBar[!!RangeBase.ValueProperty], + [!Track.ViewportSizeProperty] = scrollBar[!ScrollBar.ViewportSizeProperty], + [!Track.OrientationProperty] = scrollBar[!ScrollBar.OrientationProperty], + Thumb = new Thumb + { + Template = new FuncControlTemplate(CreateThumbTemplate), + }, + }.RegisterInNameScope(scope), + }; + } + + private static Control CreateThumbTemplate(Thumb control, INameScope scope) + { + return new Border + { + Background = Brushes.Gray, + }; + } + + private Thumb GetVerticalThumb(ScrollViewer target) + { + var scrollbar = Assert.IsType( + target.GetTemplateChildren().FirstOrDefault(x => x.Name == "PART_VerticalScrollBar")); + var track = Assert.IsType( + scrollbar.GetTemplateChildren().FirstOrDefault(x => x.Name == "track")); + return Assert.IsType(track.Thumb); + } + private static void InitializeScrollViewer(ScrollViewer target) { target.ApplyTemplate(); @@ -295,5 +455,15 @@ namespace Avalonia.Controls.UnitTests presenter.AttachToScrollViewer(); presenter.UpdateChild(); } + + private class TestContent : Control + { + public Size MeasureSize { get; set; } = new Size(1000, 2000); + + protected override Size MeasureOverride(Size availableSize) + { + return MeasureSize; + } + } } } diff --git a/tests/Avalonia.UnitTests/MouseTestHelper.cs b/tests/Avalonia.UnitTests/MouseTestHelper.cs index d63327239b..eff38388e2 100644 --- a/tests/Avalonia.UnitTests/MouseTestHelper.cs +++ b/tests/Avalonia.UnitTests/MouseTestHelper.cs @@ -59,7 +59,7 @@ namespace Avalonia.UnitTests { _pressedButton = mouseButton; _pointer.Capture((IInputElement)target); - source.RaiseEvent(new PointerPressedEventArgs(source, _pointer, (Visual)source, position, Timestamp(), props, + source.RaiseEvent(new PointerPressedEventArgs(source, _pointer, GetRoot(target), position, Timestamp(), props, modifiers, clickCount)); } } @@ -68,7 +68,7 @@ namespace Avalonia.UnitTests public void Move(Interactive target, Interactive source, in Point position, KeyModifiers modifiers = default) { - target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, (Visual)target, position, + target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, GetRoot(target), position, Timestamp(), new PointerPointProperties((RawInputModifiers)_pressedButtons, PointerUpdateKind.Other), modifiers)); } @@ -88,7 +88,7 @@ namespace Avalonia.UnitTests ); if (ButtonCount(props) == 0) { - target.RaiseEvent(new PointerReleasedEventArgs(source, _pointer, (Visual)target, position, + target.RaiseEvent(new PointerReleasedEventArgs(source, _pointer, GetRoot(target), position, Timestamp(), props, modifiers, _pressedButton)); _pointer.Capture(null); } @@ -131,5 +131,9 @@ namespace Avalonia.UnitTests Timestamp(), new PointerPointProperties((RawInputModifiers)_pressedButtons, PointerUpdateKind.Other), KeyModifiers.None)); } + private Visual GetRoot(Interactive source) + { + return ((source as Visual)?.GetVisualRoot() as Visual) ?? (Visual)source; + } } }