Skip to content

Commit 21ee489

Browse files
committed
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.
1 parent d719dd2 commit 21ee489

File tree

2 files changed

+108
-33
lines changed

2 files changed

+108
-33
lines changed

src/Avalonia.Controls/VirtualizingStackPanel.cs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -148,17 +148,17 @@ protected override Size MeasureOverride(Size availableSize)
148148
if (items.Count == 0)
149149
return default;
150150

151+
var orientation = Orientation;
152+
151153
// If we're bringing an item into view, ignore any layout passes until we receive a new
152154
// effective viewport.
153155
if (_isWaitingForViewportUpdate)
154-
return DesiredSize;
156+
return EstimateDesiredSize(orientation, items.Count);
155157

156158
_isInLayout = true;
157159

158160
try
159161
{
160-
var orientation = Orientation;
161-
162162
_realizedElements ??= new();
163163
_measureElements ??= new();
164164

@@ -461,12 +461,25 @@ private MeasureViewport CalculateMeasureViewport(IReadOnlyList<object?> items)
461461
var viewportStart = Orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
462462
var viewportEnd = Orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom;
463463

464-
// Get or estimate the anchor element from which to start realization.
465-
var (anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport(
466-
viewportStart,
467-
viewportEnd,
468-
items.Count,
469-
ref _lastEstimatedElementSizeU);
464+
// Get or estimate the anchor element from which to start realization. If we are
465+
// scrolling to an element, use that as the anchor element. Otherwise, estimate the
466+
// anchor element based on the current viewport.
467+
int anchorIndex;
468+
double anchorU;
469+
470+
if (_scrollToIndex >= 0 && _scrollToElement is not null)
471+
{
472+
anchorIndex = _scrollToIndex;
473+
anchorU = _scrollToElement.Bounds.Top;
474+
}
475+
else
476+
{
477+
(anchorIndex, anchorU) = _realizedElements.GetOrEstimateAnchorElementForViewport(
478+
viewportStart,
479+
viewportEnd,
480+
items.Count,
481+
ref _lastEstimatedElementSizeU);
482+
}
470483

471484
// Check if the anchor element is not within the currently realized elements.
472485
var disjunct = anchorIndex < _realizedElements.FirstIndex ||
@@ -496,6 +509,25 @@ private Size CalculateDesiredSize(Orientation orientation, int itemCount, in Mea
496509
return orientation == Orientation.Horizontal ? new(sizeU, sizeV) : new(sizeV, sizeU);
497510
}
498511

512+
private Size EstimateDesiredSize(Orientation orientation, int itemCount)
513+
{
514+
if (_scrollToIndex >= 0 && _scrollToElement is not null)
515+
{
516+
// We have an element to scroll to, so we can estimate the desired size based on the
517+
// element's position and the remaining elements.
518+
var remaining = itemCount - _scrollToIndex - 1;
519+
var u = orientation == Orientation.Horizontal ?
520+
_scrollToElement.Bounds.Right :
521+
_scrollToElement.Bounds.Bottom;
522+
var sizeU = u + (remaining * _lastEstimatedElementSizeU);
523+
return orientation == Orientation.Horizontal ?
524+
new(sizeU, DesiredSize.Height) :
525+
new(DesiredSize.Width, sizeU);
526+
}
527+
528+
return DesiredSize;
529+
}
530+
499531
private double EstimateElementSizeU()
500532
{
501533
if (_realizedElements is null)

tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ namespace Avalonia.Controls.UnitTests
2323
{
2424
public class VirtualizingStackPanelTests : ScopedTestBase
2525
{
26+
private static FuncDataTemplate<ItemWithHeight> CanvasWithHeightTemplate = new((_, _) =>
27+
new Canvas
28+
{
29+
Width = 100,
30+
[!Layoutable.HeightProperty] = new Binding("Height"),
31+
});
32+
2633
[Fact]
2734
public void Creates_Initial_Items()
2835
{
@@ -744,14 +751,7 @@ public void Scrolling_Down_With_Larger_Element_Does_Not_Cause_Jump_And_Arrives_A
744751
var items = Enumerable.Range(0, 1000).Select(x => new ItemWithHeight(x)).ToList();
745752
items[20].Height = 200;
746753

747-
var itemTemplate = new FuncDataTemplate<ItemWithHeight>((x, _) =>
748-
new Canvas
749-
{
750-
Width = 100,
751-
[!Canvas.HeightProperty] = new Binding("Height"),
752-
});
753-
754-
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
754+
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
755755

756756
var index = target.FirstRealizedIndex;
757757

@@ -780,14 +780,7 @@ public void Scrolling_Up_To_Larger_Element_Does_Not_Cause_Jump()
780780
var items = Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x)).ToList();
781781
items[20].Height = 200;
782782

783-
var itemTemplate = new FuncDataTemplate<ItemWithHeight>((x, _) =>
784-
new Canvas
785-
{
786-
Width = 100,
787-
[!Canvas.HeightProperty] = new Binding("Height"),
788-
});
789-
790-
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
783+
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
791784

792785
// Scroll past the larger element.
793786
scroll.Offset = new Vector(0, 600);
@@ -817,14 +810,7 @@ public void Scrolling_Up_To_Smaller_Element_Does_Not_Cause_Jump()
817810
var items = Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x, 30)).ToList();
818811
items[20].Height = 25;
819812

820-
var itemTemplate = new FuncDataTemplate<ItemWithHeight>((x, _) =>
821-
new Canvas
822-
{
823-
Width = 100,
824-
[!Canvas.HeightProperty] = new Binding("Height"),
825-
});
826-
827-
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: itemTemplate);
813+
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
828814

829815
// Scroll past the larger element.
830816
scroll.Offset = new Vector(0, 25 * items[0].Height);
@@ -1154,6 +1140,58 @@ public void ScrollIntoView_With_TargetRect_Outside_Viewport_Should_Scroll_To_Ite
11541140
Assert.Equal(9901, scroll.Offset.X);
11551141
}
11561142

