From 593b5a2ff5eeaf61ce4bdb61c7f65b04135a887d Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 13 May 2026 14:00:39 +0000 Subject: [PATCH] Ensure system back event is passed to child page once (#21246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ensure system back event is passed to child page once * fix formatting in DrawerPage * add tests * add back default page handler for system back event, but only check OnSystemBackButtonPressed * removed `OnSystemBackButtonPressed` checks in derived page classes * nav page - send back event to current page before modal pages. * Fix modal-first system back routing in NavigationPage --------- Co-authored-by: Javier Suárez Ruiz --- src/Avalonia.Controls/Page/CarouselPage.cs | 13 ++++ src/Avalonia.Controls/Page/DrawerPage.cs | 13 ++++ src/Avalonia.Controls/Page/NavigationPage.cs | 12 +++ src/Avalonia.Controls/Page/Page.cs | 17 ++--- src/Avalonia.Controls/Page/TabbedPage.cs | 32 ++++++-- .../CarouselPageTests.cs | 32 +++++++- .../DrawerPageTests.cs | 75 +++++++++++++++++++ .../NavigationPageTests.cs | 37 +++++++++ .../PageNavigationHostTests.cs | 40 ++++++++++ .../TabbedPageTests.cs | 46 ++++++++++++ 10 files changed, 299 insertions(+), 18 deletions(-) diff --git a/src/Avalonia.Controls/Page/CarouselPage.cs b/src/Avalonia.Controls/Page/CarouselPage.cs index 22bee65871..1cf67c603f 100644 --- a/src/Avalonia.Controls/Page/CarouselPage.cs +++ b/src/Avalonia.Controls/Page/CarouselPage.cs @@ -55,6 +55,19 @@ namespace Avalonia.Controls { ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); FocusableProperty.OverrideDefaultValue(true); + PageNavigationSystemBackButtonPressedEvent.AddClassHandler((sender, eventArgs) => + { + if (eventArgs.Handled) + return; + + var pageEvent = new RoutedEventArgs(PageNavigationSystemBackButtonPressedEvent); + sender.CurrentPage?.RaiseEvent(pageEvent); + + if (pageEvent.Handled) + { + eventArgs.Handled = true; + } + }); } public CarouselPage() diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs index 3494e3a069..065a5df410 100644 --- a/src/Avalonia.Controls/Page/DrawerPage.cs +++ b/src/Avalonia.Controls/Page/DrawerPage.cs @@ -295,6 +295,9 @@ namespace Avalonia.Controls { PageNavigationSystemBackButtonPressedEvent.AddClassHandler((sender, eventArgs) => { + if (eventArgs.Handled) + return; + if (sender.IsOpen && sender.DrawerBehavior != DrawerBehavior.Locked && sender.DrawerBehavior != DrawerBehavior.Disabled) @@ -302,6 +305,16 @@ namespace Avalonia.Controls sender.IsOpen = false; eventArgs.Handled = true; } + else + { + var pageEvent = new RoutedEventArgs(PageNavigationSystemBackButtonPressedEvent); + sender.CurrentPage?.RaiseEvent(pageEvent); + + if (pageEvent.Handled) + { + eventArgs.Handled = true; + } + } }); } diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs index 8297a157cf..e43fe5f06d 100644 --- a/src/Avalonia.Controls/Page/NavigationPage.cs +++ b/src/Avalonia.Controls/Page/NavigationPage.cs @@ -234,6 +234,18 @@ namespace Avalonia.Controls return; } + if (sender.CurrentPage != null) + { + var forwarded = new RoutedEventArgs(PageNavigationSystemBackButtonPressedEvent); + sender.CurrentPage.RaiseEvent(forwarded); + + if (forwarded.Handled) + eventArgs.Handled = true; + } + + if (eventArgs.Handled) + return; + if (sender.StackDepth > 1) { eventArgs.Handled = true; diff --git a/src/Avalonia.Controls/Page/Page.cs b/src/Avalonia.Controls/Page/Page.cs index 4dcb84312d..f40840caed 100644 --- a/src/Avalonia.Controls/Page/Page.cs +++ b/src/Avalonia.Controls/Page/Page.cs @@ -62,7 +62,7 @@ namespace Avalonia.Controls public static readonly RoutedEvent PageNavigationSystemBackButtonPressedEvent = RoutedEvent.Register( nameof(PageNavigationSystemBackButtonPressed), - RoutingStrategies.Bubble); + RoutingStrategies.Direct); /// /// Defines the property. @@ -75,18 +75,17 @@ namespace Avalonia.Controls static Page() { - PageNavigationSystemBackButtonPressedEvent.AddClassHandler((page, args) => + AffectsMeasure(SafeAreaPaddingProperty); + PageNavigationSystemBackButtonPressedEvent.AddClassHandler((sender, eventArgs) => { - if (!args.Handled && page.OnSystemBackButtonPressed()) - { - args.Handled = true; + if (eventArgs.Handled) return; - } - page.CurrentPage?.RaiseEvent(args); + if (sender.OnSystemBackButtonPressed()) + { + eventArgs.Handled = true; + } }); - - AffectsMeasure(SafeAreaPaddingProperty); } /// diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs index 89e926dc3c..81e30819b7 100644 --- a/src/Avalonia.Controls/Page/TabbedPage.cs +++ b/src/Avalonia.Controls/Page/TabbedPage.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Input.GestureRecognizers; +using Avalonia.Interactivity; using Avalonia.Threading; namespace Avalonia.Controls @@ -87,6 +88,19 @@ namespace Avalonia.Controls static TabbedPage() { FocusableProperty.OverrideDefaultValue(true); + PageNavigationSystemBackButtonPressedEvent.AddClassHandler((sender, eventArgs) => + { + if (eventArgs.Handled) + return; + + var forwarded = new RoutedEventArgs(PageNavigationSystemBackButtonPressedEvent); + sender.CurrentPage?.RaiseEvent(forwarded); + + if (forwarded.Handled) + { + eventArgs.Handled = true; + } + }); } /// @@ -289,13 +303,15 @@ namespace Avalonia.Controls if (target < 0) { _ignoringDisabledSelection = true; - try { _tabControl.SelectedIndex = SelectedIndex; } + try + { _tabControl.SelectedIndex = SelectedIndex; } finally { _ignoringDisabledSelection = false; } return; } _ignoringDisabledSelection = true; - try { _tabControl.SelectedIndex = target; } + try + { _tabControl.SelectedIndex = target; } finally { _ignoringDisabledSelection = false; } if (target == SelectedIndex) @@ -658,12 +674,12 @@ namespace Avalonia.Controls int delta = (e.SwipeDirection, isHorizontal, isRtl) switch { - (SwipeDirection.Left, true, false) => +1, - (SwipeDirection.Right, true, false) => -1, - (SwipeDirection.Left, true, true) => -1, - (SwipeDirection.Right, true, true) => +1, - (SwipeDirection.Up, false, _) => -1, - (SwipeDirection.Down, false, _) => +1, + (SwipeDirection.Left, true, false) => +1, + (SwipeDirection.Right, true, false) => -1, + (SwipeDirection.Left, true, true) => -1, + (SwipeDirection.Right, true, true) => +1, + (SwipeDirection.Up, false, _) => -1, + (SwipeDirection.Down, false, _) => +1, _ => 0 }; diff --git a/tests/Avalonia.Controls.UnitTests/CarouselPageTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselPageTests.cs index d4ba06d6e2..138a2e7a3c 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselPageTests.cs @@ -11,10 +11,11 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Input.GestureRecognizers; -using Avalonia.Threading; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.Media; +using Avalonia.Threading; using Avalonia.UnitTests; using Avalonia.VisualTree; using Xunit; @@ -542,6 +543,35 @@ public class CarouselPageTests } } + public class SystemBackButtonTests : ScopedTestBase + { + private static RoutedEventArgs RaiseBackButton(Page dp) + { + var args = new RoutedEventArgs(Page.PageNavigationSystemBackButtonPressedEvent); + dp.RaiseEvent(args); + return args; + } + + [Fact] + public void Back_Event_Is_Forwarded_To_Content() + { + var cp = new CarouselPage(); + var page = new ContentPage(); + bool isRaised = false; + page.PageNavigationSystemBackButtonPressed += (s, e) => + { + isRaised = true; + }; + var root = new TestRoot { Child = cp }; + ((AvaloniaList)cp.Pages!).Add(page); + + var args = RaiseBackButton(cp); + + Assert.True(isRaised); + Assert.False(args.Handled); + } + } + public class LogicalChildrenTests : ScopedTestBase { [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs index e506c0bbc9..7df674e80f 100644 --- a/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DrawerPageTests.cs @@ -1105,6 +1105,25 @@ public class DrawerPageTests return args; } + [Fact] + public void Back_Event_Is_Forwarded_To_Content() + { + var dp = new DrawerPage(); + var page = new ContentPage(); + bool isRaised = false; + page.PageNavigationSystemBackButtonPressed += (s, e) => + { + isRaised = true; + }; + var root = new TestRoot { Child = dp }; + dp.CurrentPage = page; + + var args = RaiseBackButton(dp); + + Assert.True(isRaised); + Assert.False(args.Handled); + } + [Fact] public void BackButton_ClosesOpenDrawer() { @@ -1156,6 +1175,62 @@ public class DrawerPageTests Assert.False(dp.IsOpen); Assert.False(args.Handled); } + + [Fact] + public async Task BackButton_ForwardsThroughNavigationPageToModalBeforeCoveredPage() + { + var dp = new DrawerPage(); + var nav = new NavigationPage(); + var coveredPage = new BackHandlingPage { HandleBack = true }; + var modal = new BackHandlingPage(); + await nav.PushAsync(coveredPage); + await nav.PushModalAsync(modal); + dp.Content = nav; + var root = new TestRoot { Child = dp }; + + var args = RaiseBackButton(dp); + + Assert.True(args.Handled); + Assert.Equal(0, coveredPage.BackButtonPressCount); + Assert.Equal(1, modal.BackButtonPressCount); + Assert.Empty(nav.ModalStack); + Assert.Same(coveredPage, nav.CurrentPage); + } + + [Fact] + public async Task BackButton_ClosesOpenDrawerBeforeForwardingToNestedNavigationPage() + { + var dp = new DrawerPage { IsOpen = true }; + var nav = new NavigationPage(); + var coveredPage = new BackHandlingPage { HandleBack = true }; + var modal = new BackHandlingPage(); + await nav.PushAsync(coveredPage); + await nav.PushModalAsync(modal); + dp.Content = nav; + var root = new TestRoot { Child = dp }; + + var args = RaiseBackButton(dp); + + Assert.True(args.Handled); + Assert.False(dp.IsOpen); + Assert.Equal(0, coveredPage.BackButtonPressCount); + Assert.Equal(0, modal.BackButtonPressCount); + Assert.Single(nav.ModalStack); + Assert.Same(modal, nav.ModalStack[0]); + } + + private sealed class BackHandlingPage : ContentPage + { + public int BackButtonPressCount { get; private set; } + + public bool HandleBack { get; set; } + + protected override bool OnSystemBackButtonPressed() + { + BackButtonPressCount++; + return HandleBack; + } + } } public class IconTests : ScopedTestBase diff --git a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs index 5ac4cf2dfe..d0bd88bbbd 100644 --- a/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/NavigationPageTests.cs @@ -1354,6 +1354,43 @@ public class NavigationPageTests Assert.Same(modal, nav.ModalStack[0]); } + [Fact] + public async Task BackButton_WithModal_DoesNotForwardToCoveredCurrentPage() + { + var nav = new NavigationPage(); + var root = new BackHandlingPage { HandleBack = true }; + var modal = new BackHandlingPage(); + await nav.PushAsync(root); + await nav.PushModalAsync(modal); + + var args = RaiseBackButton(nav); + + Assert.True(args.Handled); + Assert.Equal(0, root.BackButtonPressCount); + Assert.Equal(1, modal.BackButtonPressCount); + Assert.Empty(nav.ModalStack); + Assert.Equal(1, nav.StackDepth); + Assert.Same(root, nav.CurrentPage); + } + + [Fact] + public async Task BackButton_WithHandledModal_DoesNotForwardToCoveredCurrentPage() + { + var nav = new NavigationPage(); + var root = new BackHandlingPage { HandleBack = true }; + var modal = new BackHandlingPage { HandleBack = true }; + await nav.PushAsync(root); + await nav.PushModalAsync(modal); + + var args = RaiseBackButton(nav); + + Assert.True(args.Handled); + Assert.Equal(0, root.BackButtonPressCount); + Assert.Equal(1, modal.BackButtonPressCount); + Assert.Single(nav.ModalStack); + Assert.Same(modal, nav.ModalStack[0]); + } + private sealed class BackHandlingPage : ContentPage { public int BackButtonPressCount { get; private set; } diff --git a/tests/Avalonia.Controls.UnitTests/PageNavigationHostTests.cs b/tests/Avalonia.Controls.UnitTests/PageNavigationHostTests.cs index 88b8741a03..3ce606245c 100644 --- a/tests/Avalonia.Controls.UnitTests/PageNavigationHostTests.cs +++ b/tests/Avalonia.Controls.UnitTests/PageNavigationHostTests.cs @@ -1,3 +1,5 @@ +using Avalonia.Collections; +using Avalonia.Interactivity; using Avalonia.UnitTests; using Xunit; @@ -163,4 +165,42 @@ public class PageNavigationHostTests Assert.Same(second, host.Presenter!.Child); } } + + public class SystemBackButtonTests : ScopedTestBase + { + [Fact] + public void BackRequested_ForwardsToNestedCurrentPageOnce() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + var child = new ContentPage { Header = "Child" }; + var parent = new CarouselPage + { + Pages = new AvaloniaList { child } + }; + var host = new PageNavigationHost { Page = parent }; + var window = new Window + { + Width = 400, + Height = 300, + Content = host + }; + var raiseCount = 0; + child.PageNavigationSystemBackButtonPressed += (_, e) => + { + raiseCount++; + e.Handled = true; + }; + + window.Show(); + + Assert.Same(child, parent.CurrentPage); + Assert.Same(parent, host.Presenter?.Child); + + var args = new RoutedEventArgs(TopLevel.BackRequestedEvent); + window.RaiseEvent(args); + + Assert.Equal(1, raiseCount); + Assert.True(args.Handled); + } + } } diff --git a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs index a27f398a17..621b3cff7d 100644 --- a/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabbedPageTests.cs @@ -1423,6 +1423,52 @@ public class TabbedPageTests } } + public class SystemBackButtonTests : ScopedTestBase + { + private static RoutedEventArgs RaiseBackButton(Page dp) + { + var args = new RoutedEventArgs(Page.PageNavigationSystemBackButtonPressedEvent); + dp.RaiseEvent(args); + return args; + } + + [Fact] + public void Back_Event_Is_Forwarded_To_Content() + { + var tp = new TestableTabbedPage(); + var page1 = new ContentPage { Header = "Page1" }; + var page2 = new ContentPage { Header = "Page2" }; + var page3 = new ContentPage { Header = "Page3" }; + var pages = new AvaloniaList { page1, page2, page3 }; + tp.Pages = pages; + + NotifyCollectionChangedEventArgs? received = null; + tp.PagesChanged += (_, e) => received = e; + bool isRaisedOnPage1 = false, isRaisedOnPage2 = false, isRaisedOnPage3 = false; + page1.PageNavigationSystemBackButtonPressed += (s, e) => + { + isRaisedOnPage1 = true; + }; + page2.PageNavigationSystemBackButtonPressed += (s, e) => + { + isRaisedOnPage2 = true; + }; + page3.PageNavigationSystemBackButtonPressed += (s, e) => + { + isRaisedOnPage3 = true; + }; + var root = new TestRoot { Child = tp }; + tp.CallCommitSelection(1, page2); + + var args = RaiseBackButton(tp); + + Assert.False(isRaisedOnPage1); + Assert.True(isRaisedOnPage2); + Assert.False(isRaisedOnPage3); + Assert.False(args.Handled); + } + } + private sealed class TestableTabbedPage : TabbedPage { public void CallCommitSelection(int index, Page? page) => CommitSelection(index, page);