From d8a0008ef03292ee60407b974cb63727165d7c41 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 9 Mar 2026 21:00:37 +0900 Subject: [PATCH] [Navigation] Fire Page lifecycle events after transitions complete, not before (#20826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Send Page lifecycle events after page transitions * Cleanup * Moved the push lifecycle calls out of ExecutePushCore --------- Co-authored-by: Javier Suárez Ruiz --- src/Avalonia.Controls/Page/NavigationPage.cs | 86 +++++++-- .../NavigationPageTests.cs | 164 ++++++++++++++++++ 2 files changed, 231 insertions(+), 19 deletions(-) diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs index 2fb5235ff7..dd14d71a04 100644 --- a/src/Avalonia.Controls/Page/NavigationPage.cs +++ b/src/Avalonia.Controls/Page/NavigationPage.cs @@ -43,6 +43,7 @@ namespace Avalonia.Controls private ContentPresenter? _pagePresenter; private ContentPresenter? _pageBackPresenter; private CancellationTokenSource? _currentTransition; + private Task _lastPageTransitionTask = Task.CompletedTask; private CancellationTokenSource? _currentModalTransition; private Border? _navBar; private Border? _navBarShadow; @@ -769,15 +770,13 @@ namespace Avalonia.Controls page.SetInNavigationPage(true); UpdateActivePage(); - - previousPage?.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Push)); - page.SendNavigatedTo(new NavigatedToEventArgs(previousPage, NavigationType.Push)); - Pushed?.Invoke(this, new NavigationEventArgs(page, NavigationType.Push)); } /// - /// Performs the stack mutation and lifecycle events for a pop. The visual transition runs - /// subsequently via . + /// Performs the stack mutation for a pop. The visual transition runs + /// subsequently via . Callers are responsible + /// for firing lifecycle events via + /// after awaiting the page transition where possible. /// private Page? ExecutePopCore() { @@ -810,11 +809,6 @@ namespace Avalonia.Controls { old.Navigation = null; old.SetInNavigationPage(false); - - var newCurrentPage = CurrentPage; - old.SendNavigatedFrom(new NavigatedFromEventArgs(newCurrentPage, NavigationType.Pop)); - newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(old, NavigationType.Pop)); - Popped?.Invoke(this, new NavigationEventArgs(old, NavigationType.Pop)); } return old; @@ -844,6 +838,12 @@ namespace Avalonia.Controls } ExecutePushCore(page, previousPage); + + await AwaitPageTransitionAsync(); + + previousPage?.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Push)); + page.SendNavigatedTo(new NavigatedToEventArgs(previousPage, NavigationType.Push)); + Pushed?.Invoke(this, new NavigationEventArgs(page, NavigationType.Push)); } finally { @@ -886,7 +886,14 @@ namespace Avalonia.Controls return null; } - return ExecutePopCore(); + var old = ExecutePopCore(); + + await AwaitPageTransitionAsync(); + + if (old != null) + SendPopLifecycleEvents(old, NavigationType.Pop); + + return old; } finally { @@ -931,6 +938,7 @@ namespace Avalonia.Controls } bool isIncc = Pages is INotifyCollectionChanged; + var poppedPages = new List(); void TearDownPopped(Page popped) { @@ -939,8 +947,7 @@ namespace Avalonia.Controls LogicalChildren.Remove(poppedLogical); popped.Navigation = null; popped.SetInNavigationPage(false); - popped.SendNavigatedFrom(new NavigatedFromEventArgs(rootPage, NavigationType.PopToRoot)); - Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.PopToRoot)); + poppedPages.Add(popped); } if (Pages is Stack stack) @@ -962,6 +969,14 @@ namespace Avalonia.Controls _isPop = true; UpdateActivePage(); + await AwaitPageTransitionAsync(); + + foreach (var popped in poppedPages) + { + popped.SendNavigatedFrom(new NavigatedFromEventArgs(rootPage, NavigationType.PopToRoot)); + Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.PopToRoot)); + } + var newCurrentPage = CurrentPage; if (newCurrentPage != null) @@ -1013,6 +1028,7 @@ namespace Avalonia.Controls } bool isIncc = Pages is INotifyCollectionChanged; + var poppedPages = new List(); void TearDownPopped(Page popped) { @@ -1021,8 +1037,7 @@ namespace Avalonia.Controls LogicalChildren.Remove(poppedLogical); popped.Navigation = null; popped.SetInNavigationPage(false); - popped.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Pop)); - Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.Pop)); + poppedPages.Add(popped); } if (Pages is Stack stack) @@ -1044,6 +1059,14 @@ namespace Avalonia.Controls _isPop = true; UpdateActivePage(); + await AwaitPageTransitionAsync(); + + foreach (var popped in poppedPages) + { + popped.SendNavigatedFrom(new NavigatedFromEventArgs(page, NavigationType.Pop)); + Popped?.Invoke(this, new NavigationEventArgs(popped, NavigationType.Pop)); + } + var newCurrentPage = CurrentPage; if (newCurrentPage != null) { @@ -1356,7 +1379,9 @@ namespace Avalonia.Controls { if (stack.Count > 0 && ReferenceEquals(stack.Peek(), page)) { - ExecutePopCore(); + var old = ExecutePopCore(); + if (old != null) + SendPopLifecycleEvents(old, NavigationType.Pop); PageRemoved?.Invoke(this, new PageRemovedEventArgs(page)); return; } @@ -1387,7 +1412,9 @@ namespace Avalonia.Controls if (idx == list.Count - 1) { - ExecutePopCore(); + var old = ExecutePopCore(); + if (old != null) + SendPopLifecycleEvents(old, NavigationType.Pop); PageRemoved?.Invoke(this, new PageRemovedEventArgs(page)); return; } @@ -1595,12 +1622,14 @@ namespace Avalonia.Controls oldPresenter.ZIndex = 0; } - _ = RunPageTransitionAsync(resolvedTransition, oldPresenter, newPresenter, !isPop, cancel.Token); + _lastPageTransitionTask = RunPageTransitionAsync(resolvedTransition, oldPresenter, newPresenter, !isPop, cancel.Token); (_pagePresenter, _pageBackPresenter) = (newPresenter, oldPresenter); } else { + _lastPageTransitionTask = Task.CompletedTask; + _pagePresenter.Content = page; _pagePresenter.IsVisible = page != null; _pagePresenter.ZIndex = 0; @@ -1686,6 +1715,25 @@ namespace Avalonia.Controls from.Opacity = 1; } + private Task AwaitPageTransitionAsync() + { + var task = _lastPageTransitionTask; + _lastPageTransitionTask = Task.CompletedTask; + return task; + } + + /// + /// Fires lifecycle events after a pop: SendNavigatedFrom on the old page, + /// SendNavigatedTo on the new current page, and raises the Popped event. + /// + private void SendPopLifecycleEvents(Page oldPage, NavigationType navigationType) + { + var newCurrentPage = CurrentPage; + oldPage.SendNavigatedFrom(new NavigatedFromEventArgs(newCurrentPage, navigationType)); + newCurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(oldPage, navigationType)); + Popped?.Invoke(this, new NavigationEventArgs(oldPage, navigationType)); + } + /// /// Swaps the top of the navigation stack with . /// diff --git a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs index 8f3055a2fd..9602256fe8 100644 --- a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.UnitTests; @@ -1574,4 +1578,164 @@ public class NavigationPageTests } } + public class LifecycleAfterTransitionTests : ScopedTestBase + { + [Fact] + public async Task PushAsync_LifecycleEvents_FireAfterTransition() + { + var tcs = new TaskCompletionSource(); + var transition = new ControllableTransition(tcs.Task); + var nav = CreateNavigationPage(transition); + + var root = new ContentPage { Header = "Root" }; + await nav.PushAsync(root); + + bool navigatedFromDuringTransition = false; + bool navigatedToDuringTransition = false; + bool pushedDuringTransition = false; + + var second = new ContentPage { Header = "Second" }; + root.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + second.NavigatedTo += (_, _) => navigatedToDuringTransition = !tcs.Task.IsCompleted; + nav.Pushed += (_, _) => pushedDuringTransition = !tcs.Task.IsCompleted; + + var pushTask = nav.PushAsync(second); + + tcs.SetResult(); + await pushTask; + + Assert.False(navigatedFromDuringTransition); + Assert.False(navigatedToDuringTransition); + Assert.False(pushedDuringTransition); + } + + [Fact] + public async Task PopAsync_LifecycleEvents_FireAfterTransition() + { + var tcs = new TaskCompletionSource(); + var nav = CreateNavigationPage(null); + + var root = new ContentPage { Header = "Root" }; + var top = new ContentPage { Header = "Top" }; + await nav.PushAsync(root); + await nav.PushAsync(top); + + nav.PageTransition = new ControllableTransition(tcs.Task); + + bool navigatedFromDuringTransition = false; + bool navigatedToDuringTransition = false; + bool poppedDuringTransition = false; + + top.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + root.NavigatedTo += (_, _) => navigatedToDuringTransition = !tcs.Task.IsCompleted; + nav.Popped += (_, _) => poppedDuringTransition = !tcs.Task.IsCompleted; + + var popTask = nav.PopAsync(); + + tcs.SetResult(); + await popTask; + + Assert.False(navigatedFromDuringTransition); + Assert.False(navigatedToDuringTransition); + Assert.False(poppedDuringTransition); + } + + [Fact] + public async Task PopToRootAsync_LifecycleEvents_FireAfterTransition() + { + var tcs = new TaskCompletionSource(); + var nav = CreateNavigationPage(null); + + var root = new ContentPage { Header = "Root" }; + var second = new ContentPage { Header = "Second" }; + var third = new ContentPage { Header = "Third" }; + await nav.PushAsync(root); + await nav.PushAsync(second); + await nav.PushAsync(third); + + nav.PageTransition = new ControllableTransition(tcs.Task); + + bool navigatedFromDuringTransition = false; + bool navigatedToDuringTransition = false; + bool poppedToRootDuringTransition = false; + + second.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + third.NavigatedFrom += (_, _) => navigatedFromDuringTransition = !tcs.Task.IsCompleted; + root.NavigatedTo += (_, _) => navigatedToDuringTransition = !tcs.Task.IsCompleted; + nav.PoppedToRoot += (_, _) => poppedToRootDuringTransition = !tcs.Task.IsCompleted; + + var popTask = nav.PopToRootAsync(); + + tcs.SetResult(); + await popTask; + + Assert.False(navigatedFromDuringTransition); + Assert.False(navigatedToDuringTransition); + Assert.False(poppedToRootDuringTransition); + } + + private static NavigationPage CreateNavigationPage(IPageTransition? transition) + { + var nav = new NavigationPage + { + PageTransition = transition, + Template = NavigationPageTemplate() + }; + var root = new TestRoot { Child = nav }; + root.LayoutManager.ExecuteInitialLayoutPass(); + return nav; + } + + private static IControlTemplate NavigationPageTemplate() + { + return new FuncControlTemplate((parent, ns) => + { + var contentHost = new Panel + { + Name = "PART_ContentHost", + Children = + { + new ContentPresenter { Name = "PART_PageBackPresenter" }.RegisterInNameScope(ns), + new ContentPresenter { Name = "PART_PagePresenter" }.RegisterInNameScope(ns), + } + }.RegisterInNameScope(ns); + + return new Panel + { + Children = + { + new Border + { + Name = "PART_NavigationBar", + Child = new Button { Name = "PART_BackButton" }.RegisterInNameScope(ns) + }.RegisterInNameScope(ns), + contentHost, + new ContentPresenter { Name = "PART_TopCommandBar" }.RegisterInNameScope(ns), + new ContentPresenter { Name = "PART_ModalBackPresenter" }.RegisterInNameScope(ns), + new ContentPresenter { Name = "PART_ModalPresenter" }.RegisterInNameScope(ns), + } + }; + }); + } + + private class ControllableTransition : IPageTransition + { + private readonly Task _gate; + + public ControllableTransition(Task gate) + { + _gate = gate; + } + + public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + if (to != null) + to.IsVisible = true; + await _gate; + if (from != null) + from.IsVisible = false; + } + } + } + }