浏览代码

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.
Steven Kirk 1 年之前
父节点
当前提交
91646f1d32

+ 41 - 9
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)

+ 67 - 24
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@@ -23,6 +23,13 @@ namespace Avalonia.Controls.UnitTests
 {
     public class VirtualizingStackPanelTests : ScopedTestBase
     {
+        private static FuncDataTemplate<ItemWithHeight> 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<ItemWithHeight>((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<ItemWithHeight>((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<ItemWithHeight>((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<ContentPresenter>(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<ContentPresenter>(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<int> 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<TContainer>(