Skip to content

Commit 38402fc

Browse files
zrt2399grokysclaude
authored
Fix VirtualizingStackPanel offset when scrolling to variable-sized items (#21975)
* Fix VirtualizingStackPanel offset when scrolling to variable-sized items * Remove the `isScrollIntoView` parameter. * Tweak now-failing test. Change the the test to check that the element is outside of the viewport, instead of asserting its exact coordinates - that's the important part. * Retrigger CI. Azure Pipelines never queued a build for 3d81ceb. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169P8BipcMkn44hvhpQ2UNF --------- Co-authored-by: grokys <grokys@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 33b288f commit 38402fc

2 files changed

Lines changed: 84 additions & 46 deletions

File tree

src/Avalonia.Controls/VirtualizingStackPanel.cs

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo
5656
/// Defines the <see cref="CacheLength"/> property.
5757
/// </summary>
5858
public static readonly StyledProperty<double> CacheLengthProperty =
59-
AvaloniaProperty.Register<VirtualizingStackPanel, double>(nameof(CacheLength), 0.0,
59+
AvaloniaProperty.Register<VirtualizingStackPanel, double>(nameof(CacheLength), 0.0,
6060
validate: v => v is >= 0 and <= 2);
6161

6262
private static readonly AttachedProperty<object?> RecycleKeyProperty =
@@ -80,8 +80,8 @@ public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo
8080
private int _focusedIndex = -1;
8181
private Control? _realizingElement;
8282
private int _realizingIndex = -1;
83-
private double _bufferFactor;
84-
83+
private double _bufferFactor;
84+
8585
private bool _hasReachedStart = false;
8686
private bool _hasReachedEnd = false;
8787
private Rect _lastMeasuredExtendedViewport;
@@ -270,7 +270,7 @@ protected override Size ArrangeOverride(Size finalSize)
270270
new Rect(0, u, finalSize.Width, sizeU);
271271

272272
e.Arrange(rect);
273-
273+
274274
if (e.IsVisible && _viewport.Intersects(rect))
275275
{
276276
try
@@ -285,7 +285,7 @@ protected override Size ArrangeOverride(Size finalSize)
285285
e, ex.Message);
286286
}
287287
}
288-
288+
289289
u += orientation == Orientation.Horizontal ? rect.Width : rect.Height;
290290
}
291291
}
@@ -543,7 +543,7 @@ protected override void OnItemsControlChanged(ItemsControl? oldValue)
543543
var count = Items.Count;
544544
var fromControl = from as Control;
545545

546-
if (count == 0 ||
546+
if (count == 0 ||
547547
(fromControl is null && direction is not NavigationDirection.First and not NavigationDirection.Last))
548548
return null;
549549

@@ -644,7 +644,7 @@ protected internal override int IndexFromContainer(Control container)
644644
element.BringIntoView();
645645
return element;
646646
}
647-
else if (this.GetLayoutRoot() is {} root)
647+
else if (this.GetLayoutRoot() is { } root)
648648
{
649649
// Create and measure the element to be brought into view. Store it in a field so that
650650
// it can be re-used in the layout pass.
@@ -746,7 +746,7 @@ private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadO
746746
}
747747

748748
// Check if the anchor element is not within the currently realized elements.
749-
var disjunct = anchorIndex < _realizedElements.FirstIndex ||
749+
var disjunct = anchorIndex < _realizedElements.FirstIndex ||
750750
anchorIndex > _realizedElements.LastIndex;
751751

752752
return new MeasureViewport
@@ -780,12 +780,12 @@ private Size EstimateDesiredSize(Orientation orientation, int itemCount)
780780
// We have an element to scroll to, so we can estimate the desired size based on the
781781
// element's position and the remaining elements.
782782
var remaining = itemCount - _scrollToIndex - 1;
783-
var u = orientation == Orientation.Horizontal ?
783+
var u = orientation == Orientation.Horizontal ?
784784
_scrollToElement.Bounds.Right :
785785
_scrollToElement.Bounds.Bottom;
786786
var sizeU = u + (remaining * _lastEstimatedElementSizeU);
787-
return orientation == Orientation.Horizontal ?
788-
new(sizeU, DesiredSize.Height) :
787+
return orientation == Orientation.Horizontal ?
788+
new(sizeU, DesiredSize.Height) :
789789
new(DesiredSize.Width, sizeU);
790790
}
791791