1143+
[Fact]
1144+
public void ScrollIntoView_Correctly_Scrolls_Down_To_A_Page_Of_Smaller_Items()
1145+
{
1146+
using var app = App();
1147+
1148+
// First 10 items have height of 20, next 10 have height of 10.
1149+
var items = Enumerable.Range(0, 20).Select(x => new ItemWithHeight(x, ((29 - x) / 10) * 10));
1150+
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
1151+
1152+
// Scroll the last item into view.
1153+
target.ScrollIntoView(19);
1154+
1155+
// At the time of the scroll, the average item height is 20, so the requested item
1156+
// should be placed at 380 (19 * 20) which therefore results in an extent of 390 to
1157+
// accommodate the item height of 10. This is obviously not a perfect answer, but
1158+
// it's the best we can do without knowing the actual item heights.
1159+
var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(19));
1160+
Assert.Equal(new Rect(0, 380, 100, 10), container.Bounds);
1161+
Assert.Equal(new Size(100, 100), scroll.Viewport);
1162+
Assert.Equal(new Size(100, 390), scroll.Extent);
1163+
Assert.Equal(new Vector(0, 290), scroll.Offset);
1164+
1165+
// Items 10-19 should be visible.
1166+
AssertRealizedItems(target, itemsControl, 10, 10);
1167+
}
1168+
1169+
[Fact]
1170+
public void ScrollIntoView_Correctly_Scrolls_Down_To_A_Page_Of_Larger_Items()
1171+
{
1172+
using var app = App();
1173+
1174+
// First 10 items have height of 10, next 10 have height of 20.
1175+
var items = Enumerable.Range(0, 20).Select(x => new ItemWithHeight(x, ((x / 10) + 1) * 10));
1176+
var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
1177+
1178+
// Scroll the last item into view.
1179+
target.ScrollIntoView(19);
1180+
1181+
// At the time of the scroll, the average item height is 10, so the requested item
1182+
// should be placed at 190 (19 * 10) which therefore results in an extent of 210 to
1183+
// accommodate the item height of 20. This is obviously not a perfect answer, but
1184+
// it's the best we can do without knowing the actual item heights.
1185+
var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(19));
1186+
Assert.Equal(new Rect(0, 190, 100, 20), container.Bounds);
1187+
Assert.Equal(new Size(100, 100), scroll.Viewport);
1188+
Assert.Equal(new Size(100, 210), scroll.Extent);
1189+
Assert.Equal(new Vector(0, 110), scroll.Offset);
1190+
1191+
// Items 15-19 should be visible.
1192+
AssertRealizedItems(target, itemsControl, 15, 5);
1193+
}
1194+
11571195
private static IReadOnlyList<int> GetRealizedIndexes(VirtualizingStackPanel target, ItemsControl itemsControl)
11581196
{
11591197
return target.GetRealizedElements()
@@ -1176,6 +1214,11 @@ private static void AssertRealizedItems(
11761214
.OrderBy(x => x)
11771215
.ToList();
11781216
Assert.Equal(Enumerable.Range(firstIndex, count), childIndexes);
1217+
1218+
var visibleChildren = target.Children
1219+
.Where(x => x.IsVisible)
1220+
.ToList();
1221+
Assert.Equal(count, visibleChildren.Count);
11791222
}
11801223

11811224
private static void AssertRealizedControlItems<TContainer>(

0 commit comments

Comments
 (0)