Browse Source

#20868 - virtualizingstackpanel, when viewport shrinks and grows again, does not re-measure items (#20870)

Co-authored-by: alexander.marek <alexander.marek@opti-q.com>
pull/20953/head
Alexander Marek 5 days ago
committed by GitHub
parent
commit
9c4480d5fe
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 111
      src/Avalonia.Controls/VirtualizingStackPanel.cs
  2. 121
      tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

111
src/Avalonia.Controls/VirtualizingStackPanel.cs

@ -84,7 +84,8 @@ namespace Avalonia.Controls
private bool _hasReachedStart = false;
private bool _hasReachedEnd = false;
private Rect _extendedViewport;
private Rect _lastMeasuredExtendedViewport;
private Rect _lastKnownExtendedViewport;
static VirtualizingStackPanel()
{
@ -182,7 +183,7 @@ namespace Avalonia.Controls
/// <summary>
/// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2)
/// </summary>
internal Rect ExtendedViewPort => _extendedViewport;
internal Rect LastMeasuredExtendedViewPort => _lastMeasuredExtendedViewport;
protected override Size MeasureOverride(Size availableSize)
{
@ -692,7 +693,7 @@ namespace Avalonia.Controls
Debug.Assert(_realizedElements is not null);
// Use the extended viewport for calculations
var viewport = _extendedViewport;
var viewport = _lastMeasuredExtendedViewport;
// Get the viewport in the orientation direction.
var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
@ -1165,40 +1166,25 @@ namespace Avalonia.Controls
ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex);
}
private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
private Rect CalculateExtendedViewport(bool vertical, double viewportSize, double bufferSize)
{
var vertical = Orientation == Orientation.Vertical;
var oldViewportStart = vertical ? _viewport.Top : _viewport.Left;
var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right;
var oldExtendedViewportStart = vertical ? _extendedViewport.Top : _extendedViewport.Left;
var oldExtendedViewportEnd = vertical ? _extendedViewport.Bottom : _extendedViewport.Right;
// Update current viewport
_viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
_isWaitingForViewportUpdate = false;
// Calculate buffer sizes based on viewport dimensions
var viewportSize = vertical ? _viewport.Height : _viewport.Width;
var bufferSize = viewportSize * _bufferFactor;
// Calculate extended viewport with relative buffers
var extendedViewportStart = vertical ?
Math.Max(0, _viewport.Top - bufferSize) :
var extendedViewportStart = vertical ?
Math.Max(0, _viewport.Top - bufferSize) :
Math.Max(0, _viewport.Left - bufferSize);
var extendedViewportEnd = vertical ?
Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) :
var extendedViewportEnd = vertical ?
Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) :
Math.Min(Bounds.Width, _viewport.Right + bufferSize);
// special case:
// If we are at the start of the list, append 2 * CacheLength additional items
// If we are at the end of the list, prepend 2 * CacheLength additional items
// - this way we always maintain "2 * CacheLength * element" items.
// - this way we always maintain "2 * CacheLength * element" items.
if (vertical)
{
var spaceAbove = _viewport.Top - bufferSize;
var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize);
if (spaceAbove < 0 && spaceBelow >= 0)
extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove));
if (spaceAbove >= 0 && spaceBelow < 0)
@ -1208,30 +1194,48 @@ namespace Avalonia.Controls
{
var spaceLeft = _viewport.Left - bufferSize;
var spaceRight = Bounds.Width - (_viewport.Right + bufferSize);
if (spaceLeft < 0 && spaceRight >= 0)
extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft));
if(spaceLeft >= 0 && spaceRight < 0)
if (spaceLeft >= 0 && spaceRight < 0)
extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight));
}
Rect extendedViewPort;
if (vertical)
{
extendedViewPort = new Rect(
_viewport.X,
return new Rect(
_viewport.X,
extendedViewportStart,
_viewport.Width,
extendedViewportEnd - extendedViewportStart);
}
else
{
extendedViewPort = new Rect(
return new Rect(
extendedViewportStart,
_viewport.Y,
extendedViewportEnd - extendedViewportStart,
_viewport.Height);
}
}
private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
{
var vertical = Orientation == Orientation.Vertical;
var oldViewportStart = vertical ? _viewport.Top : _viewport.Left;
var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right;
var oldExtendedViewportStart = vertical ? _lastMeasuredExtendedViewport.Top : _lastMeasuredExtendedViewport.Left;
var oldExtendedViewportEnd = vertical ? _lastMeasuredExtendedViewport.Bottom : _lastMeasuredExtendedViewport.Right;
// Update current viewport
_viewport = e.EffectiveViewport.Intersect(new(Bounds.Size));
_isWaitingForViewportUpdate = false;
// Calculate buffer sizes based on viewport dimensions
var viewportSize = vertical ? _viewport.Height : _viewport.Width;
var bufferSize = viewportSize * _bufferFactor;
var extendedViewPort = CalculateExtendedViewport(vertical, viewportSize, bufferSize);
// Determine if we need a new measure
var newViewportStart = vertical ? _viewport.Top : _viewport.Left;
@ -1240,14 +1244,13 @@ namespace Avalonia.Controls
var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right;
var needsMeasure = false;
// Case 1: Viewport has changed significantly
if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) ||
!MathUtilities.AreClose(oldViewportEnd, newViewportEnd))
{
// Case 1a: The new viewport exceeds the old extended viewport
if (newViewportStart < oldExtendedViewportStart ||
if (newViewportStart < oldExtendedViewportStart ||
newViewportEnd > oldExtendedViewportEnd)
{
needsMeasure = true;
@ -1259,19 +1262,19 @@ namespace Avalonia.Controls
// Check if we're about to scroll into an area where we don't have realized elements
// This would be the case if we're near the edge of our current extended viewport
var nearingEdge = false;
if (_realizedElements != null)
{
var firstRealizedElementU = _realizedElements.StartU;
var lastRealizedElementU = _realizedElements.StartU;
for (var i = 0; i < _realizedElements.Count; i++)
{
lastRealizedElementU += _realizedElements.SizeU[i];
}
// If scrolling up/left and nearing the top/left edge of realized elements
if (newViewportStart < oldViewportStart &&
if (newViewportStart < oldViewportStart &&
newViewportStart - newExtendedViewportStart < bufferSize)
{
// Edge case: We're at item 0 with excess measurement space.
@ -1279,9 +1282,9 @@ namespace Avalonia.Controls
// This prevents redundant Measure-Arrange cycles when at list beginning.
nearingEdge = !_hasReachedStart;
}
// If scrolling down/right and nearing the bottom/right edge of realized elements
if (newViewportEnd > oldViewportEnd &&
if (newViewportEnd > oldViewportEnd &&
newExtendedViewportEnd - newViewportEnd < bufferSize)
{
// Edge case: We're at the last item with excess measurement space.
@ -1294,16 +1297,34 @@ namespace Avalonia.Controls
{
nearingEdge = true;
}
needsMeasure = nearingEdge;
}
}
// Supplementary check: detect viewport growth after a previous shrink.
// The main comparison (Cases 1a/1b) uses _extendedViewport which only updates
// on measure. When the viewport shrinks (e.g. ComboBox popup during filtering),
// _extendedViewport stays stale-large, masking subsequent growth. Compare against
// _lastKnownExtendedViewport (always updated) to catch this case.
if (!needsMeasure)
{
var lastKnownStart = vertical ? _lastKnownExtendedViewport.Top : _lastKnownExtendedViewport.Left;
var lastKnownEnd = vertical ? _lastKnownExtendedViewport.Bottom : _lastKnownExtendedViewport.Right;
if (newViewportStart < lastKnownStart || newViewportEnd > lastKnownEnd)
{
needsMeasure = true;
}
}
_lastKnownExtendedViewport = extendedViewPort;
if (needsMeasure)
{
// only store the new "old" extended viewport if we _did_ actually measure
_extendedViewport = extendedViewPort;
// Only update the measure viewport when triggering a measure. This keeps the
// wider realization range available for externally-triggered measures (e.g. from
// OnItemsChanged), ensuring enough items are realized.
_lastMeasuredExtendedViewport = extendedViewPort;
InvalidateMeasure();
}
}

121
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@ -632,6 +632,69 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new Vector(0, 0), scroll.Offset);
}
[Fact]
public void Shrinking_Viewport_Then_Growing_Back_Triggers_Remeasure()
{
// Regression test for stale _extendedViewport comparison in OnEffectiveViewportChanged.
//
// When the viewport shrinks (e.g., ComboBox popup shrinks during filtering),
// OnEffectiveViewportChanged doesn't trigger a measure (needsMeasure=false because
// the smaller viewport is within the old extended viewport). The _extendedViewport
// comparison baseline is NOT updated. When the viewport later grows back,
// OnEffectiveViewportChanged compares against the stale large _extendedViewport,
// concludes "no significant change", and skips the measure. This prevents item
// realization when the only measure trigger is OnEffectiveViewportChanged.
//
// The fix uses a separate _lastKnownExtendedViewport that is always updated,
// so the comparison correctly detects viewport growth after a shrink.
//
// Key: ScrollContentPresenter passes infinite height for vertical scroll, so
// the panel's MeasureOverride is NOT called from the layout cascade when only
// the root size changes. OnEffectiveViewportChanged is the sole measure trigger.
using var app = App();
var items = Enumerable.Range(0, 20).Select(x => $"Item {x}");
var (target, scroll, itemsControl) =
CreateUnrootedTarget<ItemsControl, VirtualizingStackPanelCountingMeasureArrange>(
items: items, bufferFactor: 0);
var root = CreateRoot(itemsControl, new Size(100, 100));
root.LayoutManager.ExecuteInitialLayoutPass();
// Initial state: viewport 0-100, 10 items visible, _extendedViewport = (0,0,100,100)
AssertRealizedItems(target, itemsControl, 0, 10);
// Shrink viewport (simulates popup shrinking when items are filtered).
// Panel MeasureOverride is NOT called (ScrollContentPresenter passes infinite height).
// OnEffectiveViewportChanged fires with small viewport but needsMeasure=false
// because the small viewport is within the old _extendedViewport.
root.ClientSize = new Size(100, 10);
root.InvalidateMeasure();
Layout(target);
// Reset counters after shrink
target.ResetMeasureArrangeCounters();
// Grow viewport back (simulates popup growing when filter is removed).
// Panel MeasureOverride is NOT called from layout cascade (same infinite constraint).
// OnEffectiveViewportChanged is the ONLY path to trigger a remeasure.
root.ClientSize = new Size(100, 100);
root.InvalidateMeasure();
Layout(target);
// Without fix: OnEffectiveViewportChanged compares new viewport (0-100) against
// stale _extendedViewport (0-100, never updated during shrink). Sees no change.
// needsMeasure=false. No remeasure triggered. Measure count = 0.
//
// With fix: compares against _lastKnownExtendedViewport (0-10, updated during
// shrink). Detects that viewport grew past it (100 > 10). needsMeasure=true.
// InvalidateMeasure called. Measure count >= 1.
Assert.True(target.Measured >= 1,
"Panel should be re-measured when viewport grows back after a previous shrink. " +
"OnEffectiveViewportChanged must detect viewport growth by comparing against " +
"the last known extended viewport, not the stale _extendedViewport.");
}
[Theory]
[InlineData(0d, 10, "4,9")]
[InlineData(0.5d, 20, "4,9,14,19")]
@ -1655,8 +1718,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(0, target.ViewPort.Top);
Assert.Equal(100, target.ViewPort.Bottom);
Assert.Equal(0, target.ExtendedViewPort.Top);
Assert.Equal(200, target.ExtendedViewPort.Bottom);
Assert.Equal(0, target.LastMeasuredExtendedViewPort.Top);
Assert.Equal(200, target.LastMeasuredExtendedViewPort.Bottom);
}
[Fact]
@ -1680,8 +1743,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(900, target.ViewPort.Top);
Assert.Equal(1000, target.ViewPort.Bottom);
Assert.Equal(800, target.ExtendedViewPort.Top);
Assert.Equal(1000, target.ExtendedViewPort.Bottom);
Assert.Equal(800, target.LastMeasuredExtendedViewPort.Top);
Assert.Equal(1000, target.LastMeasuredExtendedViewPort.Bottom);
}
[Fact]
@ -1705,8 +1768,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(500, target.ViewPort.Top);
Assert.Equal(600, target.ViewPort.Bottom);
Assert.Equal(450, target.ExtendedViewPort.Top);
Assert.Equal(650, target.ExtendedViewPort.Bottom);
Assert.Equal(450, target.LastMeasuredExtendedViewPort.Top);
Assert.Equal(650, target.LastMeasuredExtendedViewPort.Bottom);
}
[Fact]
@ -1729,8 +1792,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(0, target.ViewPort.Left);
Assert.Equal(100, target.ViewPort.Right);
Assert.Equal(0, target.ExtendedViewPort.Left);
Assert.Equal(200, target.ExtendedViewPort.Right);
Assert.Equal(0, target.LastMeasuredExtendedViewPort.Left);
Assert.Equal(200, target.LastMeasuredExtendedViewPort.Right);
}
[Fact]
@ -1754,8 +1817,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(900, target.ViewPort.Left);
Assert.Equal(1000, target.ViewPort.Right);
Assert.Equal(800, target.ExtendedViewPort.Left);
Assert.Equal(1000, target.ExtendedViewPort.Right);
Assert.Equal(800, target.LastMeasuredExtendedViewPort.Left);
Assert.Equal(1000, target.LastMeasuredExtendedViewPort.Right);
}
[Fact]
@ -1780,8 +1843,8 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(500, target.ViewPort.Left);
Assert.Equal(600, target.ViewPort.Right);
Assert.Equal(450, target.ExtendedViewPort.Left);
Assert.Equal(650, target.ExtendedViewPort.Right);
Assert.Equal(450, target.LastMeasuredExtendedViewPort.Left);
Assert.Equal(650, target.LastMeasuredExtendedViewPort.Right);
}
[Fact]
@ -1806,11 +1869,15 @@ namespace Avalonia.Controls.UnitTests
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.LastRealizedIndex < 20)
{
scroll.Offset = new Vector(0, scroll.Offset.Y + 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -1862,11 +1929,15 @@ namespace Avalonia.Controls.UnitTests
var initialFirstRealizedIndex = target.FirstRealizedIndex;
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex >= 15)
{
scroll.Offset = new Vector(0, scroll.Offset.Y - 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -1918,11 +1989,15 @@ namespace Avalonia.Controls.UnitTests
var initialLastRealizedIndex = target.LastRealizedIndex;
var count = 0;
// Scroll down until we reached the very last item
while (target.LastRealizedIndex < 99)
{
scroll.Offset = new Vector(0, scroll.Offset.Y + 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -1972,11 +2047,15 @@ namespace Avalonia.Controls.UnitTests
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex > 0)
{
scroll.Offset = new Vector(0, scroll.Offset.Y - 5);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2022,12 +2101,15 @@ namespace Avalonia.Controls.UnitTests
// shows 20 items, each is 10 high.
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.LastRealizedIndex < 20)
{
scroll.Offset = new Vector(scroll.Offset.X + 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2079,12 +2161,15 @@ namespace Avalonia.Controls.UnitTests
// until then no measure-arrange call should happen
var initialFirstRealizedIndex = target.FirstRealizedIndex;
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex >= 15)
{
scroll.Offset = new Vector(scroll.Offset.X - 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2137,11 +2222,15 @@ namespace Avalonia.Controls.UnitTests
var initialLastRealizedIndex = target.LastRealizedIndex;
var count = 0;
// Scroll down until we reached the very last item
while (target.LastRealizedIndex < 99)
{
scroll.Offset = new Vector(scroll.Offset.X + 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert
@ -2192,11 +2281,15 @@ namespace Avalonia.Controls.UnitTests
// visible are 10 => need to scroll down 100px until the next 5 (visible*BufferFactor) additional items are added.
// until then no measure-arrange call should happen
var count = 0;
// Scroll down until the extended viewport bounds are reached
while (target.FirstRealizedIndex > 0)
{
scroll.Offset = new Vector(scroll.Offset.X - 5, 0);
Layout(target);
count++;
if (count > 1000)
throw new InvalidOperationException("infinite scroll detected");
}
// Assert

Loading…
Cancel
Save