Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/Controls/src/Core/FlyoutPage/FlyoutPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
11 changes: 9 additions & 2 deletions src/Controls/src/Core/NavigationPage/NavigationPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/Controls/src/Core/Page/Page.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page>;
pageContainer?.CurrentPage?.SendAppearing();

Expand Down
5 changes: 5 additions & 0 deletions src/Controls/src/Core/Shell/Shell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/Controls/src/Core/Window/Window.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");

Expand Down
83 changes: 81 additions & 2 deletions src/Controls/src/Core/Window/Window.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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));
Expand Down Expand Up @@ -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<Page> 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) =>
Expand Down
174 changes: 174 additions & 0 deletions src/Controls/tests/Core.UnitTests/WindowsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -947,4 +947,178 @@ private class TestScopedService
public string TestProperty { get; set; } = "test";
}
}

/// <summary>
/// Unit tests for <see cref="Window.CanConsumeBackNavigation"/> — pure C# logic,
/// no Android platform dependencies required.
/// </summary>
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;
}
}
}
Loading
Loading