diff --git a/src/Avalonia.Base/Layout/LayoutManager.cs b/src/Avalonia.Base/Layout/LayoutManager.cs index 94955a18ae..747ee1c082 100644 --- a/src/Avalonia.Base/Layout/LayoutManager.cs +++ b/src/Avalonia.Base/Layout/LayoutManager.cs @@ -17,7 +17,7 @@ namespace Avalonia.Layout /// public class LayoutManager : ILayoutManager, IDisposable { - private const int MaxPasses = 3; + private const int MaxPasses = 10; private readonly Layoutable _owner; private readonly LayoutQueue _toMeasure = new LayoutQueue(v => !v.IsMeasureValid); private readonly LayoutQueue _toArrange = new LayoutQueue(v => !v.IsArrangeValid); diff --git a/src/Avalonia.Base/Layout/LayoutQueue.cs b/src/Avalonia.Base/Layout/LayoutQueue.cs index 24adeb0793..48efa501f2 100644 --- a/src/Avalonia.Base/Layout/LayoutQueue.cs +++ b/src/Avalonia.Base/Layout/LayoutQueue.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using Avalonia.Logging; namespace Avalonia.Layout { @@ -48,10 +49,21 @@ namespace Avalonia.Layout { _loopQueueInfo.TryGetValue(item, out var info); - if (!info.Active && info.Count < _maxEnqueueCountPerLoop) + if (!info.Active) { - _inner.Enqueue(item); - _loopQueueInfo[item] = new Info() { Active = true, Count = info.Count + 1 }; + if (info.Count < _maxEnqueueCountPerLoop) + { + _inner.Enqueue(item); + _loopQueueInfo[item] = new Info() { Active = true, Count = info.Count + 1 }; + } + else + { + Logger.TryGet(LogEventLevel.Warning, LogArea.Layout)?.Log( + this, + "Layout cycle detected. Item {Item} was enqueued {Count} times.", + item, + info.Count); + } } } diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index ed88b73149..08f327d048 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -776,10 +776,24 @@ namespace Avalonia.Layout // All changes to visibility cause the parent element to be notified. this.GetVisualParent()?.ChildDesiredSizeChanged(this); - // We only invalidate outselves when visibility is changed to true. if (change.GetNewValue()) { + // We only invalidate ourselves when visibility is changed to true. InvalidateMeasure(); + + // If any descendant had its measure/arrange invalidated while we were hidden, + // they will need to to be registered with the layout manager now that they + // are again effectively visible. If IsEffectivelyVisible becomes an observable + // property then we can piggy-pack on that; for the moment we do this manually. + if (VisualRoot is ILayoutRoot layoutRoot) + { + var count = VisualChildren.Count; + + for (var i = 0; i < count; ++i) + { + (VisualChildren[i] as Layoutable)?.AncestorBecameVisible(layoutRoot.LayoutManager); + } + } } } } @@ -804,6 +818,30 @@ namespace Avalonia.Layout InvalidateMeasure(); } + private void AncestorBecameVisible(ILayoutManager layoutManager) + { + if (!IsVisible) + return; + + if (!IsMeasureValid) + { + layoutManager.InvalidateMeasure(this); + InvalidateVisual(); + } + else if (!IsArrangeValid) + { + layoutManager.InvalidateArrange(this); + InvalidateVisual(); + } + + var count = VisualChildren.Count; + + for (var i = 0; i < count; ++i) + { + (VisualChildren[i] as Layoutable)?.AncestorBecameVisible(layoutManager); + } + } + /// /// Called when the layout manager raises a LayoutUpdated event. /// diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs index f2b105c901..b2c138599e 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs @@ -85,7 +85,7 @@ namespace Avalonia.Controls.Generators /// The index of the item to display. /// /// If is true for an item, then this method - /// only needs to be called a single time, otherwise this method should be called after the + /// must only be called a single time, otherwise this method must be called after the /// container is created, and each subsequent time the container is recycled to display a /// new item. /// @@ -100,10 +100,11 @@ namespace Avalonia.Controls.Generators /// The item being displayed. /// The index of the item being displayed. /// - /// This method should be called when a container has been fully prepared and added + /// This method must be called when a container has been fully prepared and added /// to the logical and visual trees, but may be called before a layout pass has completed. - /// It should be called regardless of the result of - /// . + /// It must be called regardless of the result of + /// but if that method returned true then + /// must be called only a single time. /// public void ItemContainerPrepared(Control container, object? item, int index) => _owner.ItemContainerPrepared(container, item, index); @@ -122,6 +123,12 @@ namespace Avalonia.Controls.Generators /// Undoes the effects of the method. /// /// The container control. + /// + /// This method must be called when a container is unrealized. The container must have + /// already have been removed from the virtualizing panel's list of realized containers before + /// this method is called. This method must not be called if + /// returned true for the item. + /// public void ClearItemContainer(Control container) => _owner.ClearItemContainer(container); [Obsolete("Use ItemsControl.ContainerFromIndex")] diff --git a/src/Avalonia.Controls/Utils/RealizedStackElements.cs b/src/Avalonia.Controls/Utils/RealizedStackElements.cs index 8dbfb2c957..11bbaa11c4 100644 --- a/src/Avalonia.Controls/Utils/RealizedStackElements.cs +++ b/src/Avalonia.Controls/Utils/RealizedStackElements.cs @@ -353,7 +353,10 @@ namespace Avalonia.Controls.Utils for (var i = start; i < end; ++i) { if (_elements[i] is Control element) + { + _elements[i] = null; recycleElement(element); + } } _elements.RemoveRange(start, end - start); @@ -389,10 +392,13 @@ namespace Avalonia.Controls.Utils if (_elements is null || _elements.Count == 0) return; - foreach (var e in _elements) + for (var i = 0; i < _elements.Count; i++) { - if (e is not null) + if (_elements[i] is Control e) + { + _elements[i] = null; recycleElement(e); + } } _startU = _firstIndex = 0; @@ -422,7 +428,10 @@ namespace Avalonia.Controls.Utils for (var i = 0; i < endIndex; ++i) { if (_elements[i] is Control e) + { + _elements[i] = null; recycleElement(e, i + FirstIndex); + } } _elements.RemoveRange(0, endIndex); @@ -453,7 +462,10 @@ namespace Avalonia.Controls.Utils for (var i = startIndex; i < count; ++i) { if (_elements[i] is Control e) + { + _elements[i] = null; recycleElement(e, i + FirstIndex); + } } _elements.RemoveRange(startIndex, _elements.Count - startIndex); @@ -470,13 +482,13 @@ namespace Avalonia.Controls.Utils if (_elements is null || _elements.Count == 0) return; - var i = FirstIndex; - - foreach (var e in _elements) + for (var i = 0; i < _elements.Count; i++) { - if (e is not null) - recycleElement(e, i); - ++i; + if (_elements[i] is Control e) + { + _elements[i] = null; + recycleElement(e, i + FirstIndex); + } } _startU = _firstIndex = 0; diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 67ec238ceb..e0768edfa4 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -565,7 +565,6 @@ namespace Avalonia.Controls GetItemIsOwnContainer(items, index) ?? GetRecycledElement(items, index) ?? CreateElement(items, index); - InvalidateHack(e); return e; } @@ -713,39 +712,6 @@ namespace Avalonia.Controls } } - private static void InvalidateHack(Control c) - { - bool HasInvalidations(Control c) - { - if (!c.IsMeasureValid) - return true; - - for (var i = 0; i < c.VisualChildren.Count; ++i) - { - if (c.VisualChildren[i] is Control child) - { - if (!child.IsMeasureValid || HasInvalidations(child)) - return true; - } - } - - return false; - } - - void Invalidate(Control c) - { - c.InvalidateMeasure(); - for (var i = 0; i < c.VisualChildren.Count; ++i) - { - if (c.VisualChildren[i] is Control child) - Invalidate(child); - } - } - - if (HasInvalidations(c)) - Invalidate(c); - } - private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e) { if (_unrealizedFocusedElement is null || sender != _unrealizedFocusedElement) diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests.cs index 9f13520086..09f78c5a6c 100644 --- a/tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests.cs +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests.cs @@ -59,6 +59,29 @@ namespace Avalonia.Base.UnitTests.Layout Assert.False(control.Arranged); } + [Fact] + public void Lays_Out_Descendents_That_Were_Invalidated_While_Ancestor_Was_Not_Visible() + { + // Issue #11076 + var control = new LayoutTestControl(); + var parent = new Decorator { Child = control }; + var grandparent = new Decorator { Child = parent }; + var root = new LayoutTestRoot { Child = grandparent }; + + root.LayoutManager.ExecuteInitialLayoutPass(); + + grandparent.IsVisible = false; + control.InvalidateMeasure(); + root.LayoutManager.ExecuteInitialLayoutPass(); + + grandparent.IsVisible = true; + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.True(control.IsMeasureValid); + Assert.True(control.IsArrangeValid); + } + [Fact] public void Arranges_InvalidateArranged_Control() { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 928c0c94ef..daebc1e709 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -1074,6 +1074,40 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { items[2] }, target.Selection.SelectedItems); } + [Fact] + public void Selection_Is_Not_Cleared_On_Recycling_Containers() + { + using var app = Start(); + var items = Enumerable.Range(0, 100).Select(x => new ItemViewModel($"Item {x}", false)).ToList(); + + // Create a SelectingItemsControl that creates containers that raise IsSelectedChanged, + // with a virtualizing stack panel. + var target = CreateTarget( + itemsSource: items, + virtualizing: true); + target.AutoScrollToSelectedItem = false; + + var panel = Assert.IsType(target.ItemsPanelRoot); + var scroll = panel.FindAncestorOfType()!; + + // Select item 1. + target.SelectedIndex = 1; + + // Scroll item 1 out of view. + scroll.Offset = new(0, 1000); + Layout(target); + + Assert.Equal(10, panel.FirstRealizedIndex); + Assert.Equal(19, panel.LastRealizedIndex); + + // The selection should be preserved. + Assert.Empty(SelectedContainers(target)); + Assert.Equal(1, target.SelectedIndex); + Assert.Same(items[1], target.SelectedItem); + Assert.Equal(new[] { 1 }, target.Selection.SelectedIndexes); + Assert.Equal(new[] { items[1] }, target.Selection.SelectedItems); + } + [Fact] public void Selection_State_Change_On_Unrealized_Item_Is_Respected_With_IsSelected_Binding() { @@ -1248,6 +1282,7 @@ namespace Avalonia.Controls.UnitTests.Primitives Setters = { new Setter(TestContainer.TemplateProperty, CreateTestContainerTemplate()), + new Setter(TestContainer.HeightProperty, 100.0), }, }; }