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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AppBarLayout>();
var rootCoordinator = appBarLayout?.Parent.GetParentOfType<CoordinatorLayout>();

nativeToolbar.MaybeRequestLayout();
appBarLayout?.MaybeRequestLayout();
rootCoordinator?.MaybeRequestLayout();

if (rootCoordinator is not null)
{
ViewCompat.RequestApplyInsets(rootCoordinator);
Comment thread
ne0rrmatrix marked this conversation as resolved.
}
else if (appBarLayout is not null)
{
ViewCompat.RequestApplyInsets(appBarLayout);
}
else
{
ViewCompat.RequestApplyInsets(nativeToolbar);
}
}

public static void UpdateTitleIcon(this AToolbar nativeToolbar, Toolbar toolbar)
Expand Down
53 changes: 53 additions & 0 deletions src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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<bool> _taskCompletionSource = new TaskCompletionSource<bool>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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<WindowHandlerStub>(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<AppBarLayout>(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.");
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,84 @@ await CreateHandlerAndAddToWindow<ShellRenderer>(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<ShellRenderer>(shell, async (handler) =>
{
await OnLoadedAsync(contentPage);
await OnNavigatedToAsync(contentPage);

var platformToolbar = GetPlatformToolbar(handler);
var appBar = platformToolbar.Parent.GetParentOfType<AppBarLayout>();
var rootCoordinator = appBar?.Parent.GetParentOfType<CoordinatorLayout>();
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);
Expand Down
52 changes: 38 additions & 14 deletions src/Core/src/Platform/Android/MauiWindowInsetListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
Loading