Browse Source
* 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
committed by
GitHub
12 changed files with 695 additions and 80 deletions
@ -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(); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue