From 959b3ffc9b07f9263b2dd890e58cb8abdaa48e90 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Fri, 4 Sep 2026 22:40:57 -0500 Subject: [PATCH 1/2] Go edge-to-edge on Android with native WebView safe-area insets MAUI 10.0.100 plus a Chromium 136+ WebView forward the system bar insets into env(safe-area-inset-*), so the page drops the letterbox (SafeAreaEdges None) and the web UI pads itself. The app now owns the bar icon contrast on every Android version, disables the 3-button navigation scrim, tints the still-opaque bars below Android 15, and re-applies all of that on resume because MAUI resets it whenever the activity relaunches. Fixes #110 --- JournalApp/Data/PreferenceService.cs | 30 +++++++++++++++++-- JournalApp/JournalApp.csproj | 4 +-- JournalApp/MainPage.xaml | 6 ++-- JournalApp/MauiProgram.cs | 7 +++++ JournalApp/wwwroot/app.css | 45 +++++++++++++--------------- JournalApp/wwwroot/index.html | 2 -- global.json | 2 +- 7 files changed, 61 insertions(+), 35 deletions(-) diff --git a/JournalApp/Data/PreferenceService.cs b/JournalApp/Data/PreferenceService.cs index d23fea3..1c05642 100644 --- a/JournalApp/Data/PreferenceService.cs +++ b/JournalApp/Data/PreferenceService.cs @@ -50,6 +50,7 @@ public PreferenceService(ILogger logger, IPreferences prefere { _application = Application.Current; _application.RequestedThemeChanged += Application_RequestedThemeChanged; + } ApplyPlatformTheme(); @@ -211,16 +212,39 @@ public void ApplyPlatformTheme() var surface = IsDarkMode ? GetTheme().PaletteDark.Background : GetTheme().PaletteLight.Background; var background = Color.FromRgb(surface.R, surface.G, surface.B); - // On Android 15 and up the system bars are transparent, so what shows behind them is this page background rather than any status bar color we set. + // The page shows through until the web view paints its first frame, so it carries the surface tone as well. if (_application.Windows.Count > 0 && _application.Windows[0].Page is ContentPage page) page.BackgroundColor = background; - if (OperatingSystem.IsAndroid()) +#if ANDROID + // Go through the MAUI window so this reaches the activity currently hosting it, which Platform.CurrentActivity can lag behind during a relaunch. + var window = (_application.Windows.Count > 0 ? _application.Windows[0].Handler?.PlatformView as Android.App.Activity : null)?.Window; + if (window is null) + return; + + // The web view draws under the transparent bars on Android 15 and up, so icon contrast is the only knob left and it has to follow the in-app theme. + // MAUI guesses it once at startup from the theme's colorPrimary, which is a dark purple in both modes, so it always lands on light icons. + var controller = AndroidX.Core.View.WindowCompat.GetInsetsController(window, window.DecorView); + controller.AppearanceLightStatusBars = !IsDarkMode; + controller.AppearanceLightNavigationBars = !IsDarkMode; + + // Three-button navigation gets a translucent scrim by default, which would grey the surface showing through the bar. + if (OperatingSystem.IsAndroidVersionAtLeast(29)) + { + window.StatusBarContrastEnforced = false; + window.NavigationBarContrastEnforced = false; + } + + // Older versions still draw opaque bars, so tint them to the surface tone instead. + if (!OperatingSystem.IsAndroidVersionAtLeast(35)) { - // Still needed below Android 15, where the status bar has its own color instead of showing the page through. StatusBar.SetColor(background); StatusBar.SetStyle(IsDarkMode ? StatusBarStyle.LightContent : StatusBarStyle.DarkContent); +#pragma warning disable CA1422 // Deprecated in API 35, which the surrounding check excludes. + window.SetNavigationBarColor(Android.Graphics.Color.Rgb(surface.R, surface.G, surface.B)); +#pragma warning restore CA1422 } +#endif } public string GetMoodColor(string emoji) diff --git a/JournalApp/JournalApp.csproj b/JournalApp/JournalApp.csproj index e5fe59f..bc8c75a 100644 --- a/JournalApp/JournalApp.csproj +++ b/JournalApp/JournalApp.csproj @@ -50,7 +50,7 @@ - + @@ -59,7 +59,7 @@ - + diff --git a/JournalApp/MainPage.xaml b/JournalApp/MainPage.xaml index 2562bb7..2b6d45d 100644 --- a/JournalApp/MainPage.xaml +++ b/JournalApp/MainPage.xaml @@ -1,11 +1,11 @@ - + - + diff --git a/JournalApp/MauiProgram.cs b/JournalApp/MauiProgram.cs index 72ee352..6d32d93 100644 --- a/JournalApp/MauiProgram.cs +++ b/JournalApp/MauiProgram.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Maui; +using Microsoft.Maui.LifecycleEvents; using JournalApp.Data; namespace JournalApp; @@ -24,6 +25,12 @@ public static MauiApp CreateMauiApp() // Enable Blazor WebView. builder.Services.AddMauiBlazorWebView(); +#if ANDROID + // Android relaunches the activity for some configuration changes, like switching the navigation mode, and MAUI resets the system bar appearance each time it connects to one. + builder.ConfigureLifecycleEvents(events => events.AddAndroid(android => android.OnResume(_ => + IPlatformApplication.Current?.Services.GetService()?.ApplyPlatformTheme()))); +#endif + #if DEBUG // Add debugging tools and log output for development. builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/JournalApp/wwwroot/app.css b/JournalApp/wwwroot/app.css index 47602eb..7f92057 100644 --- a/JournalApp/wwwroot/app.css +++ b/JournalApp/wwwroot/app.css @@ -116,11 +116,13 @@ textarea, font-weight: 400; } +/* The web view draws under the transparent status bar, so the header extends up behind it and carries the surface tone there. */ .page-header { z-index: var(--mud-zindex-appbar); display: flex; flex-direction: column; padding: 0 !important; + padding-top: env(safe-area-inset-top, 0px) !important; margin: 0 !important; top: 0 !important; position: sticky !important; @@ -135,7 +137,7 @@ textarea, } /* On-scroll separation for the sticky header: a soft lift fades in once content passes underneath. */ -/* MD3 proper would retone the container instead, but that desyncs the natively colored status bar, so elevation stands in until edge-to-edge lands. */ +/* MD3 proper would retone the container instead; now that the header owns the status bar region that retone can follow it, so it is a candidate to swap in. */ @supports (animation-timeline: scroll()) { .page-header { animation: header-on-scroll linear both; @@ -157,7 +159,7 @@ textarea, max-width: 960px; margin: 0 auto !important; padding: 8px !important; - padding-bottom: 15vh !important; + padding-bottom: calc(15vh + env(safe-area-inset-bottom, 0px)) !important; animation: fadeInUp var(--ja-motion-spatial); } @@ -463,8 +465,14 @@ label.mud-switch.mud-disabled .mud-switch-span { } /* The keyboard overlays the WebView and shrinks the visual viewport; pinning the container to it glides dialogs out of the keyboard's way. */ +/* The container spans under the system bars as well, so it pads them out; the bottom inset is dropped while the keyboard covers the navigation bar. */ .mud-dialog-container { + box-sizing: border-box; height: var(--visible-height, 100%); + padding-top: env(safe-area-inset-top, 0px); + padding-left: env(safe-area-inset-left, 0px); + padding-right: env(safe-area-inset-right, 0px); + padding-bottom: max(0px, calc(env(safe-area-inset-bottom, 0px) - var(--keyboard-inset, 0px))); transition: height 250ms cubic-bezier(0.2, 0, 0, 1); } @@ -473,9 +481,16 @@ label.mud-switch.mud-disabled .mud-switch-span { max-height: calc(100% - 32px); } -/* Give the document scroll room so content can move up out from under the keyboard. */ +/* Give the document scroll room so content can move up out from under the keyboard, and keep it clear of a side cutout or bar in landscape. */ body { padding-bottom: var(--keyboard-inset, 0px); + padding-left: env(safe-area-inset-left, 0px); + padding-right: env(safe-area-inset-right, 0px); +} + +/* Bottom-anchored chrome floats above the navigation bar instead of under it. */ +.mud-snackbar-location-bottom-center { + bottom: calc(24px + env(safe-area-inset-bottom, 0px)); } /* M3 dialogs sit on surfaceContainerHigh with the 24px container inset, at elevation Level3 rather than MudBlazor's M2 elevation 24. */ @@ -695,30 +710,12 @@ a.list-group-title { display: none; left: 0; padding: 1.25rem; + padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px)); position: fixed; width: 100%; z-index: 1000; } -.status-bar-safe-area { - display: none; -} - -@supports (-webkit-touch-callout: none) { - .status-bar-safe-area { - display: flex; - position: sticky; - top: 0; - height: env(safe-area-inset-top); - width: 100%; - z-index: 1; - } - - .flex-column, .navbar-brand { - padding-left: env(safe-area-inset-left); - } -} - /* Android honors Remove animations under accessibility settings, so drop every entrance, transition and transform the app adds. */ /* The scroll-driven header lift stays: it is tied to scroll position rather than to time, and it is the only cue that content is passing underneath. */ @media (prefers-reduced-motion: reduce) { @@ -743,13 +740,13 @@ a.list-group-title { } @media (prefers-color-scheme: dark) { - .status-bar-safe-area, #splash-screen { + #splash-screen { background-color: #181215; } } @media (prefers-color-scheme: light) { - .status-bar-safe-area, #splash-screen { + #splash-screen { background-color: #FFF8F9; } } diff --git a/JournalApp/wwwroot/index.html b/JournalApp/wwwroot/index.html index 619a34a..113cf70 100644 --- a/JournalApp/wwwroot/index.html +++ b/JournalApp/wwwroot/index.html @@ -14,8 +14,6 @@ -
-

