diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index 4e8633b3a1f1..eb28142f93a9 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -9,11 +9,14 @@ using Android.Text; using Android.Text.Style; using Android.Views; +using AndroidX.CoordinatorLayout.Widget; using AndroidX.AppCompat.Graphics.Drawable; using AndroidX.AppCompat.Widget; using AndroidX.Core.View; using AndroidX.Core.View.Accessibility; +using Google.Android.Material.AppBar; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Platform; using Microsoft.Maui.Primitives; using AGraphics = Android.Graphics; using ATextView = global::Android.Widget.TextView; @@ -54,7 +57,26 @@ public static void UpdateIsVisible(this AToolbar nativeToolbar, Toolbar toolbar) } nativeToolbar.LayoutParameters = lp; - AndroidX.Core.View.ViewCompat.RequestApplyInsets(nativeToolbar); + + var appBarLayout = nativeToolbar.Parent.GetParentOfType(); + var rootCoordinator = appBarLayout?.Parent.GetParentOfType(); + + nativeToolbar.MaybeRequestLayout(); + appBarLayout?.MaybeRequestLayout(); + rootCoordinator?.MaybeRequestLayout(); + + if (rootCoordinator is not null) + { + ViewCompat.RequestApplyInsets(rootCoordinator); + } + else if (appBarLayout is not null) + { + ViewCompat.RequestApplyInsets(appBarLayout); + } + else + { + ViewCompat.RequestApplyInsets(nativeToolbar); + } } public static void UpdateTitleIcon(this AToolbar nativeToolbar, Toolbar toolbar) diff --git a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs index 86e57d9cb0e5..f5ec49fc86ce 100644 --- a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs +++ b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs @@ -8,6 +8,7 @@ using AndroidX.AppCompat.View.Menu; using AndroidX.AppCompat.Widget; using AndroidX.CoordinatorLayout.Widget; +using AndroidX.Core.View; using AndroidX.Fragment.App; using Google.Android.Material.AppBar; using Microsoft.Maui.Controls; @@ -211,6 +212,36 @@ public bool IsNavigationBarVisible(IMauiContext mauiContext) .LayoutParameters?.Height > 0; } + protected static WindowInsetsCompat CreateTopCutoutInsets(int statusBarTopInset, int displayCutoutTopInset) + { + return new WindowInsetsCompat.Builder() + .SetInsets(WindowInsetsCompat.Type.SystemBars(), AndroidX.Core.Graphics.Insets.Of(0, statusBarTopInset, 0, 0)) + .SetInsets(WindowInsetsCompat.Type.DisplayCutout(), AndroidX.Core.Graphics.Insets.Of(0, displayCutoutTopInset, 0, 0)) + .Build(); + } + + protected static void AssertTopInsets(WindowInsetsCompat insets, int expectedSystemBarsTop, int expectedDisplayCutoutTop, string message) + { + Assert.NotNull(insets); + + var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + var displayCutout = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); + + Assert.True( + (systemBars?.Top ?? 0) == expectedSystemBarsTop && (displayCutout?.Top ?? 0) == expectedDisplayCutoutTop, + $"{message} Actual SystemBars.Top={(systemBars?.Top ?? 0)}, DisplayCutout.Top={(displayCutout?.Top ?? 0)}."); + } + + protected static CapturingWindowInsetsListener AttachCapturingWindowInsetsListener(AView rootView, AView descendantView) + { + var originalListener = MauiWindowInsetListener.FindListenerForView(descendantView) + ?? throw new InvalidOperationException("Unable to locate the MauiWindowInsetListener for the current test view hierarchy."); + + var capturingListener = new CapturingWindowInsetsListener(originalListener); + ViewCompat.SetOnApplyWindowInsetsListener(rootView, capturingListener); + return capturingListener; + } + protected bool IsBackButtonVisible(IElementHandler handler) { if (GetPlatformToolbar(handler)?.NavigationIcon is DrawerArrowDrawable dad) @@ -230,6 +261,28 @@ protected void AssertTranslationMatches(global::Android.Views.View nativeView, d Assert.Equal(expectedYInPixels, nativeView.TranslationY, precision: 1); } + protected sealed class CapturingWindowInsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener + { + readonly MauiWindowInsetListener _innerListener; + + internal CapturingWindowInsetsListener(MauiWindowInsetListener innerListener) + { + _innerListener = innerListener ?? throw new ArgumentNullException(nameof(innerListener)); + } + + public int InvocationCount { get; private set; } + + public WindowInsetsCompat LastAppliedInsets { get; private set; } + + public WindowInsetsCompat OnApplyWindowInsets(AView v, WindowInsetsCompat insets) + { + var appliedInsets = _innerListener.OnApplyWindowInsets(v, insets) ?? insets; + LastAppliedInsets = appliedInsets; + InvocationCount++; + return appliedInsets; + } + } + class WindowTestFragment : Fragment { TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(); diff --git a/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs index 9b9d0e37067a..31d5f8f0cae0 100644 --- a/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Text; using System.Threading.Tasks; +using AndroidX.CoordinatorLayout.Widget; using Google.Android.Material.AppBar; using Microsoft.Maui; using Microsoft.Maui.Controls; @@ -12,6 +13,7 @@ using Microsoft.Maui.Handlers; using Microsoft.Maui.Platform; using Xunit; +using AndroidX.Core.View; using static Microsoft.Maui.DeviceTests.AssertHelpers; namespace Microsoft.Maui.DeviceTests @@ -110,5 +112,94 @@ await AssertEventually(() => message: "StackNavigationManager fields were not cleared after Disconnect()"); }); } + + [Fact(DisplayName = "NavigationPage push to hidden navigation bar clears app bar inset padding")] + public async Task PushingToPageWithoutNavigationBarClearsAppBarInsetPadding() + { + SetupBuilder(); + const int statusBarTopInset = 24; + const int displayCutoutTopInset = 96; + + var rootPage = new ContentPage + { + Title = "Visible Page", + Content = new Label { Text = "Root Content" } + }; + + var hiddenNavBarPage = new ContentPage + { + Title = "Hidden Page", + Content = new Label { Text = "Hidden Content" } + }; + + NavigationPage.SetHasNavigationBar(hiddenNavBarPage, false); + + var syntheticInsets = CreateTopCutoutInsets(statusBarTopInset, displayCutoutTopInset); + var navPage = new NavigationPage(rootPage); + + await CreateHandlerAndAddToWindow(new Window(navPage), async (handler) => + { + await OnLoadedAsync(rootPage); + await OnNavigatedToAsync(rootPage); + + var platformToolbar = GetPlatformToolbar(handler); + var rootCoordinator = handler.MauiContext?.GetNavigationRootManager()?.RootView as CoordinatorLayout; + var appBar = rootCoordinator?.FindViewById(Resource.Id.navigationlayout_appbar); + var capturingListener = AttachCapturingWindowInsetsListener(rootCoordinator, platformToolbar); + + Assert.NotNull(platformToolbar); + Assert.NotNull(rootCoordinator); + Assert.NotNull(appBar); + + await AssertEventually(() => platformToolbar.LayoutParameters?.Height > 0, + timeout: 2000, + message: "Toolbar did not render before navigating to the page with the navigation bar hidden."); + + ViewCompat.DispatchApplyWindowInsets(rootCoordinator, syntheticInsets); + + await AssertEventually(() => capturingListener.InvocationCount > 0, + timeout: 2000, + message: "The NavigationPage root did not receive the initial synthetic window insets dispatch."); + + await AssertEventually(() => appBar.PaddingTop == displayCutoutTopInset, + timeout: 2000, + message: "AppBar never received the synthetic display cutout top inset while the NavigationPage navigation bar was visible."); + + AssertTopInsets(capturingListener.LastAppliedInsets, expectedSystemBarsTop: 0, expectedDisplayCutoutTop: 0, + message: "Visible NavigationPage app bar should consume the synthetic top insets."); + + var visibleInsetsInvocationCount = capturingListener.InvocationCount; + + await navPage.Navigation.PushAsync(hiddenNavBarPage); + await OnLoadedAsync(hiddenNavBarPage); + await OnNavigatedToAsync(hiddenNavBarPage); + + await AssertEventually(() => + { + var currentToolbar = GetPlatformToolbar(handler); + return currentToolbar?.LayoutParameters?.Height == 0 && currentToolbar.Height == 0; + }, + timeout: 2000, + message: "Toolbar did not fully collapse after navigating to a page with NavigationPage.HasNavigationBar set to false."); + + await AssertEventually(() => capturingListener.InvocationCount > visibleInsetsInvocationCount && appBar.PaddingTop == 0, + timeout: 2000, + message: "Navigating to a page with the navigation bar hidden did not trigger an inset redispatch that cleared the AppBar top padding."); + + // Re-dispatch the synthetic insets now that the nav bar is hidden so we can assert + // with known values that the app bar no longer consumes the top insets. + var hiddenInsetsInvocationCount = capturingListener.InvocationCount; + ViewCompat.DispatchApplyWindowInsets(rootCoordinator, syntheticInsets); + + await AssertEventually(() => capturingListener.InvocationCount > hiddenInsetsInvocationCount, + timeout: 2000, + message: "Expected an additional inset dispatch after re-injecting synthetic insets post-nav-bar-hide."); + + AssertTopInsets(capturingListener.LastAppliedInsets, + expectedSystemBarsTop: statusBarTopInset, + expectedDisplayCutoutTop: displayCutoutTopInset, + message: "Hidden NavigationPage app bar should stop consuming the synthetic top insets."); + }); + } } } diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs index d4051b5619da..6c841ded0ba6 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs @@ -117,6 +117,84 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => }); } + [Fact(DisplayName = "Hidden Shell navigation bar clears app bar inset padding")] + public async Task HiddenShellNavigationBarClearsAppBarInsetPadding() + { + SetupBuilder(); + const int statusBarTopInset = 24; + const int displayCutoutTopInset = 96; + + var contentPage = new ContentPage() + { + Title = "Test", + Content = new Label { Text = "Content" } + }; + + var syntheticInsets = CreateTopCutoutInsets(statusBarTopInset, displayCutoutTopInset); + + var shell = await CreateShellAsync(shell => + { + shell.CurrentItem = new FlyoutItem() { Items = { contentPage } }; + }); + + await CreateHandlerAndAddToWindow(shell, async (handler) => + { + await OnLoadedAsync(contentPage); + await OnNavigatedToAsync(contentPage); + + var platformToolbar = GetPlatformToolbar(handler); + var appBar = platformToolbar.Parent.GetParentOfType(); + var rootCoordinator = appBar?.Parent.GetParentOfType(); + var capturingListener = AttachCapturingWindowInsetsListener(rootCoordinator, platformToolbar); + + Assert.NotNull(appBar); + Assert.NotNull(rootCoordinator); + + await AssertEventually(() => platformToolbar.LayoutParameters?.Height > 0, + timeout: 2000, + message: "Toolbar did not render before Shell.NavBarIsVisible was toggled."); + + ViewCompat.DispatchApplyWindowInsets(rootCoordinator, syntheticInsets); + + await AssertEventually(() => capturingListener.InvocationCount > 0, + timeout: 2000, + message: "The Shell root did not receive the initial synthetic window insets dispatch."); + + await AssertEventually(() => appBar.PaddingTop == displayCutoutTopInset, + timeout: 2000, + message: "AppBar never received the synthetic display cutout top inset before the Shell navigation bar was hidden."); + + AssertTopInsets(capturingListener.LastAppliedInsets, expectedSystemBarsTop: 0, expectedDisplayCutoutTop: 0, + message: "Visible Shell app bar should consume the synthetic top insets."); + + var visibleInsetsInvocationCount = capturingListener.InvocationCount; + + Shell.SetNavBarIsVisible(contentPage, false); + + await AssertEventually(() => platformToolbar.LayoutParameters?.Height == 0 && platformToolbar.Height == 0, + timeout: 2000, + message: "Toolbar did not fully collapse after Shell.NavBarIsVisible was set to false."); + + await AssertEventually(() => capturingListener.InvocationCount > visibleInsetsInvocationCount && appBar.PaddingTop == 0, + timeout: 2000, + message: "Shell.NavBarIsVisible did not trigger an inset redispatch that cleared the AppBar top padding."); + + // Re-dispatch the synthetic insets now that the nav bar is hidden so we can assert + // with known values that the app bar no longer consumes the top insets. + var hiddenInsetsInvocationCount = capturingListener.InvocationCount; + ViewCompat.DispatchApplyWindowInsets(rootCoordinator, syntheticInsets); + + await AssertEventually(() => capturingListener.InvocationCount > hiddenInsetsInvocationCount, + timeout: 2000, + message: "Expected an additional inset dispatch after re-injecting synthetic insets post-nav-bar-hide."); + + AssertTopInsets(capturingListener.LastAppliedInsets, + expectedSystemBarsTop: statusBarTopInset, + expectedDisplayCutoutTop: displayCutoutTopInset, + message: "Hidden Shell app bar should stop consuming the synthetic top insets."); + }); + } + protected async Task CheckFlyoutState(ShellRenderer handler, bool desiredState) { var drawerLayout = GetDrawerLayout(handler); diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs index 943a1330cf5a..0296cbef62db 100644 --- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs +++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs @@ -237,20 +237,12 @@ public MauiWindowInsetListener() : base(DispatchModeStop) } } - // Check if AppBarLayout has meaningful content - bool appBarHasContent = appBarLayout?.MeasuredHeight > 0; - if (!appBarHasContent && appBarLayout is not null) - { - for (int i = 0; i < appBarLayout.ChildCount; i++) - { - var child = appBarLayout.GetChildAt(i); - if (child?.MeasuredHeight > 0) - { - appBarHasContent = true; - break; - } - } - } + // Check if AppBarLayout has meaningful content. + // When the Shell toolbar is hidden we set its height to 0, but the AppBarLayout can still + // retain previously applied top padding. If we key off MeasuredHeight alone, that stale + // padding makes the app bar look "non-empty" and we keep consuming the top inset, + // leaving a blank gap on cutout devices. + bool appBarHasContent = HasVisibleAppBarContent(appBarLayout); // Apply padding to AppBarLayout based on content and system insets if (appBarLayout is not null) @@ -308,6 +300,38 @@ public MauiWindowInsetListener() : base(DispatchModeStop) ?.Build() ?? insets; } + static bool HasVisibleAppBarContent(AppBarLayout? appBarLayout) + { + if (appBarLayout is null || appBarLayout.Visibility == ViewStates.Gone) + { + return false; + } + + for (int i = 0; i < appBarLayout.ChildCount; i++) + { + var child = appBarLayout.GetChildAt(i); + if (child is null || child.Visibility == ViewStates.Gone) + { + continue; + } + + var childLayoutHeight = child.LayoutParameters?.Height ?? 0; + if (child is MaterialToolbar && childLayoutHeight == 0) + { + continue; + } + + var childContentHeight = Math.Max(0, child.MeasuredHeight - child.PaddingTop - child.PaddingBottom); + if (childContentHeight > 0 || child.Height > 0 || childLayoutHeight > 0) + { + return true; + } + + } + + return false; + } + public void TrackView(AView view) { _trackedViews.Add(view);