From 4142052f9ee44360d9bee6b24b5dcce2db083f84 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 28 Aug 2026 12:19:10 +0000 Subject: [PATCH] Add BringIntoView pass to the LayoutManager (#22002) * Add BringIntoView pass to the LayoutManager * Make ProcessBringIntoViewRequests part of the standard layout pass * Clarify why TryScrollIntoViewNow's result is ignored --------- Co-authored-by: grokys --- .../Layout/BringIntoViewRequest.cs | 22 ++ .../Layout/IBringIntoViewLayoutManager.cs | 18 ++ src/Avalonia.Base/Layout/LayoutManager.cs | 113 +++++++- src/Avalonia.Controls/ControlExtensions.cs | 53 +++- .../Presenters/ItemsPresenter.cs | 86 +++++- .../Primitives/SelectingItemsControl.cs | 61 +--- src/Avalonia.Controls/TreeView.cs | 8 +- src/Avalonia.Controls/TreeViewItem.cs | 3 +- .../LayoutManagerTests_BringIntoView.cs | 265 ++++++++++++++++++ .../BringIntoViewTests.cs | 86 ++++++ .../Primitives/SelectingItemsControlTests.cs | 27 ++ .../SelectingItemsControlTests_AutoSelect.cs | 33 ++- 12 files changed, 695 insertions(+), 80 deletions(-) create mode 100644 src/Avalonia.Base/Layout/BringIntoViewRequest.cs create mode 100644 src/Avalonia.Base/Layout/IBringIntoViewLayoutManager.cs create mode 100644 tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests_BringIntoView.cs create mode 100644 tests/Avalonia.Controls.UnitTests/BringIntoViewTests.cs diff --git a/src/Avalonia.Base/Layout/BringIntoViewRequest.cs b/src/Avalonia.Base/Layout/BringIntoViewRequest.cs new file mode 100644 index 0000000000..39913d41d8 --- /dev/null +++ b/src/Avalonia.Base/Layout/BringIntoViewRequest.cs @@ -0,0 +1,22 @@ +namespace Avalonia.Layout +{ + /// + /// A bring-into-view request, processed by the at the end of a layout pass. + /// + internal abstract class BringIntoViewRequest(Layoutable target) + { + /// + /// Gets the control this request is associated with. + /// + public Layoutable Target { get; } = target; + + /// + /// Attempts to execute the request. + /// + /// + /// true if the request has been executed (or abandoned) and should be removed from the queue; + /// false if the target isn't ready yet and the request should be retained for a following layout pass. + /// + public abstract bool TryExecute(); + } +} diff --git a/src/Avalonia.Base/Layout/IBringIntoViewLayoutManager.cs b/src/Avalonia.Base/Layout/IBringIntoViewLayoutManager.cs new file mode 100644 index 0000000000..4da21615d2 --- /dev/null +++ b/src/Avalonia.Base/Layout/IBringIntoViewLayoutManager.cs @@ -0,0 +1,18 @@ +namespace Avalonia.Layout; + +/// +/// A that handles . +/// +internal interface IBringIntoViewLayoutManager : ILayoutManager +{ + /// + /// Gets whether a layout pass is currently running. + /// + bool IsInLayoutPass { get; } + + /// + /// Enqueues a bring-into-view request to be processed at the end of the current or next layout pass, + /// replacing any previously enqueued request for the same target. + /// + void EnqueueBringIntoView(BringIntoViewRequest request); +} diff --git a/src/Avalonia.Base/Layout/LayoutManager.cs b/src/Avalonia.Base/Layout/LayoutManager.cs index fa82ff1d31..bf6a3f4ff5 100644 --- a/src/Avalonia.Base/Layout/LayoutManager.cs +++ b/src/Avalonia.Base/Layout/LayoutManager.cs @@ -5,20 +5,17 @@ using System.Diagnostics; using Avalonia.Diagnostics; using Avalonia.Logging; using Avalonia.Media; -using Avalonia.Metadata; using Avalonia.Rendering; using Avalonia.Threading; using Avalonia.Utilities; using Avalonia.VisualTree; -#nullable enable - namespace Avalonia.Layout { /// /// Manages measuring and arranging of controls. /// - internal class LayoutManager : ILayoutManager, IDisposable + internal class LayoutManager : IBringIntoViewLayoutManager { private const int MaxPasses = 10; private readonly ILayoutRoot _owner; @@ -26,9 +23,11 @@ namespace Avalonia.Layout private readonly LayoutQueue _toArrange = new LayoutQueue(v => !v.IsArrangeValid); private readonly List _toArrangeAfterMeasure = new(); private List? _effectiveViewportChangedListeners; + private List? _bringIntoViewRequests; private bool _disposed; private bool _queued; private bool _running; + private bool _processingBringIntoViewRequests; private int _totalPassCount; private readonly Action _invokeOnRender; @@ -40,6 +39,8 @@ namespace Avalonia.Layout public virtual event EventHandler? LayoutUpdated; + public bool IsInLayoutPass => _running; + internal Action? LayoutPassTimed { get; set; } /// @@ -151,7 +152,14 @@ namespace Avalonia.Layout { InnerLayoutPass(); - if (!RaiseEffectiveViewportChanged()) + if (RaiseEffectiveViewportChanged()) + { + continue; + } + + // The layout is now stable: bring-into-view requests can execute against final bounds. + // Executing them typically changes scroll offsets, which invalidates layout again. + if (!ProcessBringIntoViewRequests() || (_toMeasure.Count == 0 && _toArrange.Count == 0)) { break; } @@ -173,11 +181,105 @@ namespace Avalonia.Layout Logger.TryGet(timingLogLevel, LogArea.Layout)?.Log(this, "Layout pass finished in {Time}", elapsed); } } + else if (_processingBringIntoViewRequests) + { + // A layout pass forced while executing a bring-into-view request is part of the enclosing pass: + // run inner passes inline, and let the enclosing pass raise LayoutUpdated once all requests have been processed. + for (var pass = 0; pass < MaxPasses; ++pass) + { + InnerLayoutPass(); + + if (!RaiseEffectiveViewportChanged()) + { + break; + } + } + + return; + } _queued = false; + LayoutUpdated?.Invoke(this, EventArgs.Empty); } + /// + public void EnqueueBringIntoView(BringIntoViewRequest request) + { + Dispatcher.UIThread.VerifyAccess(); + + if (_disposed) + return; + + var requests = _bringIntoViewRequests ??= new(); + var replaced = false; + + for (var i = 0; i < requests.Count; ++i) + { + if (requests[i].Target == request.Target) + { + requests[i] = request; + replaced = true; + break; + } + } + + if (!replaced) + requests.Add(request); + + // The request will usually be consumed by the already pending layout pass that will lay out its target, + // but make sure a pass is scheduled in case there is none. + QueueLayoutPass(); + } + + /// + /// Attempts to execute each pending bring-into-view request once. + /// + /// + /// true if at least one request was executed; + /// false if there was nothing to do or no request could make progress. + /// + private bool ProcessBringIntoViewRequests() + { + if (_processingBringIntoViewRequests || _bringIntoViewRequests is not { Count: > 0 } requests) + return false; + + _processingBringIntoViewRequests = true; + + try + { + var executedAny = false; + var i = 0; + + while (i < requests.Count) + { + var request = requests[i]; + + // The target has been detached, abort. + if (request.Target.GetLayoutRoot() != _owner) + { + requests.RemoveAt(i); + continue; + } + + var executed = request.TryExecute(); + executedAny |= executed; + + // Executing the request may have replaced it with a new one for the same target. + if (executed && i < requests.Count && requests[i] == request) + requests.RemoveAt(i); + else + ++i; + } + + return executedAny; + } + finally + { + _processingBringIntoViewRequests = false; + } + } + /// public virtual void ExecuteInitialLayoutPass() { @@ -212,6 +314,7 @@ namespace Avalonia.Layout _disposed = true; _toMeasure.Dispose(); _toArrange.Dispose(); + _bringIntoViewRequests = null; } void ILayoutManager.RegisterEffectiveViewportListener(Layoutable control) diff --git a/src/Avalonia.Controls/ControlExtensions.cs b/src/Avalonia.Controls/ControlExtensions.cs index 889a4cc79f..ab406ba2da 100644 --- a/src/Avalonia.Controls/ControlExtensions.cs +++ b/src/Avalonia.Controls/ControlExtensions.cs @@ -1,5 +1,7 @@ using System; +using Avalonia.Layout; using Avalonia.Reactive; +using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -14,9 +16,9 @@ namespace Avalonia.Controls /// The control. public static void BringIntoView(this Control control) { - _ = control ?? throw new ArgumentNullException(nameof(control)); + ArgumentNullException.ThrowIfNull(control); - control.BringIntoView(new Rect(control.Bounds.Size)); + BringIntoViewCore(control, null); } /// @@ -26,19 +28,37 @@ namespace Avalonia.Controls /// The area of the control to being into view. public static void BringIntoView(this Control control, Rect rect) { - _ = control ?? throw new ArgumentNullException(nameof(control)); + ArgumentNullException.ThrowIfNull(control); + + BringIntoViewCore(control, rect); + } + + private static void BringIntoViewCore(Control control, Rect? rect) + { + if (TryExecuteBringIntoView(control, rect)) + return; + + var layoutManager = control.GetLayoutRoot()?.LayoutManager as IBringIntoViewLayoutManager; + layoutManager?.EnqueueBringIntoView(new ControlBringIntoViewRequest(control, rect)); + } - if (control.IsEffectivelyVisible) + private static bool TryExecuteBringIntoView(Control control, Rect? rect) + { + if (!control.IsEffectivelyVisible) + return true; // BringIntoView on an invisible control just does nothing. + + if (!control.IsMeasureValid || !control.IsArrangeValid) + return false; + + var ev = new RequestBringIntoViewEventArgs { - var ev = new RequestBringIntoViewEventArgs - { - RoutedEvent = Control.RequestBringIntoViewEvent, - TargetObject = control, - TargetRect = rect, - }; - - control.RaiseEvent(ev); - } + RoutedEvent = Control.RequestBringIntoViewEvent, + TargetObject = control, + TargetRect = rect ?? new Rect(control.Bounds.Size), + }; + + control.RaiseEvent(ev); + return true; } /// @@ -99,5 +119,12 @@ namespace Avalonia.Controls return trigger.Subscribe(x => classes.Set(name, x)); } + + private sealed class ControlBringIntoViewRequest(Control target, Rect? rect) + : BringIntoViewRequest(target) + { + public override bool TryExecute() + => TryExecuteBringIntoView(target, rect); + } } } diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index 2d775e2be0..52bf160a10 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Diagnostics; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.VisualTree; namespace Avalonia.Controls.Presenters { @@ -20,6 +22,8 @@ namespace Avalonia.Controls.Presenters private PanelContainerGenerator? _generator; private ILogicalScrollable? _logicalScrollable; private EventHandler? _scrollInvalidated; + private ScrollIntoViewRequest? _scrollIntoViewRequest; + private int _pendingScrollIntoViewIndex = -1; event EventHandler? ILogicalScrollable.ScrollInvalidated { @@ -126,10 +130,84 @@ namespace Avalonia.Controls.Presenters internal void ScrollIntoView(int index) { - if (Panel is VirtualizingPanel v) - v.ScrollIntoView(index); - else if (index >= 0 && index < Panel?.Children.Count) - Panel.Children[index].BringIntoView(); + if (index < 0) + return; + + // Not attached to a layout root yet: the request will be enqueued in OnAttachedToVisualTree. + if (this.GetLayoutRoot()?.LayoutManager is not IBringIntoViewLayoutManager layoutManager) + { + _pendingScrollIntoViewIndex = index; + return; + } + + // Try to execute synchronously when no layout pass is running. + if (!layoutManager.IsInLayoutPass && TryScrollIntoViewNow(index)) + { + _pendingScrollIntoViewIndex = -1; + return; + } + + // Defer to the end of the layout pass. + _pendingScrollIntoViewIndex = index; + layoutManager.EnqueueBringIntoView(_scrollIntoViewRequest ??= new(this)); + } + + internal void CancelScrollIntoView(int index) + { + if (_pendingScrollIntoViewIndex == index) + _pendingScrollIntoViewIndex = -1; + } + + private bool TryScrollIntoViewNow(int index) + { + if (!IsEffectivelyVisible || Panel is not { } panel) + return false; + + if (panel is VirtualizingPanel virtualizingPanel) + return virtualizingPanel.ScrollIntoView(index) is not null; + + if (index < panel.Children.Count) + { + panel.Children[index].BringIntoView(); + return true; + } + + return false; + } + + private bool TryExecutePendingScrollIntoView() + { + var index = _pendingScrollIntoViewIndex; + if (index < 0) + return true; + + if (Panel is not { IsMeasureValid: true, IsArrangeValid: true } || !IsEffectivelyVisible) + return false; + + _pendingScrollIntoViewIndex = -1; + + // Try to scroll, but ignore the return value. We don't want to return false if this fails: + // this would requeue a request that might never been be fulfilled because items changed. + _ = TryScrollIntoViewNow(index); + + // Executing the scroll may have prepared containers whose handlers requested a new + // scroll: in that case keep the request queued so it gets executed as well. + return _pendingScrollIntoViewIndex < 0; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (_pendingScrollIntoViewIndex >= 0 && this.GetLayoutRoot()?.LayoutManager is IBringIntoViewLayoutManager layoutManager) + layoutManager.EnqueueBringIntoView(_scrollIntoViewRequest ??= new(this)); + } + + private sealed class ScrollIntoViewRequest(ItemsPresenter presenter) + : BringIntoViewRequest(presenter) + { + public override bool TryExecute() + => presenter.TryExecutePendingScrollIntoView(); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 8272d1e8b2..715b60fa50 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -491,14 +491,7 @@ namespace Avalonia.Controls.Primitives { base.OnApplyTemplate(e); - if (AutoScrollToSelectedItem) - { - Dispatcher.UIThread.Post(static state => - { - var control = (SelectingItemsControl)state!; - control.AutoScrollToSelectedItemIfNecessary(control.GetAnchorIndex()); - }, this); - } + AutoScrollToSelectedItemIfNecessary(GetAnchorIndex()); } internal int GetAnchorIndex() @@ -1201,53 +1194,25 @@ namespace Avalonia.Controls.Primitives } } - private int? _pendingAutoScrollAnchorIndex; + private int _autoScrollRequestedIndex = -1; private void AutoScrollToSelectedItemIfNecessary(int anchorIndex) { - if (!(AutoScrollToSelectedItem && !_hasScrolledToSelectedItem && Presenter != null && anchorIndex >= 0 && IsAttachedToVisualTree)) - { - ClearPendingAutoScroll(); - return; - } - - if (!IsEffectivelyVisible) + if (AutoScrollToSelectedItem && Presenter is not null && anchorIndex >= 0 && IsAttachedToVisualTree) { - // Defer scroll until the control becomes effectively visible. - _pendingAutoScrollAnchorIndex = anchorIndex; - IsEffectivelyVisibleChanged -= OnIsEffectivelyVisibleChangedForAutoScroll; - IsEffectivelyVisibleChanged += OnIsEffectivelyVisibleChangedForAutoScroll; - return; + if (!_hasScrolledToSelectedItem) + { + ScrollIntoView(anchorIndex); + _autoScrollRequestedIndex = anchorIndex; + _hasScrolledToSelectedItem = true; + } } - - ClearPendingAutoScroll(); - ScrollToAnchorIndex(anchorIndex); - } - - private void OnIsEffectivelyVisibleChangedForAutoScroll(object? sender, EventArgs e) - { - if (!IsEffectivelyVisible || _pendingAutoScrollAnchorIndex is not { } anchorIndex) + else if (_autoScrollRequestedIndex >= 0) { - return; + // The conditions for auto-scrolling no longer hold, cancel the scroll we requested earlier. + Presenter?.CancelScrollIntoView(_autoScrollRequestedIndex); + _autoScrollRequestedIndex = -1; } - - ClearPendingAutoScroll(); - ScrollToAnchorIndex(anchorIndex); - } - - private void ClearPendingAutoScroll() - { - _pendingAutoScrollAnchorIndex = null; - IsEffectivelyVisibleChanged -= OnIsEffectivelyVisibleChangedForAutoScroll; - } - - private void ScrollToAnchorIndex(int anchorIndex) - { - Dispatcher.UIThread.Post(state => - { - ScrollIntoView((int)state!); - _hasScrolledToSelectedItem = true; - }, anchorIndex); } /// diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index ad7a97be56..5776294197 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -3,19 +3,13 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; -using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using Avalonia.Automation.Peers; using Avalonia.Collections; -using Avalonia.Controls.Generators; -using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives; using Avalonia.Input; -using Avalonia.Input.Platform; using Avalonia.Interactivity; -using Avalonia.Layout; -using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls @@ -546,7 +540,7 @@ namespace Avalonia.Controls // If the newly realized container is the selected container, scroll to it after layout. if (AutoScrollToSelectedItem && SelectedItem == item) { - Dispatcher.UIThread.Post(container.BringIntoView, DispatcherPriority.Loaded); + container.BringIntoView(); } } diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index d51e72dc3b..f67332db70 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -12,7 +12,6 @@ using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; -using Avalonia.Threading; namespace Avalonia.Controls { @@ -371,7 +370,7 @@ namespace Avalonia.Controls if (_deferredBringIntoViewFlag) { _deferredBringIntoViewFlag = false; - Dispatcher.UIThread.Post(this.BringIntoView); // must use the Dispatcher, otherwise the TreeView doesn't scroll + this.BringIntoView(); } } diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests_BringIntoView.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests_BringIntoView.cs new file mode 100644 index 0000000000..b48a862eb6 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests_BringIntoView.cs @@ -0,0 +1,265 @@ +using System; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Layout; + +public class LayoutManagerTests_BringIntoView : ScopedTestBase +{ + [Fact] + public void Request_Is_Executed_At_End_Of_Layout_Pass() + { + var control = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = control }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var request = new TestRequest(control); + GetLayoutManager(root).EnqueueBringIntoView(request); + + Assert.Equal(0, request.ExecuteAttempts); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, request.ExecuteAttempts); + Assert.Equal(1, request.Executions); + + root.LayoutManager.ExecuteLayoutPass(); + + // Should not have been executed twice. + Assert.Equal(1, request.ExecuteAttempts); + Assert.Equal(1, request.Executions); + } + + [Fact] + public void Request_Is_Executed_Before_LayoutUpdated_Is_Raised() + { + var control = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = control }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var layoutUpdatedRaised = false; + var executedBeforeLayoutUpdated = false; + root.LayoutManager.LayoutUpdated += (_, _) => layoutUpdatedRaised = true; + + var request = new TestRequest(control) + { + OnExecute = () => executedBeforeLayoutUpdated = !layoutUpdatedRaised, + }; + + GetLayoutManager(root).EnqueueBringIntoView(request); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, request.Executions); + Assert.True(executedBeforeLayoutUpdated); + } + + [Fact] + public void Request_Is_Retained_Until_It_Can_Execute() + { + var control = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = control }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var canExecute = false; + var request = new TestRequest(control) { CanExecute = () => canExecute }; + GetLayoutManager(root).EnqueueBringIntoView(request); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, request.ExecuteAttempts); + Assert.Equal(0, request.Executions); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(2, request.ExecuteAttempts); + Assert.Equal(0, request.Executions); + + canExecute = true; + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(3, request.ExecuteAttempts); + Assert.Equal(1, request.Executions); + } + + [Fact] + public void Requests_Are_Coalesced_By_Target() + { + var control = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = control }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var first = new TestRequest(control); + var second = new TestRequest(control); + var layoutManager = GetLayoutManager(root); + + layoutManager.EnqueueBringIntoView(first); + layoutManager.EnqueueBringIntoView(second); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(0, first.ExecuteAttempts); + Assert.Equal(1, second.Executions); + } + + [Fact] + public void Request_Is_Dropped_When_Target_Is_Detached() + { + var control = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = control }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var request = new TestRequest(control); + GetLayoutManager(root).EnqueueBringIntoView(request); + + root.Child = null; + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(0, request.ExecuteAttempts); + + root.Child = control; + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(0, request.ExecuteAttempts); + } + + [Fact] + public void Layout_Invalidated_By_Request_Converges_Within_Same_Pass() + { + var control = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = control }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + control.Measured = control.Arranged = false; + + var request = new TestRequest(control) { OnExecute = control.InvalidateMeasure }; + GetLayoutManager(root).EnqueueBringIntoView(request); + + root.LayoutManager.ExecuteLayoutPass(); + + // The layout invalidated by the request (e.g. a scroll offset change) has been + // re-run before ExecuteLayoutPass returned, so the frame is rendered fully scrolled. + Assert.Equal(1, request.Executions); + Assert.True(control.Measured); + Assert.True(control.Arranged); + } + + [Fact] + public void LayoutUpdated_Is_Not_Raised_Until_All_Requests_Have_Been_Processed() + { + var first = new LayoutTestControl(); + var second = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = new StackPanel { Children = { first, second } } }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var layoutManager = GetLayoutManager(root); + var layoutUpdatedRaised = false; + var secondExecutedBeforeLayoutUpdated = false; + root.LayoutManager.LayoutUpdated += (_, _) => layoutUpdatedRaised = true; + + // The second request can only execute once the layout invalidated by the first one has + // been re-run, so it is executed by a following pass of the processing loop. + first.Measured = false; + + var secondRequest = new TestRequest(second) + { + CanExecute = () => first.Measured, + OnExecute = () => secondExecutedBeforeLayoutUpdated = !layoutUpdatedRaised, + }; + + var firstRequest = new TestRequest(first) { OnExecute = first.InvalidateMeasure }; + + layoutManager.EnqueueBringIntoView(firstRequest); + layoutManager.EnqueueBringIntoView(secondRequest); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, secondRequest.Executions); + Assert.True(secondExecutedBeforeLayoutUpdated); + } + + [Fact] + public void LayoutUpdated_Is_Raised_Once_When_Request_Invalidates_Layout() + { + var control = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = control }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var layoutUpdated = 0; + root.LayoutManager.LayoutUpdated += (_, _) => ++layoutUpdated; + + var request = new TestRequest(control) { OnExecute = control.InvalidateMeasure }; + GetLayoutManager(root).EnqueueBringIntoView(request); + + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, request.Executions); + Assert.Equal(1, layoutUpdated); + } + + [Fact] + public void Request_Enqueued_While_Processing_Is_Attempted_By_The_Same_Pass() + { + var first = new LayoutTestControl(); + var second = new LayoutTestControl(); + var root = new LayoutTestRoot { Child = new StackPanel { Children = { first, second } } }; + root.LayoutManager.ExecuteInitialLayoutPass(); + + var layoutManager = GetLayoutManager(root); + var canExecuteSecond = false; + var secondRequest = new TestRequest(second) { CanExecute = () => canExecuteSecond }; + + // Executing a request can enqueue another one (as ControlExtensions.BringIntoViewCore does): + // the new request must be attempted by the same pass, and retained if it can't execute yet. + var firstRequest = new TestRequest(first) + { + OnExecute = () => + { + layoutManager.EnqueueBringIntoView(secondRequest); + first.InvalidateMeasure(); + } + }; + + layoutManager.EnqueueBringIntoView(firstRequest); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, firstRequest.Executions); + + // Attempted first during the same pass as the first request, then retried once after a new layout pass. + Assert.Equal(2, secondRequest.ExecuteAttempts); + Assert.Equal(0, secondRequest.Executions); + + // The second request couldn't execute despite having been through an extra layout pass. + // We can't retry forever (nothing has changed). The request will be retried again on the next "natural" pass. + canExecuteSecond = true; + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(3, secondRequest.ExecuteAttempts); + Assert.Equal(1, secondRequest.Executions); + } + + private static LayoutManager GetLayoutManager(TestRoot root) + => Assert.IsType(root.LayoutManager, exactMatch: false); + + private sealed class TestRequest(Layoutable target) : BringIntoViewRequest(target) + { + public int ExecuteAttempts { get; private set; } + public int Executions { get; private set; } + public Func? CanExecute { get; init; } + public Action? OnExecute { get; init; } + + public override bool TryExecute() + { + ++ExecuteAttempts; + + var canExecute = CanExecute?.Invoke() ?? true; + if (!canExecute) + return false; + + ++Executions; + OnExecute?.Invoke(); + return true; + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/BringIntoViewTests.cs b/tests/Avalonia.Controls.UnitTests/BringIntoViewTests.cs new file mode 100644 index 0000000000..8dd2f58eb5 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/BringIntoViewTests.cs @@ -0,0 +1,86 @@ +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests; + +public class BringIntoViewTests : ScopedTestBase +{ + [Fact] + public void BringIntoView_On_Laid_Out_Control_Raises_RequestBringIntoView_Synchronously() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var child = new Border { Width = 50, Height = 50 }; + var root = new TestRoot(child); + root.LayoutManager.ExecuteInitialLayoutPass(); + + var raised = false; + root.AddHandler(Control.RequestBringIntoViewEvent, (_, _) => raised = true); + + child.BringIntoView(); + + Assert.True(raised); + } + + [Fact] + public void BringIntoView_Before_Layout_Is_Deferred_Until_End_Of_Layout_Pass() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var child = new Border { Width = 50, Height = 50 }; + var root = new TestRoot(child); + + var raised = 0; + var targetRect = default(Rect); + root.AddHandler(Control.RequestBringIntoViewEvent, (_, e) => + { + ++raised; + targetRect = e.TargetRect; + }); + + child.BringIntoView(); + + Assert.Equal(0, raised); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.Equal(1, raised); + Assert.Equal(new Rect(0, 0, 50, 50), targetRect); + } + + [Fact] + public void Deferred_BringIntoView_Is_Abandoned_When_Control_Becomes_Invisible() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var child = new Border { Width = 50, Height = 50 }; + var root = new TestRoot(child); + + var raised = false; + root.AddHandler(Control.RequestBringIntoViewEvent, (_, _) => raised = true); + + child.BringIntoView(); + child.IsVisible = false; + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.False(raised); + } + + [Fact] + public void BringIntoView_On_Invisible_Control_Is_Ignored() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var child = new Border { Width = 50, Height = 50, IsVisible = false }; + var root = new TestRoot(child); + root.LayoutManager.ExecuteInitialLayoutPass(); + + var raised = false; + root.AddHandler(Control.RequestBringIntoViewEvent, (_, _) => raised = true); + + child.BringIntoView(); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.False(raised); + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 6c982417f1..212bd035c9 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -1914,6 +1914,33 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.True(raised); } + [Fact] + public void AutoScrollToSelectedItem_Scrolls_Synchronously_When_Laid_Out() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var items = new ObservableCollection + { + "Foo", + "Bar", + "Baz" + }; + + var target = new ListBox + { + Template = Template(), + ItemsSource = items, + }; + + var raised = false; + + Prepare(target); + target.AddHandler(Control.RequestBringIntoViewEvent, (_, _) => raised = true); + target.SelectedIndex = 2; + + Assert.True(raised); + } + [Fact] public void Can_Set_Both_SelectedItem_And_SelectedItems_During_Initialization() { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs index c7863a92fa..0d722c27ec 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs @@ -349,7 +349,38 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.InRange(offset, 2400, 2500); } } - + + [Fact] + public void AutoScrollToSelectedItem_Executes_Within_Layout_Pass_When_Becoming_Visible() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var items = Enumerable.Range(0, 100).Select(i => $"Item {i}").ToList(); + + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + ItemsSource = items, + ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), + Height = 100, + ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel { CacheLength = 0 }), + AutoScrollToSelectedItem = true, + IsVisible = false + }; + + target.Width = target.Height = 100; + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + target.SelectedIndex = 50; + target.IsVisible = true; + + target.UpdateLayout(); + + var scrollViewer = (ScrollViewer)target.VisualChildren[0]; + Assert.InRange(scrollViewer.Offset.Y, 2400, 2500); + } + private static FuncControlTemplate Template() { return new FuncControlTemplate((control, scope) =>