diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs
index bc529b526c..669e75d81a 100644
--- a/src/Avalonia.Controls/VirtualizingStackPanel.cs
+++ b/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
///
/// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2)
///
- 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();
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
index 36c15f76bb..873da11c67 100644
--- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
+++ b/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(
+ 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