Getting things ready...

diff --git a/global.json b/global.json index f7772fe..3dbe096 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.300", + "version": "10.0.400", "rollForward": "latestPatch", "allowPrerelease": false } From ee66dc97efefd8423f5de993790a9a7f814b4527 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Fri, 4 Sep 2026 23:06:38 -0500 Subject: [PATCH 2/2] Keep older web views letterboxed inside themed bars Only WebView 144 and up forwards the system bar insets to CSS, so the page falls back to the Container safe area when the installed web view is older, and the window background now carries the surface tone so the transparent bars still blend behind a letterboxed page. Below Android 15 the toolkit's opaque status bar color already re-fits the decor, which is the intended fallback there and is now documented as such. --- JournalApp/Data/PreferenceService.cs | 11 +++++++++-- JournalApp/MainPage.xaml.cs | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/JournalApp/Data/PreferenceService.cs b/JournalApp/Data/PreferenceService.cs index 1c05642..22cb2b4 100644 --- a/JournalApp/Data/PreferenceService.cs +++ b/JournalApp/Data/PreferenceService.cs @@ -222,6 +222,8 @@ public void ApplyPlatformTheme() if (window is null) return; + var platformColor = Android.Graphics.Color.Rgb(surface.R, surface.G, surface.B); + // The web view draws under the transparent bars on Android 15 and up, so icon contrast is the only knob left and it has to follow the in-app theme. // MAUI guesses it once at startup from the theme's colorPrimary, which is a dark purple in both modes, so it always lands on light icons. var controller = AndroidX.Core.View.WindowCompat.GetInsetsController(window, window.DecorView); @@ -235,13 +237,18 @@ public void ApplyPlatformTheme() window.NavigationBarContrastEnforced = false; } - // Older versions still draw opaque bars, so tint them to the surface tone instead. + // The window itself is what shows through the transparent bars whenever the page is letterboxed, like on a web view too old to report the insets. + window.SetBackgroundDrawable(new Android.Graphics.Drawables.ColorDrawable(platformColor)); + + // Below Android 15 the bars are opaque, so tint them to the surface tone instead. + // Setting an opaque status bar color through the toolkit also makes the decor fit the system windows again, which letterboxes the page inside the bars. + // That is intentional there: those versions ship web views too old to pad for the bars themselves, and the themed opaque bars read the same as drawing under them. if (!OperatingSystem.IsAndroidVersionAtLeast(35)) { StatusBar.SetColor(background); StatusBar.SetStyle(IsDarkMode ? StatusBarStyle.LightContent : StatusBarStyle.DarkContent); #pragma warning disable CA1422 // Deprecated in API 35, which the surrounding check excludes. - window.SetNavigationBarColor(Android.Graphics.Color.Rgb(surface.R, surface.G, surface.B)); + window.SetNavigationBarColor(platformColor); #pragma warning restore CA1422 } #endif diff --git a/JournalApp/MainPage.xaml.cs b/JournalApp/MainPage.xaml.cs index 605f12f..2afb729 100644 --- a/JournalApp/MainPage.xaml.cs +++ b/JournalApp/MainPage.xaml.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Components.WebView; +using Microsoft.AspNetCore.Components.WebView; namespace JournalApp; @@ -7,7 +7,21 @@ public partial class MainPage : ContentPage public MainPage() { InitializeComponent(); + +#if ANDROID + // Only WebView 144 and up forward the system bar insets to CSS, so an older one keeps the page letterboxed inside the bars instead of drawing under them blind. + if (!WebViewReportsSafeArea()) + SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.Container); +#endif + } + +#if ANDROID + private static bool WebViewReportsSafeArea() + { + var version = Android.Webkit.WebView.CurrentWebViewPackage?.VersionName; + return version is not null && int.TryParse(version.Split('.')[0], out var major) && major >= 144; } +#endif // The native WebView paints white before Blazor's first frame, which flashes hard against a dark splash, so let the themed page background show through instead. private void OnBlazorWebViewInitialized(object sender, BlazorWebViewInitializedEventArgs e)