From 9c4480d5fed91f4e697dc43fb8980293d970d9e8 Mon Sep 17 00:00:00 2001 From: Alexander Marek Date: Thu, 19 Mar 2026 11:14:53 +0100 Subject: [PATCH] #20868 - virtualizingstackpanel, when viewport shrinks and grows again, does not re-measure items (#20870) Co-authored-by: alexander.marek --- .../VirtualizingStackPanel.cs | 111 +++++++++------- .../VirtualizingStackPanelTests.cs | 121 ++++++++++++++++-- 2 files changed, 173 insertions(+), 59 deletions(-) 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