@@ -901,23 +901,26 @@ private double GetOrEstimateElementU(int index)
901901
{
902902
var first = realized.FirstIndex;
903903
var last = realized.LastIndex;
904-
904+
905905
if (index < first)
906906
{
907-
return realized.StartU - ((first - index) * estimatedSize);
907+
// Interpolate between the known panel origin and the first realized item.
908+
// Using the realized items' average size to extrapolate backwards can place
909+
// the target before the panel origin.
910+
return realized.StartU * index / first;
908911
}
909-
912+
910913
if (index > last)
911914
{
912915
var sizes = realized.SizeU;
913916
var realizedSpan = 0.0;
914-
917+
915918
for (var i = 0; i < sizes.Count; ++i)
916919
{
917920
var sizeU = sizes[i];
918921
realizedSpan += double.IsNaN(sizeU) ? estimatedSize : sizeU;
919922
}
920-
923+
921924
return realized.StartU + realizedSpan + ((index - last - 1) * estimatedSize);
922925
}
923926
}
@@ -940,7 +943,7 @@ private void RealizeElements(
940943
var viewportEnd = horizontal ? _viewport.Right : _viewport.Bottom;
941944
var anchorAtEnd = !_hasReachedEnd && index == items.Count - 1 &&
942945
MathUtilities.GreaterThanOrClose(viewportEnd, horizontal ? Bounds.Width : Bounds.Height);
943-
946+
944947
// Reset boundary flags
945948
_hasReachedStart = false;
946949
_hasReachedEnd = false;
@@ -956,9 +959,9 @@ private void RealizeElements(
956959
_realizingIndex = index;
957960
var e = GetOrCreateElement(items, index);
958961
_realizingElement = e;
959-
962+
960963
e.Measure(availableSize);
961-
964+
962965
var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height;
963966
var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width;
964967

@@ -976,10 +979,10 @@ private void RealizeElements(
976979
_realizingIndex = -1;
977980
_realizingElement = null;
978981
} while (u < viewport.viewportUEnd && index < items.Count);
979-
982+
980983
// Check if we reached the end of the collection
981984
_hasReachedEnd = index >= items.Count;
982-
985+
983986
// Store the last index and end U position for the desired size calculation.
984987
viewport.lastIndex = index - 1;
985988
viewport.realizedEndU = u;
@@ -994,7 +997,7 @@ private void RealizeElements(
994997
while (u > viewport.viewportUStart && index >= 0)
995998
{
996999
var e = GetOrCreateElement(items, index);
997-
1000+
9981001
e.Measure(availableSize);
9991002
var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height;
10001003
var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width;
@@ -1004,7 +1007,7 @@ private void RealizeElements(
10041007
viewport.measuredV = Math.Max(viewport.measuredV, sizeV);
10051008
--index;
10061009
}
1007-
1010+
10081011
// Check if we reached the start of the collection
10091012
_hasReachedStart = index < 0;
10101013

@@ -1039,7 +1042,7 @@ private Control GetOrCreateElement(IReadOnlyList<object?> items, int index)
10391042
{
10401043
return _realizedElements?.GetElement(index);
10411044
}
1042-
1045+
10431046
private static Control? GetRealizedElement(
10441047
int index,
10451048
ref int specialIndex,
@@ -1118,7 +1121,7 @@ private void RecycleElement(Control element, int index)
11181121
{
11191122
Debug.Assert(ItemsControl is not null);
11201123
Debug.Assert(ItemContainerGenerator is not null);
1121-
1124+
11221125
_scrollAnchorProvider?.UnregisterAnchorCandidate(element);
11231126

11241127
var recycleKey = element.GetValue(RecycleKeyProperty);
@@ -1153,7 +1156,7 @@ private void RecycleElementOnItemRemoved(Control element)
11531156
_scrollAnchorProvider?.UnregisterAnchorCandidate(element);
11541157

11551158
var recycleKey = element.GetValue(RecycleKeyProperty);
1156-
1159+
11571160
if (recycleKey is null)
11581161
{
11591162
ItemContainerGenerator!.ClearItemContainer(element);
@@ -1181,7 +1184,7 @@ private void RecycleFocusedElement()
11811184
_focusedElement = null;
11821185
_focusedIndex = -1;
11831186
}
1184-
1187+
11851188
private void RecycleScrollToElement()
11861189
{
11871190
if (_scrollToElement != null)
@@ -1191,7 +1194,7 @@ private void RecycleScrollToElement()
11911194
_scrollToElement = null;
11921195
_scrollToIndex = -1;
11931196
}
1194-
1197+
11951198
private void PushToRecyclePool(object recycleKey, Control element)
11961199
{
11971200
_recyclePool ??= new();
@@ -1211,7 +1214,7 @@ private void UpdateElementIndex(Control element, int oldIndex, int newIndex)
12111214

12121215
ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex);
12131216
}
1214-
1217+
12151218
private Rect CalculateExtendedViewport(bool vertical, double viewportSize, double bufferSize)
12161219
{
12171220

@@ -1378,7 +1381,7 @@ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChanged
13781381
private void OnItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
13791382
{
13801383
if (_focusedElement is not null &&
1381-
e.Property == KeyboardNavigation.TabOnceActiveElementProperty &&
1384+
e.Property == KeyboardNavigation.TabOnceActiveElementProperty &&
13821385
e.GetOldValue<IInputElement?>() == _focusedElement)
13831386
{
13841387
// TabOnceActiveElement has moved away from _focusedElement so we can recycle it.
@@ -1392,15 +1395,15 @@ private void OnCacheLengthChanged(AvaloniaPropertyChangedEventArgs e)
13921395
{
13931396
var newValue = e.GetNewValue<double>();
13941397
_bufferFactor = newValue;
1395-
1398+
13961399
// Force a recalculation of the extended viewport on the next layout pass
13971400
InvalidateMeasure();
13981401
}
1399-
1402+
14001403
/// <inheritdoc/>
14011404
public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
14021405
{
1403-
if(_realizedElements == null)
1406+
if (_realizedElements == null)
14041407
return new List<double>();
14051408

14061409
return new VirtualizingSnapPointsList(_realizedElements, ItemsControl?.ItemsSource?.Count() ?? 0, orientation, Orientation, snapPointsAlignment, EstimateElementSizeU());

tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections;
33
using System.Collections.Generic;
44
using System.Collections.ObjectModel;
@@ -1683,15 +1683,15 @@ public void Focused_Container_Is_Positioned_Correctly_when_Container_Size_Change
16831683
}
16841684

16851685
[Fact]
1686-
public void Focused_Container_Is_Positioned_Correctly_When_Scrolled_Past_Items_With_Different_Heights()
1686+
public void Focused_Container_Is_Positioned_Outside_Viewport_When_Scrolled_Past_Items_With_Different_Heights()
16871687
{
16881688
using var app = App();
16891689

16901690
var items = Enumerable.Range(0, 20)
16911691
.Select(x => new ItemWithHeight(x, x < 10 ? 10 : 50))
16921692
.ToList();
16931693

1694-
var (target, _, _) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
1694+
var (target, scroll, _) = CreateTarget(items: items, itemTemplate: CanvasWithHeightTemplate);
16951695

16961696
var focused = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(5));
16971697
focused.Focusable = true;
@@ -1702,18 +1702,19 @@ public void Focused_Container_Is_Positioned_Correctly_When_Scrolled_Past_Items_W
17021702

17031703
Assert.True(target.FirstRealizedIndex > 5);
17041704

1705-
var firstIndex = target.FirstRealizedIndex;
1706-
var firstRealized = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(firstIndex));
1707-
var realized = target.GetRealizedElements()
1708-
.Where(x => x is not null)
1709-
.Cast<Control>()
1710-
.ToList();
1705+
var firstRealized = Assert.IsType<ContentPresenter>(
1706+
target.ContainerFromIndex(target.FirstRealizedIndex));
1707+
focused = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(5));
17111708

1712-
var estimatedSize = realized.Average(x => x.DesiredSize.Height);
1713-
var expectedTop = firstRealized.Bounds.Top - ((firstIndex - 5) * estimatedSize);
1709+
// The focused container's position is estimated, as it's outside the realized range.
1710+
// The estimate must never place it before the panel origin...
1711+
Assert.True(focused.Bounds.Top >= 0);
17141712

1715-
focused = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(5));
1716-
Assert.Equal(expectedTop, focused.Bounds.Top, 3);
1713+
// ...must keep it above the realized range rather than overlapping it...
1714+
Assert.True(focused.Bounds.Bottom <= firstRealized.Bounds.Top);
1715+
1716+
// ...and must keep it out of the viewport, so it can't appear as a ghost item.
1717+
Assert.True(focused.Bounds.Bottom <= scroll.Offset.Y);
17171718
}
17181719

17191720
[Theory]
@@ -1765,6 +1766,40 @@ public void Focused_Container_Is_Positioned_Correctly_when_Container_Size_Change
17651766
Assert.Equal(new Rect(0, 140, 100, 20), container.Bounds);
17661767
}
17671768

1769+
[Theory]
1770+
[InlineData(25, Orientation.Vertical)]
1771+
[InlineData(99, Orientation.Vertical)]
1772+
[InlineData(25, Orientation.Horizontal)]
1773+
[InlineData(99, Orientation.Horizontal)]
1774+
public void ScrollIntoView_With_Variable_Size_Items_Keeps_Target_In_Viewport(int targetIndex, Orientation orientation)
1775+
{
1776+
using var app = App();
1777+
1778+
var firstHalfSize = targetIndex < 60 ? 20 : 40;
1779+
var secondHalfSize = targetIndex < 60 ? 40 : 20;
1780+
var horizontal = orientation == Orientation.Horizontal;
1781+
IEnumerable<object> items = horizontal ?
1782+
Enumerable.Range(0, 100).Select(x => new ItemWithWidth(x, x < 50 ? firstHalfSize : secondHalfSize)) :
1783+
Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x, x < 50 ? firstHalfSize : secondHalfSize));
1784+
Optional<IDataTemplate?> itemTemplate = horizontal ? CanvasWithWidthTemplate : CanvasWithHeightTemplate;
1785+
var (target, scroll, _) = CreateTarget(items: items, itemTemplate: itemTemplate, orientation: orientation);
1786+
1787+
target.ScrollIntoView(60);
1788+
target.ScrollIntoView(targetIndex);
1789+
1790+
var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(targetIndex));
1791+
var message = $"Bounds={container.Bounds}, Offset={scroll.Offset}, Viewport={scroll.Viewport}, Extent={scroll.Extent}";
1792+
1793+
var containerStart = horizontal ? container.Bounds.Left : container.Bounds.Top;
1794+
var containerEnd = horizontal ? container.Bounds.Right : container.Bounds.Bottom;
1795+
var viewportStart = horizontal ? scroll.Offset.X : scroll.Offset.Y;
1796+
var viewportEnd = viewportStart + (horizontal ? scroll.Viewport.Width : scroll.Viewport.Height);
1797+
1798+
Assert.True(containerStart > 0, message);
1799+
Assert.True(containerStart >= viewportStart, message);
1800+
Assert.True(containerEnd <= viewportEnd, message);
1801+
}
1802+
17681803
[Fact]
17691804
public void When_Vertical_Calculates_ViewPort_At_Start_Of_List()
17701805
{

0 commit comments

Comments
 (0)