diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 7934e0ccc6..b3d61a9b64 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -167,7 +167,7 @@ namespace Avalonia.Controls // We handle horizontal and vertical layouts here so X and Y are abstracted to: // - Horizontal layouts: U = horizontal, V = vertical // - Vertical layouts: U = vertical, V = horizontal - var viewport = CalculateMeasureViewport(items); + var viewport = CalculateMeasureViewport(orientation, items); // If the viewport is disjunct then we can recycle everything. if (viewport.viewportIsDisjunct) @@ -465,15 +465,15 @@ namespace Avalonia.Controls return _realizedElements?.Elements ?? Array.Empty(); } - private MeasureViewport CalculateMeasureViewport(IReadOnlyList items) + private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadOnlyList items) { Debug.Assert(_realizedElements is not null); var viewport = _viewport; // Get the viewport in the orientation direction. - var viewportStart = Orientation == Orientation.Horizontal ? viewport.X : viewport.Y; - var viewportEnd = Orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom; + 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. If we are // scrolling to an element, use that as the anchor element. Otherwise, estimate the @@ -484,7 +484,7 @@ namespace Avalonia.Controls if (_scrollToIndex >= 0 && _scrollToElement is not null) { anchorIndex = _scrollToIndex; - anchorU = _scrollToElement.Bounds.Top; + anchorU = orientation == Orientation.Horizontal ? _scrollToElement.Bounds.Left : _scrollToElement.Bounds.Top; } else { diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index d1ee1662f0..ba7765dabb 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -30,6 +30,13 @@ namespace Avalonia.Controls.UnitTests [!Layoutable.HeightProperty] = new Binding("Height"), }); + private static FuncDataTemplate CanvasWithWidthTemplate = new((_, _) => + new Canvas + { + Height = 100, + [!Layoutable.WidthProperty] = new Binding("Width"), + }); + [Fact] public void Creates_Initial_Items() { @@ -45,7 +52,7 @@ namespace Avalonia.Controls.UnitTests public void Initializes_Initial_Control_Items() { using var app = App(); - var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10}); + var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10 }); var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null); Assert.Equal(1000, scroll.Extent.Height); @@ -492,7 +499,7 @@ namespace Avalonia.Controls.UnitTests public void NthChild_Selector_Works() { using var app = App(); - + var style = new Style(x => x.OfType().NthChild(5, 0)) { Setters = { new Setter(ListBoxItem.BackgroundProperty, Brushes.Red) }, @@ -500,9 +507,9 @@ namespace Avalonia.Controls.UnitTests var (target, _, _) = CreateTarget(styles: new[] { style }); var realized = target.GetRealizedContainers()!.Cast().ToList(); - + Assert.Equal(10, realized.Count); - + for (var i = 0; i < 10; ++i) { var container = realized[i]; @@ -537,7 +544,7 @@ namespace Avalonia.Controls.UnitTests var expectedBackground = (i == 4 || i == 9) ? Brushes.Red : null; Assert.Equal(i, index); - Assert.Equal(expectedBackground, ((Canvas) container.Child!).Background); + Assert.Equal(expectedBackground, ((Canvas)container.Child!).Background); } } @@ -590,7 +597,7 @@ namespace Avalonia.Controls.UnitTests var expectedBackground = (i == 0 || i == 5) ? Brushes.Red : null; Assert.Equal(i, index); - Assert.Equal(expectedBackground, ((Canvas) container.Child!).Background); + Assert.Equal(expectedBackground, ((Canvas)container.Child!).Background); } } @@ -762,7 +769,7 @@ namespace Avalonia.Controls.UnitTests Layout(target); Assert.True( - target.FirstRealizedIndex >= index, + target.FirstRealizedIndex >= index, $"{target.FirstRealizedIndex} is not greater or equal to {index}"); if (scroll.Offset.Y + scroll.Viewport.Height == scroll.Extent.Height) @@ -801,7 +808,7 @@ namespace Avalonia.Controls.UnitTests index = target.FirstRealizedIndex; } } - + [Fact] public void Scrolling_Up_To_Smaller_Element_Does_Not_Cause_Jump() { @@ -828,12 +835,12 @@ namespace Avalonia.Controls.UnitTests Layout(target); Assert.True( - target.FirstRealizedIndex <= index, + target.FirstRealizedIndex <= index, $"{target.FirstRealizedIndex} is not less than {index}"); Assert.True( index - target.FirstRealizedIndex <= 1, $"FirstIndex changed from {index} to {target.FirstRealizedIndex}"); - + index = target.FirstRealizedIndex; } } @@ -846,7 +853,7 @@ namespace Avalonia.Controls.UnitTests var (_, _, itemsControl) = CreateUnrootedTarget(); var container = new Decorator { Margin = new Thickness(100) }; var root = new TestRoot(true, container); - + root.LayoutManager.ExecuteInitialLayoutPass(); container.Child = itemsControl; @@ -889,7 +896,7 @@ namespace Avalonia.Controls.UnitTests Assert.Null(firstItem.Parent); Assert.Null(firstItem.VisualParent); - Assert.Empty(itemsControl.ItemsPanelRoot!.Children); + Assert.Empty(itemsControl.ItemsPanelRoot!.Children); } [Fact] @@ -1038,7 +1045,7 @@ namespace Avalonia.Controls.UnitTests public void Can_Bind_Item_IsVisible() { using var app = App(); - var style = CreateIsVisibleBindingStyle(); + var style = CreateIsVisibleBindingStyle(); var items = Enumerable.Range(0, 100).Select(x => new ItemWithIsVisible(x)).ToList(); var (target, scroll, itemsControl) = CreateTarget(items: items, styles: new[] { style }); var container = target.ContainerFromIndex(2)!; @@ -1192,6 +1199,58 @@ namespace Avalonia.Controls.UnitTests AssertRealizedItems(target, itemsControl, 15, 5); } + [Fact] + public void ScrollIntoView_Correctly_Scrolls_Right_To_A_Page_Of_Smaller_Items() + { + using var app = App(); + + // First 10 items have width of 20, next 10 have width of 10. + var items = Enumerable.Range(0, 20).Select(x => new ItemWithWidth(x, ((29 - x) / 10) * 10)); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithWidthTemplate, orientation: Orientation.Horizontal); + + // Scroll the last item into view. + target.ScrollIntoView(19); + + // At the time of the scroll, the average item width 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 width of 10. This is obviously not a perfect answer, but + // it's the best we can do without knowing the actual item widths. + var container = Assert.IsType(target.ContainerFromIndex(19)); + Assert.Equal(new Rect(380, 0, 10, 100), container.Bounds); + Assert.Equal(new Size(100, 100), scroll.Viewport); + Assert.Equal(new Size(390, 100), scroll.Extent); + Assert.Equal(new Vector(290, 0), scroll.Offset); + + // Items 10-19 should be visible. + AssertRealizedItems(target, itemsControl, 10, 10); + } + + [Fact] + public void ScrollIntoView_Correctly_Scrolls_Right_To_A_Page_Of_Larger_Items() + { + using var app = App(); + + // First 10 items have width of 10, next 10 have width of 20. + var items = Enumerable.Range(0, 20).Select(x => new ItemWithWidth(x, ((x / 10) + 1) * 10)); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithWidthTemplate, orientation: Orientation.Horizontal); + + // Scroll the last item into view. + target.ScrollIntoView(19); + + // At the time of the scroll, the average item width 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 width of 20. This is obviously not a perfect answer, but + // it's the best we can do without knowing the actual item widths. + var container = Assert.IsType(target.ContainerFromIndex(19)); + Assert.Equal(new Rect(190, 0, 20, 100), container.Bounds); + Assert.Equal(new Size(100, 100), scroll.Viewport); + Assert.Equal(new Size(210, 100), scroll.Extent); + Assert.Equal(new Vector(110, 0), scroll.Offset); + + // Items 15-19 should be visible. + AssertRealizedItems(target, itemsControl, 15, 5); + } + [Fact] public void Extent_And_Offset_Should_Be_Updated_When_Containers_Resize() { @@ -1348,21 +1407,24 @@ namespace Avalonia.Controls.UnitTests private static (VirtualizingStackPanel, ScrollViewer, ItemsControl) CreateTarget( IEnumerable? items = null, Optional itemTemplate = default, - IEnumerable