From 614ba59f99982f14e18d45383ef47ef8e0d1d998 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Wed, 20 May 2026 19:43:40 -0700 Subject: [PATCH 1/6] Fix Android Shell top inset when nav bar is hidden --- .../Android/Extensions/ToolbarExtensions.cs | 24 ++++++++- .../Elements/Shell/ShellTests.Android.cs | 38 ++++++++++++++ .../Android/MauiWindowInsetListener.cs | 51 ++++++++++++++----- 3 files changed, 98 insertions(+), 15 deletions(-) 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/Elements/Shell/ShellTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs index d4051b5619da..38cda8c7f969 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs @@ -117,6 +117,44 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => }); } + [Fact(DisplayName = "Hidden Shell navigation bar clears app bar inset padding")] + public async Task HiddenShellNavigationBarClearsAppBarInsetPadding() + { + SetupBuilder(); + + var contentPage = new ContentPage() + { + Title = "Test", + Content = new Label { Text = "Content" } + }; + + Shell.SetNavBarIsVisible(contentPage, false); + + 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(); + + Assert.NotNull(appBar); + + await AssertEventually(() => platformToolbar.LayoutParameters?.Height == 0, + timeout: 2000, + message: "Toolbar did not collapse after Shell.NavBarIsVisible was set to false."); + + await AssertEventually(() => appBar.PaddingTop == 0, + timeout: 2000, + message: "AppBar retained top inset padding after the Shell navigation bar was hidden."); + }); + } + 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..83007b67811d 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,37 @@ public MauiWindowInsetListener() : base(DispatchModeStop) ?.Build() ?? insets; } + static bool HasVisibleAppBarContent(AppBarLayout? appBarLayout) + { + if (appBarLayout is null || appBarLayout.Visibility == ViewStates.Gone) + { + return false; + } + + var measuredContentHeight = Math.Max(0, appBarLayout.MeasuredHeight - appBarLayout.PaddingTop - appBarLayout.PaddingBottom); + if (measuredContentHeight > 0) + { + return true; + } + + for (int i = 0; i < appBarLayout.ChildCount; i++) + { + var child = appBarLayout.GetChildAt(i); + if (child is null || child.Visibility == ViewStates.Gone) + { + continue; + } + + var childContentHeight = Math.Max(0, child.MeasuredHeight - child.PaddingTop - child.PaddingBottom); + if (childContentHeight > 0 || child.Height > 0 || child.LayoutParameters?.Height > 0) + { + return true; + } + } + + return false; + } + public void TrackView(AView view) { _trackedViews.Add(view); From 832962d0e38b07db07e8c31ffcddd8122b03fa21 Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Thu, 21 May 2026 15:01:08 -0700 Subject: [PATCH 2/6] Fix Android Shell tests to handle hidden navigation bar insets correctly --- .../Elements/Shell/ShellTests.Android.cs | 53 +++++++++++++++++-- .../Android/MauiWindowInsetListener.cs | 3 +- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs index 38cda8c7f969..3b2e25e6fe57 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs @@ -121,6 +121,8 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => public async Task HiddenShellNavigationBarClearsAppBarInsetPadding() { SetupBuilder(); + const int statusBarTopInset = 24; + const int displayCutoutTopInset = 96; var contentPage = new ContentPage() { @@ -128,7 +130,7 @@ public async Task HiddenShellNavigationBarClearsAppBarInsetPadding() Content = new Label { Text = "Content" } }; - Shell.SetNavBarIsVisible(contentPage, false); + var syntheticInsets = CreateTopCutoutInsets(statusBarTopInset, displayCutoutTopInset); var shell = await CreateShellAsync(shell => { @@ -142,19 +144,64 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => var platformToolbar = GetPlatformToolbar(handler); var appBar = platformToolbar.Parent.GetParentOfType(); + var rootCoordinator = appBar?.Parent.GetParentOfType(); + var listener = new MauiWindowInsetListener(); Assert.NotNull(appBar); + Assert.NotNull(rootCoordinator); - await AssertEventually(() => platformToolbar.LayoutParameters?.Height == 0, + await AssertEventually(() => platformToolbar.LayoutParameters?.Height > 0, timeout: 2000, - message: "Toolbar did not collapse after Shell.NavBarIsVisible was set to false."); + message: "Toolbar did not render before Shell.NavBarIsVisible was toggled."); + + var insetsWhenVisible = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); + + 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(insetsWhenVisible, expectedSystemBarsTop: 0, expectedDisplayCutoutTop: 0, + message: "Visible Shell app bar should consume the synthetic top insets."); + + 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."); + + var insetsWhenHidden = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); await AssertEventually(() => appBar.PaddingTop == 0, timeout: 2000, message: "AppBar retained top inset padding after the Shell navigation bar was hidden."); + + AssertTopInsets(insetsWhenHidden, + expectedSystemBarsTop: statusBarTopInset, + expectedDisplayCutoutTop: displayCutoutTopInset, + message: "Hidden Shell app bar should stop consuming the synthetic top insets."); }); } + 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(); + } + + 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 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 83007b67811d..261316ea22a4 100644 --- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs +++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs @@ -321,8 +321,9 @@ static bool HasVisibleAppBarContent(AppBarLayout? appBarLayout) continue; } + var childLayoutHeight = child.LayoutParameters?.Height ?? 0; var childContentHeight = Math.Max(0, child.MeasuredHeight - child.PaddingTop - child.PaddingBottom); - if (childContentHeight > 0 || child.Height > 0 || child.LayoutParameters?.Height > 0) + if (childContentHeight > 0 || child.Height > 0 || childLayoutHeight != 0) { return true; } From 2a46cc51e8419511375a250fd34f4385b47ce7cb Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Fri, 22 May 2026 13:10:59 -0700 Subject: [PATCH 3/6] Fix NavigationPage behavior with hidden navigation bar and update inset handling --- .../NavigationPageTests.Android.cs | 98 +++++++++++++++++++ .../Android/MauiWindowInsetListener.cs | 14 +-- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs index 9b9d0e37067a..7b0d25781a81 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,101 @@ 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 listener = new MauiWindowInsetListener(); + + 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."); + + var insetsWhenVisible = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); + + 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(insetsWhenVisible, expectedSystemBarsTop: 0, expectedDisplayCutoutTop: 0, + message: "Visible NavigationPage app bar should consume the synthetic top insets."); + + 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."); + + var insetsWhenHidden = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); + + await AssertEventually(() => appBar.PaddingTop == 0, + timeout: 2000, + message: "AppBar retained top inset padding after navigating to a page with the NavigationPage navigation bar hidden."); + + AssertTopInsets(insetsWhenHidden, + expectedSystemBarsTop: statusBarTopInset, + expectedDisplayCutoutTop: displayCutoutTopInset, + message: "Hidden NavigationPage app bar should stop consuming the synthetic top insets."); + }); + } + + 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(); + } + + 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)}."); + } } } diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs index 261316ea22a4..7c61660f38f7 100644 --- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs +++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs @@ -307,12 +307,6 @@ static bool HasVisibleAppBarContent(AppBarLayout? appBarLayout) return false; } - var measuredContentHeight = Math.Max(0, appBarLayout.MeasuredHeight - appBarLayout.PaddingTop - appBarLayout.PaddingBottom); - if (measuredContentHeight > 0) - { - return true; - } - for (int i = 0; i < appBarLayout.ChildCount; i++) { var child = appBarLayout.GetChildAt(i); @@ -323,10 +317,16 @@ static bool HasVisibleAppBarContent(AppBarLayout? appBarLayout) var childLayoutHeight = child.LayoutParameters?.Height ?? 0; var childContentHeight = Math.Max(0, child.MeasuredHeight - child.PaddingTop - child.PaddingBottom); - if (childContentHeight > 0 || child.Height > 0 || childLayoutHeight != 0) + if (childContentHeight > 0 || child.Height > 0 || childLayoutHeight > 0) + { + return true; + } + + if (child is MaterialToolbar && childLayoutHeight != 0) { return true; } + } return false; From 6fcb97ecda21452e01c5df692c37a56671ce1c5f Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Sat, 23 May 2026 10:37:30 -0700 Subject: [PATCH 4/6] Fix child view height handling in MauiWindowInsetListener for MaterialToolbar --- src/Core/src/Platform/Android/MauiWindowInsetListener.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs index 7c61660f38f7..0296cbef62db 100644 --- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs +++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs @@ -316,13 +316,13 @@ static bool HasVisibleAppBarContent(AppBarLayout? appBarLayout) } var childLayoutHeight = child.LayoutParameters?.Height ?? 0; - var childContentHeight = Math.Max(0, child.MeasuredHeight - child.PaddingTop - child.PaddingBottom); - if (childContentHeight > 0 || child.Height > 0 || childLayoutHeight > 0) + if (child is MaterialToolbar && childLayoutHeight == 0) { - return true; + continue; } - if (child is MaterialToolbar && childLayoutHeight != 0) + var childContentHeight = Math.Max(0, child.MeasuredHeight - child.PaddingTop - child.PaddingBottom); + if (childContentHeight > 0 || child.Height > 0 || childLayoutHeight > 0) { return true; } From 14ce8fb5727661a6df7f7281890b859ecb97ca6a Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Tue, 26 May 2026 13:49:51 -0700 Subject: [PATCH 5/6] Strengthen Android hidden-navbar inset tests --- .../ControlsHandlerTestBase.Android.cs | 53 +++++++++++++++++++ .../NavigationPageTests.Android.cs | 40 +++++--------- .../Elements/Shell/ShellTests.Android.cs | 40 +++++--------- 3 files changed, 77 insertions(+), 56 deletions(-) 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 7b0d25781a81..52cf5b9644b5 100644 --- a/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs @@ -145,7 +145,7 @@ await CreateHandlerAndAddToWindow(new Window(navPage), async var platformToolbar = GetPlatformToolbar(handler); var rootCoordinator = handler.MauiContext?.GetNavigationRootManager()?.RootView as CoordinatorLayout; var appBar = rootCoordinator?.FindViewById(Resource.Id.navigationlayout_appbar); - var listener = new MauiWindowInsetListener(); + var capturingListener = AttachCapturingWindowInsetsListener(rootCoordinator, platformToolbar); Assert.NotNull(platformToolbar); Assert.NotNull(rootCoordinator); @@ -155,15 +155,21 @@ await AssertEventually(() => platformToolbar.LayoutParameters?.Height > 0, timeout: 2000, message: "Toolbar did not render before navigating to the page with the navigation bar hidden."); - var insetsWhenVisible = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); + 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(insetsWhenVisible, expectedSystemBarsTop: 0, expectedDisplayCutoutTop: 0, + 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); @@ -176,37 +182,15 @@ await AssertEventually(() => timeout: 2000, message: "Toolbar did not fully collapse after navigating to a page with NavigationPage.HasNavigationBar set to false."); - var insetsWhenHidden = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); - - await AssertEventually(() => appBar.PaddingTop == 0, + await AssertEventually(() => capturingListener.InvocationCount > visibleInsetsInvocationCount && appBar.PaddingTop == 0, timeout: 2000, - message: "AppBar retained top inset padding after navigating to a page with the NavigationPage navigation bar hidden."); + message: "Navigating to a page with the navigation bar hidden did not trigger an inset redispatch that cleared the AppBar top padding."); - AssertTopInsets(insetsWhenHidden, + AssertTopInsets(capturingListener.LastAppliedInsets, expectedSystemBarsTop: statusBarTopInset, expectedDisplayCutoutTop: displayCutoutTopInset, message: "Hidden NavigationPage app bar should stop consuming the synthetic top insets."); }); } - - 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(); - } - - 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)}."); - } } } diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs index 3b2e25e6fe57..e6963081c600 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs @@ -145,7 +145,7 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => var platformToolbar = GetPlatformToolbar(handler); var appBar = platformToolbar.Parent.GetParentOfType(); var rootCoordinator = appBar?.Parent.GetParentOfType(); - var listener = new MauiWindowInsetListener(); + var capturingListener = AttachCapturingWindowInsetsListener(rootCoordinator, platformToolbar); Assert.NotNull(appBar); Assert.NotNull(rootCoordinator); @@ -154,54 +154,38 @@ await AssertEventually(() => platformToolbar.LayoutParameters?.Height > 0, timeout: 2000, message: "Toolbar did not render before Shell.NavBarIsVisible was toggled."); - var insetsWhenVisible = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); + 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(insetsWhenVisible, expectedSystemBarsTop: 0, expectedDisplayCutoutTop: 0, + 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."); - var insetsWhenHidden = listener.OnApplyWindowInsets(rootCoordinator, syntheticInsets); - - await AssertEventually(() => appBar.PaddingTop == 0, + await AssertEventually(() => capturingListener.InvocationCount > visibleInsetsInvocationCount && appBar.PaddingTop == 0, timeout: 2000, - message: "AppBar retained top inset padding after the Shell navigation bar was hidden."); + message: "Shell.NavBarIsVisible did not trigger an inset redispatch that cleared the AppBar top padding."); - AssertTopInsets(insetsWhenHidden, + AssertTopInsets(capturingListener.LastAppliedInsets, expectedSystemBarsTop: statusBarTopInset, expectedDisplayCutoutTop: displayCutoutTopInset, message: "Hidden Shell app bar should stop consuming the synthetic top insets."); }); } - 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(); - } - - 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 async Task CheckFlyoutState(ShellRenderer handler, bool desiredState) { var drawerLayout = GetDrawerLayout(handler); From 25ac55addd34c89a08aef942fff20637ca0ccdac Mon Sep 17 00:00:00 2001 From: James Crutchley Date: Tue, 26 May 2026 14:22:42 -0700 Subject: [PATCH 6/6] Fix test assertions: re-dispatch synthetic insets after nav bar hide After Shell.SetNavBarIsVisible(false) or NavigationPage.HasNavigationBar=false, ViewCompat.RequestApplyInsets() triggers a dispatch of the real system insets (not the synthetic ones injected earlier). The capturing listener therefore records the real device insets in LastAppliedInsets, not the synthetic 24/96 values the test expected. Fix by re-dispatching the synthetic insets explicitly after verifying the app bar padding has cleared, then asserting the pass-through result against the known synthetic values. --- .../NavigationPage/NavigationPageTests.Android.cs | 9 +++++++++ .../DeviceTests/Elements/Shell/ShellTests.Android.cs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs index 52cf5b9644b5..31d5f8f0cae0 100644 --- a/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/NavigationPage/NavigationPageTests.Android.cs @@ -186,6 +186,15 @@ await AssertEventually(() => capturingListener.InvocationCount > visibleInsetsIn 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, diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs index e6963081c600..6c841ded0ba6 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.Android.cs @@ -179,6 +179,15 @@ await AssertEventually(() => capturingListener.InvocationCount > visibleInsetsIn 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,