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) =>