From 68ba0336b2336880b331e0c3180eb8f89987e5c8 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 23 Apr 2024 02:05:35 +0300 Subject: [PATCH] Fix VirtualizingStackPanel ScrollIntoView (#15449) * Add more tests for ScrollIntoView. * Improve ScrollIntoView. Take into account the element we're scrolling to when calculating the anchor element for realization. --- .../VirtualizingStackPanel.cs | 50 ++++++++-- .../VirtualizingStackPanelTests.cs | 91 ++++++++++++++----- 2 files changed, 108 insertions(+), 33 deletions(-) diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 65410021a8..9893c81acc 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -148,17 +148,17 @@ namespace Avalonia.Controls if (items.Count == 0) return default; + var orientation = Orientation; + // If we're bringing an item into view, ignore any layout passes until we receive a new // effective viewport. if (_isWaitingForViewportUpdate) - return DesiredSize; + return EstimateDesiredSize(orientation, items.Count); _isInLayout = true; try { - var orientation = Orientation; - _realizedElements ??= new(); _measureElements ??= new(); @@ -459,12 +459,25 @@ namespace Avalonia.Controls var viewportStart = Orientation == Orientation.Horizontal ? viewport.X : viewport.Y; var viewportEnd = Orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom; - // Get or estimate the anchor element from which to start realization. - var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport( - viewportStart, - viewportEnd, - items.Count, - ref _lastEstimatedElementSizeU); + // Get or estimate the anchor element from which to start realization. If we are + // scrolling to an element, use that as the anchor element. Otherwise, estimate the + // anchor element based on the current viewport. + int anchorIndex; + double anchorU; + + if (_scrollToIndex >= 0 && _scrollToElement is not null) + { + anchorIndex = _scrollToIndex; + anchorU = _scrollToElement.Bounds.Top; + } + else + { + (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport( + viewportStart, + viewportEnd, + items.Count, + ref _lastEstimatedElementSizeU); + } // Check if the anchor element is not within the currently realized elements. var disjunct = anchorIndex < _realizedElements.FirstIndex || @@ -494,6 +507,25 @@ namespace Avalonia.Controls return orientation == Orientation.Horizontal ? new(sizeU, sizeV) : new(sizeV, sizeU); } + private Size EstimateDesiredSize(Orientation orientation, int itemCount) + { + if (_scrollToIndex >= 0 && _scrollToElement is not null) + { + // We have an element to scroll to, so we can estimate the desired size based on the + // element's position and the remaining elements. + var remaining = itemCount - _scrollToIndex - 1; + var u = orientation == Orientation.Horizontal ? + _scrollToElement.Bounds.Right : + _scrollToElement.Bounds.Bottom; + var sizeU = u + (remaining * _lastEstimatedElementSizeU); + return orientation == Orientation.Horizontal ? + new(sizeU, DesiredSize.Height) : + new(DesiredSize.Width, sizeU); + } + + return DesiredSize; + } + private double EstimateElementSizeU() { if (_realizedElements is null) diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 5d96e68f1e..37d0f2ced4 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -23,6 +23,13 @@ namespace Avalonia.Controls.UnitTests { public class VirtualizingStackPanelTests : ScopedTestBase { + private static FuncDataTemplate CanvasWithHeightTemplate = new((_, _) => + new Canvas + { + Width = 100, + [!Layoutable.HeightProperty] = new Binding("Height"), + }); + [Fact] public void Creates_Initial_Items() { @@ -744,14 +751,7 @@ namespace Avalonia.Controls.UnitTests var items = Enumerable.Range(0, 1000).Select(x => new ItemWithHeight(x)).ToList(); items[20].Height = 200; - var itemTemplate = new FuncDataTemplate((x, _) => - new Canvas - { - Width = 100, - [!Canvas.HeightProperty] = new Binding("Height"), - }); - - var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate); var index = target.FirstRealizedIndex; @@ -780,14 +780,7 @@ namespace Avalonia.Controls.UnitTests var items = Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x)).ToList(); items[20].Height = 200; - var itemTemplate = new FuncDataTemplate((x, _) => - new Canvas - { - Width = 100, - [!Canvas.HeightProperty] = new Binding("Height"), - }); - - var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate); // Scroll past the larger element. scroll.Offset = new Vector(0, 600); @@ -817,14 +810,7 @@ namespace Avalonia.Controls.UnitTests var items = Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x, 30)).ToList(); items[20].Height = 25; - var itemTemplate = new FuncDataTemplate((x, _) => - new Canvas - { - Width = 100, - [!Canvas.HeightProperty] = new Binding("Height"), - }); - - var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate); // Scroll past the larger element. scroll.Offset = new Vector(0, 25 * items[0].Height); @@ -1154,6 +1140,58 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(9901, scroll.Offset.X); } + [Fact] + public void ScrollIntoView_Correctly_Scrolls_Down_To_A_Page_Of_Smaller_Items() + { + using var app = App(); + + // First 10 items have height of 20, next 10 have height of 10. + var items = Enumerable.Range(0, 20).Select(x => new ItemWithHeight(x, ((29 - x) / 10) * 10)); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate); + + // Scroll the last item into view. + target.ScrollIntoView(19); + + // At the time of the scroll, the average item height is 20, so the requested item + // should be placed at 380 (19 * 20) which therefore results in an extent of 390 to + // accommodate the item height of 10. This is obviously not a perfect answer, but + // it's the best we can do without knowing the actual item heights. + var container = Assert.IsType(target.ContainerFromIndex(19)); + Assert.Equal(new Rect(0, 380, 100, 10), container.Bounds); + Assert.Equal(new Size(100, 100), scroll.Viewport); + Assert.Equal(new Size(100, 390), scroll.Extent); + Assert.Equal(new Vector(0, 290), scroll.Offset); + + // Items 10-19 should be visible. + AssertRealizedItems(target, itemsControl, 10, 10); + } + + [Fact] + public void ScrollIntoView_Correctly_Scrolls_Down_To_A_Page_Of_Larger_Items() + { + using var app = App(); + + // First 10 items have height of 10, next 10 have height of 20. + var items = Enumerable.Range(0, 20).Select(x => new ItemWithHeight(x, ((x / 10) + 1) * 10)); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate); + + // Scroll the last item into view. + target.ScrollIntoView(19); + + // At the time of the scroll, the average item height is 10, so the requested item + // should be placed at 190 (19 * 10) which therefore results in an extent of 210 to + // accommodate the item height of 20. This is obviously not a perfect answer, but + // it's the best we can do without knowing the actual item heights. + var container = Assert.IsType(target.ContainerFromIndex(19)); + Assert.Equal(new Rect(0, 190, 100, 20), container.Bounds); + Assert.Equal(new Size(100, 100), scroll.Viewport); + Assert.Equal(new Size(100, 210), scroll.Extent); + Assert.Equal(new Vector(0, 110), scroll.Offset); + + // Items 15-19 should be visible. + AssertRealizedItems(target, itemsControl, 15, 5); + } + private static IReadOnlyList GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl) { return target.GetRealizedElements() @@ -1176,6 +1214,11 @@ namespace Avalonia.Controls.UnitTests .OrderBy(x => x) .ToList(); Assert.Equal(Enumerable.Range(firstIndex, count), childIndexes); + + var visibleChildren = target.Children + .Where(x => x.IsVisible) + .ToList(); + Assert.Equal(count, visibleChildren.Count); } private static void AssertRealizedControlItems(