Skip to content

Commit

Permalink
Fix VirtualizingStackPanel ScrollIntoView (#15449)
Browse files Browse the repository at this point in the history
* Add more tests for ScrollIntoView.

* Improve ScrollIntoView.

Take into account the element we're scrolling to when calculating the anchor element for realization.
  • Loading branch information
grokys authored Apr 22, 2024
1 parent edb66a7 commit 91646f1
Show file tree
Hide file tree
Showing 2 changed files with 108 additions and 33 deletions.
50 changes: 41 additions & 9 deletions src/Avalonia.Controls/VirtualizingStackPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -459,12 +459,25 @@ private MeasureViewport CalculateMeasureViewport(IReadOnlyList<object?> 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 ||
Expand Down Expand Up @@ -494,6 +507,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)
Expand Down
91 changes: 67 additions & 24 deletions tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<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;

Expand Down Expand Up @@ -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<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);
Expand Down Expand Up @@ -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<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);
Expand Down Expand Up @@ -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<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()
Expand All @@ -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<TContainer>(
Expand Down

0 comments on commit 91646f1

Please sign in to comment.