diff --git a/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs b/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs index c2f8f9de38bb..93e75ed75fe2 100644 --- a/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs +++ b/src/Controls/src/Core/FlyoutPage/FlyoutPage.cs @@ -289,7 +289,13 @@ internal static void UpdateFlyoutLayoutBehavior(FlyoutPage page) } static void OnIsPresentedPropertyChanged(BindableObject sender, object oldValue, object newValue) - => ((FlyoutPage)sender).IsPresentedChanged?.Invoke(sender, EventArgs.Empty); + { + var flyoutPage = (FlyoutPage)sender; + flyoutPage.IsPresentedChanged?.Invoke(sender, EventArgs.Empty); + // Refresh the predictive back callback when the flyout opens or closes so the + // back-to-home animation is suppressed only while the flyout is actually open. + (flyoutPage.Window as Window)?.NotifyNavigationStateChanged(); + } static void OnIsPresentedPropertyChanging(BindableObject sender, object oldValue, object newValue) { diff --git a/src/Controls/src/Core/NavigationPage/NavigationPage.cs b/src/Controls/src/Core/NavigationPage/NavigationPage.cs index e544f4d19958..3c5fee52c509 100644 --- a/src/Controls/src/Core/NavigationPage/NavigationPage.cs +++ b/src/Controls/src/Core/NavigationPage/NavigationPage.cs @@ -547,6 +547,9 @@ static void OnCurrentPageChanged(BindableObject bindable, object oldValue, objec if (newValue is Page newPage && ((NavigationPage)bindable).HasAppeared) newPage.SendAppearing(); + + // Refresh Enabled on the predictive back callback when the active page changes. + (((NavigationPage)bindable).Window as Window)?.NotifyNavigationStateChanged(); } internal IToolbar FindMyToolbar() @@ -817,7 +820,9 @@ protected override void OnInsertPageBefore(Page page, Page before) //// the current navigation stack //if (Owner._waitingCount == 0) // Owner.UpdateToolbar(); - + // InsertPageBefore changes the stack depth without triggering SendNavigated, + // so refresh the predictive back callback here. + (Owner.Window as Window)?.NotifyNavigationStateChanged(); }).FireAndForget(); } @@ -961,7 +966,9 @@ protected override void OnRemovePage(Page page) //// the current navigation stack //if (Owner._waitingCount == 0) // Owner.UpdateToolbar(); - + // RemovePage changes the stack depth without triggering SendNavigated, + // so refresh the predictive back callback here. + (Owner.Window as Window)?.NotifyNavigationStateChanged(); }).FireAndForget(); } } diff --git a/src/Controls/src/Core/Page/Page.cs b/src/Controls/src/Core/Page/Page.cs index 693a744288ff..9ee57d8359ae 100644 --- a/src/Controls/src/Core/Page/Page.cs +++ b/src/Controls/src/Core/Page/Page.cs @@ -699,6 +699,15 @@ public void SendAppearing() OnAppearing(); Appearing?.Invoke(this, EventArgs.Empty); + // Refresh Enabled on the predictive back callback so the animation preview reflects the new state. + // Guard with Window.Page check so only the outermost SendAppearing in a recursive chain fires + // once, avoiding redundant re-walks in deep hierarchies (e.g. Window → Shell → NavigationPage → ContentPage). + var mauiWindow = this.Window as Window; + if (mauiWindow?.Page == this) + { + mauiWindow.NotifyNavigationStateChanged(); + } + var pageContainer = this as IPageContainer; pageContainer?.CurrentPage?.SendAppearing(); diff --git a/src/Controls/src/Core/Shell/Shell.cs b/src/Controls/src/Core/Shell/Shell.cs index e64c44e3d0a0..bf976e681c73 100644 --- a/src/Controls/src/Core/Shell/Shell.cs +++ b/src/Controls/src/Core/Shell/Shell.cs @@ -1756,6 +1756,7 @@ void SendNavigated(ShellNavigatedEventArgs args) CurrentPage.PropertyChanged += OnCurrentPagePropertyChanged; CurrentItem?.Handler?.UpdateValue(Shell.TabBarIsVisibleProperty.PropertyName); + (this.Window as Window)?.NotifyNavigationStateChanged(); } void OnCurrentPageLoaded(object sender, EventArgs e) @@ -2212,7 +2213,11 @@ protected override void OnPropertyChanged([CallerMemberName] string propertyName { base.OnPropertyChanged(propertyName); if (propertyName == Shell.FlyoutIsPresentedProperty.PropertyName) + { Handler?.UpdateValue(nameof(IFlyoutView.IsPresented)); + // Refresh Enabled on the predictive back callback; flyout state affects whether back is consumed here. + (this.Window as Window)?.NotifyNavigationStateChanged(); + } } #region Shell Flyout Content diff --git a/src/Controls/src/Core/Window/Window.Android.cs b/src/Controls/src/Core/Window/Window.Android.cs index 9385b835816c..270425f8b3a1 100644 --- a/src/Controls/src/Core/Window/Window.Android.cs +++ b/src/Controls/src/Core/Window/Window.Android.cs @@ -7,8 +7,15 @@ namespace Microsoft.Maui.Controls { - public partial class Window : IPlatformEventsListener + public partial class Window : IPlatformEventsListener, IBackNavigationState { + bool IBackNavigationState.CanConsumeBackNavigation => + Navigation.ModalStack.Count > 0 || CanConsumeBackNavigation(Page); + + void RefreshPredictiveBackRegistration() => + (Handler?.PlatformView as MauiAppCompatActivity) + ?.UpdatePredictiveBackRegistration(); + internal Activity PlatformActivity => (Handler?.PlatformView as Activity) ?? throw new InvalidOperationException("Window should have an Activity set."); diff --git a/src/Controls/src/Core/Window/Window.cs b/src/Controls/src/Core/Window/Window.cs index 3275d5aac118..ac71553a47b7 100644 --- a/src/Controls/src/Core/Window/Window.cs +++ b/src/Controls/src/Core/Window/Window.cs @@ -467,6 +467,10 @@ internal void OnModalPopped(Page modalPage) ModalPopped?.Invoke(this, args); Application?.NotifyOfWindowModalEvent(args); + // Refresh the predictive back callback — programmatic PopModalAsync doesn't go through + // BackButtonClicked, so we update here to keep Enabled in sync with the modal stack. + NotifyNavigationStateChanged(); + #if WINDOWS this.Handler?.UpdateValue(nameof(IWindow.TitleBarDragRectangles)); this.Handler?.UpdateValue(nameof(ITitledElement.Title)); @@ -488,6 +492,10 @@ internal void OnModalPushed(Page modalPage) ModalPushed?.Invoke(this, args); Application?.NotifyOfWindowModalEvent(args); + // Refresh the predictive back callback — programmatic PushModalAsync doesn't go through + // BackButtonClicked, so we update here to keep Enabled in sync with the modal stack. + NotifyNavigationStateChanged(); + #if WINDOWS this.Handler?.UpdateValue(nameof(IWindow.TitleBarDragRectangles)); this.Handler?.UpdateValue(nameof(ITitledElement.Title)); @@ -751,12 +759,83 @@ void ShellPropertyChanged(object? sender, System.ComponentModel.PropertyChangedE bool IWindow.BackButtonClicked() { + bool handled; + if (Navigation.ModalStack.Count > 0) { - return Navigation.ModalStack[Navigation.ModalStack.Count - 1].SendBackButtonPressed(); + handled = Navigation.ModalStack[Navigation.ModalStack.Count - 1].SendBackButtonPressed(); } + else + { + handled = this.Page?.SendBackButtonPressed() ?? false; + } + + // Refresh Enabled on the predictive back callback after a back press changes the navigation state. + NotifyNavigationStateChanged(); + return handled; + } + + // Notifies that navigation state has changed so the Android predictive back callback can be updated. + // Dispatches to the main thread when called post-await on a thread-pool thread. + // No-op on non-Android platforms. + internal void NotifyNavigationStateChanged() + { +#if ANDROID + if (MainThread.IsMainThread) + RefreshPredictiveBackRegistration(); + else + MainThread.BeginInvokeOnMainThread(RefreshPredictiveBackRegistration); +#endif + } - return this.Page?.SendBackButtonPressed() ?? false; + // Returns true when there is in-app back navigation to consume, so the system should not + // play the back-to-home animation. Kept in the shared file so it can be unit-tested + // without a device (the Android-specific entry point in Window.Android.cs calls this). + internal static bool CanConsumeBackNavigation(Page? page) + { + if (page is null) + return false; + + switch (page) + { + case Shell shell: + if (CanConsumeBackNavigation(shell.CurrentPage)) + return true; + + // Only consume back to close the flyout when it is user-dismissible. + // Locked and Disabled both mean the flyout is not user-controllable via back. + var flyoutBehavior = shell.GetEffectiveFlyoutBehavior(); + if (shell.FlyoutIsPresented && flyoutBehavior == FlyoutBehavior.Flyout) + { + return true; + } + + // Shell section nav stack depth (non-NavigationPage modern Shell shape). + return shell.CurrentItem?.CurrentItem?.Stack.Count > 1; + + case NavigationPage navigationPage: + if (CanConsumeBackNavigation(navigationPage.CurrentPage)) + return true; + + return navigationPage.Navigation.NavigationStack.Count > 1; + + case FlyoutPage flyoutPage: + // In split-mode (tablets), CanChangeIsPresented=false and IsPresented is locked true; + // back should NOT be consumed in that state — only consume when the flyout can close. + if (flyoutPage.IsPresented && ((IFlyoutPageController)flyoutPage).CanChangeIsPresented) + return true; + + return CanConsumeBackNavigation(flyoutPage.Detail); + + case MultiPage multiPage: + return CanConsumeBackNavigation(multiPage.CurrentPage); + + default: + // Conservative default: return false for unknown page types. + // We cannot know whether a custom container's OnBackButtonPressed() returns true, + // so we avoid suppressing the back-to-home animation speculatively. + return false; + } } static double ValidatePositive(double value, [CallerMemberName] string? name = null) => diff --git a/src/Controls/tests/Core.UnitTests/WindowsTests.cs b/src/Controls/tests/Core.UnitTests/WindowsTests.cs index e7f4608157b0..2c8f2194d680 100644 --- a/src/Controls/tests/Core.UnitTests/WindowsTests.cs +++ b/src/Controls/tests/Core.UnitTests/WindowsTests.cs @@ -947,4 +947,178 @@ private class TestScopedService public string TestProperty { get; set; } = "test"; } } + + /// + /// Unit tests for — pure C# logic, + /// no Android platform dependencies required. + /// + public class CanConsumeBackNavigationTests : BaseTestFixture + { + [Fact] + public void NullPage_ReturnsFalse() + { + Assert.False(Window.CanConsumeBackNavigation(null)); + } + + [Fact] + public void ContentPage_ReturnsFalse() + { + // A plain ContentPage with no navigation stack → cannot consume back. + var page = new ContentPage(); + Assert.False(Window.CanConsumeBackNavigation(page)); + } + + [Fact] + public void NavigationPage_SinglePage_ReturnsFalse() + { + // NavigationPage with only the root page — nothing to pop. + var nav = new TestNavigationPage(true, new ContentPage()); + Assert.False(Window.CanConsumeBackNavigation(nav)); + } + + [Fact] + public async Task NavigationPage_MultiplePagesInStack_ReturnsTrue() + { + // After pushing a second page the stack has > 1 entry — back can be consumed. + var nav = new TestNavigationPage(true, new ContentPage()); + _ = new TestWindow(nav); + await nav.PushAsync(new ContentPage()); + Assert.True(Window.CanConsumeBackNavigation(nav)); + } + + [Fact] + public void FlyoutPage_IsPresented_ReturnsTrue() + { + // FlyoutPage with flyout open → back closes the flyout. + var flyout = new FlyoutPage + { + Flyout = new ContentPage { Title = "Flyout" }, + Detail = new ContentPage(), + IsPresented = true, + IsPlatformEnabled = true, + }; + Assert.True(Window.CanConsumeBackNavigation(flyout)); + } + + [Fact] + public void FlyoutPage_NotPresented_NoNavigation_ReturnsFalse() + { + // FlyoutPage with flyout closed and plain ContentPage detail → cannot consume back. + var flyout = new FlyoutPage + { + Flyout = new ContentPage { Title = "Flyout" }, + Detail = new ContentPage(), + IsPresented = false, + IsPlatformEnabled = true, + }; + Assert.False(Window.CanConsumeBackNavigation(flyout)); + } + + [Fact] + public void FlyoutPage_BackButtonPressedSubscriber_DoesNotSuppressAnimation() + { + // Regression: HasBackButtonPressedSubscribers was a false positive. + // A subscriber that doesn't set Handled=true must NOT suppress the animation. + var flyout = new FlyoutPage + { + Flyout = new ContentPage { Title = "Flyout" }, + Detail = new ContentPage(), + IsPresented = false, + IsPlatformEnabled = true, + }; + flyout.BackButtonPressed += (s, e) => { /* does not set e.Handled = true */ }; + Assert.False(Window.CanConsumeBackNavigation(flyout)); + } + + [Fact] + public async Task FlyoutPage_DetailIsNavigationPageWithStack_ReturnsTrue() + { + // FlyoutPage whose detail is a NavigationPage with multiple pages → back navigates. + var nav = new TestNavigationPage(true, new ContentPage()); + var flyout = new FlyoutPage + { + Flyout = new ContentPage { Title = "Flyout" }, + Detail = nav, + IsPresented = false, + IsPlatformEnabled = true, + }; + _ = new TestWindow(flyout); + await nav.PushAsync(new ContentPage()); + Assert.True(Window.CanConsumeBackNavigation(flyout)); + } + + [Fact] + public void UnknownPageType_ReturnsFalse() + { + // Custom/unknown page types must conservatively return false to avoid blocking + // the back-to-home animation for containers that don't override OnBackButtonPressed. + var page = new ContentPage(); + Assert.False(Window.CanConsumeBackNavigation(page)); + } + + [Fact] + public void FlyoutPage_SplitMode_ReturnsFalse() + { + // In split mode (tablets), CanChangeIsPresented=false while IsPresented is locked true. + // The flyout is always visible so back must NOT be consumed. + var flyout = new FlyoutPage + { + Flyout = new ContentPage { Title = "Flyout" }, + Detail = new ContentPage(), + IsPresented = true, + IsPlatformEnabled = true, + }; + ((IFlyoutPageController)flyout).CanChangeIsPresented = false; + Assert.False(Window.CanConsumeBackNavigation(flyout)); + } + + [Fact] + public void Shell_RootPage_ReturnsFalse() + { + // Shell at root with a single content page and no flyout visible -> cannot consume back. + var shell = CreateSimpleShell(); + Assert.False(Window.CanConsumeBackNavigation(shell)); + } + + [Fact] + public void Shell_FlyoutIsPresented_ReturnsTrue() + { + // Shell flyout is open and not locked -> back closes the flyout. + var shell = CreateSimpleShell(FlyoutBehavior.Flyout); + shell.FlyoutIsPresented = true; + Assert.True(Window.CanConsumeBackNavigation(shell)); + } + + [Fact] + public void Shell_FlyoutIsPresented_Locked_ReturnsFalse() + { + // Locked flyout is always visible -- IsPresented should not suppress the + // back-to-home animation even if true. + var shell = CreateSimpleShell(FlyoutBehavior.Locked); + shell.FlyoutIsPresented = true; + Assert.False(Window.CanConsumeBackNavigation(shell)); + } + + [Fact] + public async Task Shell_StackGreaterThanOne_ReturnsTrue() + { + // After pushing a page via shell navigation the section stack is > 1 -> back can pop. + var shell = CreateSimpleShell(); + _ = new TestWindow(shell); + await shell.Navigation.PushAsync(new ContentPage()); + Assert.True(Window.CanConsumeBackNavigation(shell)); + } + + static Shell CreateSimpleShell(FlyoutBehavior flyoutBehavior = FlyoutBehavior.Disabled) + { + var shell = new Shell { FlyoutBehavior = flyoutBehavior }; + var content = new ShellContent { Content = new ContentPage() }; + var section = new ShellSection(); + section.Items.Add(content); + var item = new ShellItem(); + item.Items.Add(section); + shell.Items.Add(item); + return shell; + } + } } \ No newline at end of file diff --git a/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.Android.cs b/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.Android.cs index 3820044e3311..4e6790f65aa1 100644 --- a/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.Android.cs +++ b/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.Android.cs @@ -58,12 +58,12 @@ static void OnConfigureLifeCycle(IAndroidLifecycleBuilder android) // If we tried to call window.Destroying() before, GetWindow() should return null activity.GetWindow()?.Destroying(); }) - .OnBackPressed(activity => - { - return activity.GetWindow()?.BackButtonClicked() ?? false; - }); + .OnBackPressed(HandleWindowBackButtonPressed); } + internal static bool HandleWindowBackButtonPressed(global::Android.App.Activity activity) => + activity.GetWindow()?.BackButtonClicked() ?? false; + static void OnConfigureWindow(IAndroidLifecycleBuilder android) { android @@ -92,4 +92,4 @@ static void OnConfigureWindow(IAndroidLifecycleBuilder android) }); } } -} \ No newline at end of file +} diff --git a/src/Core/src/Platform/Android/IBackNavigationState.cs b/src/Core/src/Platform/Android/IBackNavigationState.cs new file mode 100644 index 000000000000..1afb6a84882d --- /dev/null +++ b/src/Core/src/Platform/Android/IBackNavigationState.cs @@ -0,0 +1,7 @@ +namespace Microsoft.Maui +{ + interface IBackNavigationState + { + bool CanConsumeBackNavigation { get; } + } +} diff --git a/src/Core/src/Platform/Android/MauiAppCompatActivity.Lifecycle.cs b/src/Core/src/Platform/Android/MauiAppCompatActivity.Lifecycle.cs index 7c0280da8a86..1f813c41cd3a 100644 --- a/src/Core/src/Platform/Android/MauiAppCompatActivity.Lifecycle.cs +++ b/src/Core/src/Platform/Android/MauiAppCompatActivity.Lifecycle.cs @@ -148,7 +148,25 @@ void HandleBackNavigation() }); if (!preventBackPropagation) - base.OnBackPressed(); + { + // Disable the callback before dispatching base.OnBackPressed() to prevent re-entry + // through OnBackPressedDispatcher, which would re-invoke this same enabled callback. + if (_mauiOnBackPressedCallback is not null) + { + _mauiOnBackPressedCallback.Enabled = false; + } + + try + { + base.OnBackPressed(); + } + finally + { + // Always re-evaluate after propagation so Enabled reflects the new navigation state. + // Using finally ensures the callback isn't permanently disabled if OnBackPressed throws. + UpdatePredictiveBackRegistration(); + } + } } } } \ No newline at end of file diff --git a/src/Core/src/Platform/Android/MauiAppCompatActivity.cs b/src/Core/src/Platform/Android/MauiAppCompatActivity.cs index aa9b9a210e98..de7ae83171c8 100644 --- a/src/Core/src/Platform/Android/MauiAppCompatActivity.cs +++ b/src/Core/src/Platform/Android/MauiAppCompatActivity.cs @@ -1,7 +1,6 @@ using System; using Android.OS; using Android.Views; -using Android.Window; using AndroidX.Activity; using AndroidX.AppCompat.App; using AndroidX.Core.Content.Resources; @@ -35,27 +34,20 @@ protected override void OnCreate(Bundle? savedInstanceState) this.CreatePlatformWindow(IPlatformApplication.Current.Application, savedInstanceState); } - // Register predictive back callback (Android 13+/API 33+) if available. - // This integrates MAUI lifecycle OnBackPressed events with the system back gesture animation. - // Guidance: route custom back handling through AndroidX OnBackPressedDispatcher so - // predictive back works correctly: - // https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture#update-custom - if (OperatingSystem.IsAndroidVersionAtLeast(33) && _predictiveBackCallback is null) - { - _predictiveBackCallback = new PredictiveBackCallback(this); - // Priority 0 = PRIORITY_DEFAULT: callback invoked only when no higher-priority callback handles the event - OnBackInvokedDispatcher?.RegisterOnBackInvokedCallback(0, _predictiveBackCallback); - } + // Use OnBackPressedCallback (AndroidX) so the system predictive back-to-home + // animation plays when the app has nothing to handle (IsEnabled = false). + // IOnBackInvokedCallback (Android 13+ API) was avoided here because registering + // one always suppresses the back-to-home animation regardless of IsEnabled. + _mauiOnBackPressedCallback = new MauiOnBackPressedCallback(this); + OnBackPressedDispatcher.AddCallback(this, _mauiOnBackPressedCallback); + UpdatePredictiveBackRegistration(); } protected override void OnDestroy() { - if (OperatingSystem.IsAndroidVersionAtLeast(33) && _predictiveBackCallback is not null) - { - OnBackInvokedDispatcher?.UnregisterOnBackInvokedCallback(_predictiveBackCallback); - _predictiveBackCallback.Dispose(); - _predictiveBackCallback = null; - } + _mauiOnBackPressedCallback?.Remove(); + _mauiOnBackPressedCallback?.Dispose(); + _mauiOnBackPressedCallback = null; base.OnDestroy(); } @@ -75,21 +67,51 @@ public override bool DispatchTouchEvent(MotionEvent? e) return handled || implHandled; } - PredictiveBackCallback? _predictiveBackCallback; + MauiOnBackPressedCallback? _mauiOnBackPressedCallback; + + // Must be called at every navigation state change so that Enabled reflects the current back + // stack before the predictive back drag preview starts (Android reads Enabled before commit). + // Call sites: Page.SendAppearing, Shell.SendNavigated, NavigationPage, Window. + internal void UpdatePredictiveBackRegistration() + { + if (_mauiOnBackPressedCallback is null) + return; + + _mauiOnBackPressedCallback.Enabled = ShouldRegisterPredictiveBackCallback(); + } + + bool ShouldRegisterPredictiveBackCallback() + { + var services = IPlatformApplication.Current?.Services; + if (services is null) + return false; + + // Early exit after the first match — avoids iterating all registered delegates unnecessarily. + bool hasAnyHandler = false; + foreach (var handler in services.GetLifecycleEventDelegates()) + { + hasAnyHandler = true; + break; + } + + if (!hasAnyHandler) + return false; + + return this.GetWindow() is IBackNavigationState { CanConsumeBackNavigation: true }; + } - sealed class PredictiveBackCallback : Java.Lang.Object, IOnBackInvokedCallback + sealed class MauiOnBackPressedCallback : OnBackPressedCallback { readonly MauiAppCompatActivity _activity; - public PredictiveBackCallback(MauiAppCompatActivity activity) + public MauiOnBackPressedCallback(MauiAppCompatActivity activity) : base(false) { _activity = activity; } - public void OnBackInvoked() + public override void HandleOnBackPressed() { - // Reuse unified handling (will invoke lifecycle events and conditionally propagate). _activity.HandleBackNavigation(); } } } -} \ No newline at end of file +}