From f5d2bc9e6dd23231b1328c18c8d3644146226a80 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen Date: Thu, 20 Aug 2026 10:50:13 +0000 Subject: [PATCH] fix(VirtualizingStackPanel): hide cached focused items (#21835) * test(VirtualizingStackPanel): cover focused item virtualization A focused item remains attached so keyboard focus can be preserved after it leaves the realized range. It must nevertheless stop rendering until it is realized again; otherwise an estimated position can surface it in the viewport. * fix(VirtualizingStackPanel): hide cached focused items A focused container stays attached after it leaves the realized range so keyboard focus survives. Its estimated position can overlap the viewport when item sizes vary, causing it to render and receive pointer input as a ghost item.\n\nSuppress rendering and hit testing while the container is cached, then restore its original state before re-realization or recycling. This preserves focus and template-provided opacity. * fix(VirtualizingStackPanel): restore current focused container state Focused-container suppression previously replayed the opacity and hit-test values observed when virtualization began. That could hide updates from bindings, styles, or application code while the container was retained.\n\nUse disposable animation-priority overrides for the temporary suppression so disposing them reveals the property's current underlying value. * fix(VirtualizingStackPanel): keep retained focused container out of the viewport The container of a focused item that leaves the realized range is retained to preserve keyboard focus, and arranged at a position estimated from the average realized element size. With differing item sizes that estimate can place the container so that it overlaps the viewport, where it renders as a ghost item and intercepts pointer input (#17935). An unrealized item before the realized range always ends at or before the range's start, and one after it always starts at or after the range's end. Clamp the estimated arrange position to that invariant so the retained container can never overlap the realized elements, replacing the previous approach of masking the container with animation-priority opacity and hit-test overrides. --- .../VirtualizingStackPanel.cs | 22 ++++++++++-- .../VirtualizingStackPanelTests.cs | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index a8cc7b669d..62010aecc4 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -293,10 +293,28 @@ namespace Avalonia.Controls // Ensure that the focused element is in the correct position. if (_focusedElement is not null && _focusedIndex >= 0) { + var realizedEndU = u; + var sizeU = orientation == Orientation.Horizontal ? + _focusedElement.DesiredSize.Width : + _focusedElement.DesiredSize.Height; + u = GetOrEstimateElementU(_focusedIndex); + + // The focused element's position is estimated as it's outside the realized + // range. A bad estimate must not place it over the realized elements in the + // viewport: an element before the realized range always ends at or before its + // start, and one after it always starts at or after its end. + if (_realizedElements.Count > 0 && !double.IsNaN(_realizedElements.StartU)) + { + if (_focusedIndex < _realizedElements.FirstIndex) + u = Math.Min(u, _realizedElements.StartU - sizeU); + else if (_focusedIndex > _realizedElements.LastIndex) + u = Math.Max(u, realizedEndU); + } + var rect = orientation == Orientation.Horizontal ? - new Rect(u, 0, _focusedElement.DesiredSize.Width, finalSize.Height) : - new Rect(0, u, finalSize.Width, _focusedElement.DesiredSize.Height); + new Rect(u, 0, sizeU, finalSize.Height) : + new Rect(0, u, finalSize.Width, sizeU); _focusedElement.Arrange(rect); } diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 873da11c67..f9d079835d 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -492,6 +492,40 @@ namespace Avalonia.Controls.UnitTests Assert.Same(focused, target.GetRealizedElements().First()); } + [Theory] + [InlineData(0d)] + [InlineData(0.5d)] + public void Focused_Element_Outside_Realized_Range_Is_Not_Arranged_In_Viewport(double bufferFactor) + { + using var app = App(); + + // Item 0 is much taller than the others, so the average-based position estimated for + // it once it has left the realized range lands inside the viewport (#17935). + var items = Enumerable.Range(0, 100).Select(x => new ItemWithHeight(x, x == 0 ? 100 : 10)); + var (target, scroll, itemsControl) = CreateTarget( + items: items, + itemTemplate: CanvasWithHeightTemplate, + bufferFactor: bufferFactor); + + var focused = target.GetRealizedElements().First()!; + focused.Focusable = true; + focused.Focus(); + Assert.True(focused.IsKeyboardFocusWithin); + + scroll.Offset = new Vector(0, 160); + Layout(target); + + var viewport = new Rect(scroll.Offset.X, scroll.Offset.Y, scroll.Viewport.Width, scroll.Viewport.Height); + Assert.True(focused.IsKeyboardFocusWithin); + Assert.False(focused.Bounds.Intersects(viewport)); + + scroll.Offset = new Vector(0, 0); + Layout(target); + + Assert.Same(focused, target.GetRealizedElements().First()); + Assert.Equal(new Rect(0, 0, 100, 100), focused.Bounds); + } + [Theory] [InlineData(0d)] [InlineData(0.5d)]