Browse Source

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 <grokys@users.noreply.github.com>
pull/22095/head
Julien Lebosquain 1 month ago
committed by GitHub
parent
commit
4142052f9e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 22
      src/Avalonia.Base/Layout/BringIntoViewRequest.cs
  2. 18
      src/Avalonia.Base/Layout/IBringIntoViewLayoutManager.cs
  3. 113
      src/Avalonia.Base/Layout/LayoutManager.cs
  4. 53
      src/Avalonia.Controls/ControlExtensions.cs
  5. 86
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  6. 61
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  7. 8
      src/Avalonia.Controls/TreeView.cs
  8. 3
      src/Avalonia.Controls/TreeViewItem.cs
  9. 265
      tests/Avalonia.Base.UnitTests/Layout/LayoutManagerTests_BringIntoView.cs
  10. 86
      tests/Avalonia.Controls.UnitTests/BringIntoViewTests.cs
  11. 27
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  12. 33
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs

22
src/Avalonia.Base/Layout/BringIntoViewRequest.cs

@ -0,0 +1,22 @@
namespace Avalonia.Layout
{
/// <summary>
/// A bring-into-view request, processed by the <see cref="IBringIntoViewLayoutManager"/> at the end of a layout pass.
/// </summary>
internal abstract class BringIntoViewRequest(Layoutable target)
{
/// <summary>
/// Gets the control this request is associated with.
/// </summary>
public Layoutable Target { get; } = target;
/// <summary>
/// Attempts to execute the request.
/// </summary>
/// <returns>
/// 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.
/// </returns>
public abstract bool TryExecute();
}
}

18
src/Avalonia.Base/Layout/IBringIntoViewLayoutManager.cs

@ -0,0 +1,18 @@
namespace Avalonia.Layout;
/// <summary>
/// A <see cref="ILayoutManager"/> that handles <see cref="BringIntoViewRequest"/>.
/// </summary>
internal interface IBringIntoViewLayoutManager : ILayoutManager
{
/// <summary>
/// Gets whether a layout pass is currently running.
/// </summary>
bool IsInLayoutPass { get; }
/// <summary>
/// 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.
/// </summary>
void EnqueueBringIntoView(BringIntoViewRequest request);
}

113
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
{
/// <summary>
/// Manages measuring and arranging of controls.
/// </summary>
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<Layoutable> _toArrange = new LayoutQueue<Layoutable>(v => !v.IsArrangeValid);
private readonly List<Layoutable> _toArrangeAfterMeasure = new();
private List<EffectiveViewportChangedListener>? _effectiveViewportChangedListeners;
private List<BringIntoViewRequest>? _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<LayoutPassTiming>? LayoutPassTimed { get; set; }
/// <inheritdoc/>
@ -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);
}
/// <inheritdoc />
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();
}
/// <summary>
/// Attempts to execute each pending bring-into-view request once.
/// </summary>
/// <returns>
/// true if at least one request was executed;
/// false if there was nothing to do or no request could make progress.
/// </returns>
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;
}
}
/// <inheritdoc/>
public virtual void ExecuteInitialLayoutPass()
{
@ -212,6 +314,7 @@ namespace Avalonia.Layout
_disposed = true;
_toMeasure.Dispose();
_toArrange.Dispose();
_bringIntoViewRequests = null;
}
void ILayoutManager.RegisterEffectiveViewportListener(Layoutable control)

53
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
/// <param name="control">The control.</param>
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);
}
/// <summary>
@ -26,19 +28,37 @@ namespace Avalonia.Controls
/// <param name="rect">The area of the control to being into view.</param>
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;
}
/// <summary>
@ -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);
}
}
}

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

61
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);
}
/// <summary>

8
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();
}
}

3
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();
}
}

265
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<LayoutManager>(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<bool>? 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;
}
}
}

86
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);
}
}

27
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<string>
{
"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()
{

33
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<string>((_, _) => new TextBlock { Height = 50 }),
Height = 100,
ItemsPanel = new FuncTemplate<Panel?>(() => 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<SelectingItemsControl>((control, scope) =>

Loading…
Cancel
Save