diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index f702f09e8ae..b1251eb4737 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -148,17 +148,17 @@ protected override Size MeasureOverride(Size availableSize) 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(); @@ -461,12 +461,25 @@ private MeasureViewport CalculateMeasureViewport(IReadOnlyList items) 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 || @@ -496,6 +509,25 @@ private Size CalculateDesiredSize(Orientation orientation, int itemCount, in Mea 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 1939b52afa6..c6178f7531f 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 @@ public void Scrolling_Down_With_Larger_Element_Does_Not_Cause_Jump_And_Arrives_A 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 @@ public void Scrolling_Up_To_Larger_Element_Does_Not_Cause_Jump() 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 @@ public void Scrolling_Up_To_Smaller_Element_Does_Not_Cause_Jump() 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 @@ public void ScrollIntoView_With_TargetRect_Outside_Viewport_Should_Scroll_To_Ite 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 @@ private static void AssertRealizedItems( .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(