Skip to content

Commit 87ac419

Browse files
myieyeclaude
andcommitted
Embrace Android 15+ edge-to-edge instead of opting out
Android 15 (SDK 35) enforces edge-to-edge layouts and the old `OptOutEdgeToEdgeEnforcement` attribute in styles-v35 was causing `env(safe-area-inset-*)` to return 0 inside the BlazorWebView. Replace the opt-out with a new `AndroidEdgeToEdgeInsets` class that listens to `WindowInsetsCompat` callbacks and injects the real inset values as CSS custom properties (`--safe-area-inset-*`) into the WebView via JavaScript. MainActivity is updated to wire up the listener instead of relying on the deprecated opt-out style. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e362dd4 commit 87ac419

4 files changed

Lines changed: 158 additions & 8 deletions

File tree

backend/FwLite/FwLiteMaui/MainPage.xaml.Android.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ private partial void BlazorWebViewInitialized(object? sender, BlazorWebViewIniti
3636
? (e.WebView.WebChromeClient ?? new Android.Webkit.WebChromeClient())
3737
: new Android.Webkit.WebChromeClient();
3838
e.WebView.SetWebChromeClient(new PermissionManagingBlazorWebChromeClient(baseClient, activity));
39+
40+
// Push real system-bar insets into the WebView as CSS custom properties so
41+
// edge-to-edge layouts (Android 15+) don't render under the status bar / gesture nav.
42+
AndroidEdgeToEdgeInsets.Install(e.WebView);
3943
}
4044

4145
private partial void BlazorWebViewOnUrlLoading(object? sender, UrlLoadingEventArgs e)
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#if ANDROID
2+
using Android.Util;
3+
using AndroidX.Core.View;
4+
using AView = Android.Views.View;
5+
using AWebView = Android.Webkit.WebView;
6+
using AInsets = AndroidX.Core.Graphics.Insets;
7+
8+
namespace FwLiteMaui.Platforms.Android;
9+
10+
// Wires up edge-to-edge support for the Blazor WebView on Android 15+ (SDK 35).
11+
// Reads the real system-bar insets (status bar at top, gesture-nav / 3-button nav at bottom)
12+
// and writes them onto the WebView's root element as two tiers of CSS custom properties:
13+
//
14+
// --android-safe-{top,right,bottom,left} -- "chrome" inset, SystemBars + DisplayCutout
15+
// only. Used for general page chrome (status bar / nav bar / notch clearance) where
16+
// Material Design's standard safe area is appropriate. This is what .app pads with.
17+
//
18+
// --android-wide-{top,right,bottom,left} -- "wide" inset, chrome union with
19+
// MandatorySystemGestures + TappableElement. Used for FLOATING elements (FABs,
20+
// toasters) that need extra clearance from the gesture-nav tappable region.
21+
// Without this split, the gesture-area reservation bleeds into every page chrome
22+
// consumer and over-reserves visible space.
23+
//
24+
// Also writes --android-ime-bottom for the soft keyboard, kept separate from system bars
25+
// so chrome surfaces (sidebars, drawers) can ignore it while scrollable content can shrink.
26+
// CSS consumes them with sensible env() fallbacks,
27+
// e.g. var(--android-safe-bottom, env(safe-area-inset-bottom)).
28+
//
29+
// We do this in JS rather than relying on CSS env(safe-area-inset-*) because that has been
30+
// observed to report 0 inside the Android System WebView even with viewport-fit=cover.
31+
//
32+
// We must NOT replace the WebView's WebViewClient — Blazor's own client intercepts
33+
// https://0.0.0.0/ requests and serves the embedded app. Anything that breaks that
34+
// chain bricks the WebView. The insets-listener alone is enough; CSS vars set on
35+
// document.documentElement.style survive Blazor's DOM swap.
36+
internal static class AndroidEdgeToEdgeInsets
37+
{
38+
public static void Install(AWebView webView)
39+
{
40+
// Belt-and-suspenders: ensure the WebView doesn't auto-pad behind our back on
41+
// OEM AppCompat themes that flip fitsSystemWindows=true via parent inheritance.
42+
// We're handling insets ourselves via the listener below.
43+
webView.SetFitsSystemWindows(false);
44+
ViewCompat.SetOnApplyWindowInsetsListener(webView, new InsetsListener(webView));
45+
ViewCompat.RequestApplyInsets(webView);
46+
}
47+
48+
private sealed class InsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener
49+
{
50+
private readonly AWebView _webView;
51+
private AInsets _lastChrome = AInsets.None!;
52+
private AInsets _lastWide = AInsets.None!;
53+
private int _lastImeBottom = -1;
54+
55+
public InsetsListener(AWebView webView) => _webView = webView;
56+
57+
public WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets)
58+
{
59+
if (insets is null) return insets;
60+
// Two-tier insets:
61+
// chrome = SystemBars + DisplayCutout. Material Design's standard "safe area"
62+
// for general page chrome. What .app pads with.
63+
// wide = chrome + MandatorySystemGestures + TappableElement. For floating
64+
// elements (FABs, toasters) that must clear the gesture-nav tappable region
65+
// which can be wider than the visual handle reported in SystemBars.
66+
//
67+
// IME is read separately (not unioned) so chrome surfaces can keep using the
68+
// system-only height while scrollable content shrinks for the keyboard.
69+
// WindowInsetsCompat.Type.Ime() backports keyboard dispatch on pre-API-30.
70+
var chromeMask = WindowInsetsCompat.Type.SystemBars()
71+
| WindowInsetsCompat.Type.DisplayCutout();
72+
var wideMask = chromeMask
73+
| WindowInsetsCompat.Type.MandatorySystemGestures()
74+
| WindowInsetsCompat.Type.TappableElement();
75+
var chrome = insets.GetInsets(chromeMask);
76+
var wide = insets.GetInsets(wideMask);
77+
var sysBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
78+
var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime());
79+
// Standard reduction: subtract nav-bar bottom so we don't double-count when
80+
// the IME sits behind an opaque nav bar.
81+
var imeBottom = Math.Max(0, (ime?.Bottom ?? 0) - (sysBars?.Bottom ?? 0));
82+
var chromeChanged = chrome is not null && !chrome.Equals(_lastChrome);
83+
var wideChanged = wide is not null && !wide.Equals(_lastWide);
84+
var imeChanged = imeBottom != _lastImeBottom;
85+
if (chromeChanged || wideChanged || imeChanged)
86+
{
87+
LogBreakdown(insets, imeBottom);
88+
if (chrome is not null) _lastChrome = chrome;
89+
if (wide is not null) _lastWide = wide;
90+
_lastImeBottom = imeBottom;
91+
Apply(_webView, _lastChrome, _lastWide, imeBottom);
92+
}
93+
// Don't consume - let MAUI's own listeners (if any) still see the insets.
94+
return insets;
95+
}
96+
97+
private static void LogBreakdown(WindowInsetsCompat insets, int imeReducedBottom)
98+
{
99+
// Diagnostic: surface the individual inset categories so we can see on-device
100+
// (`adb logcat -s FwLiteInsets`) why the bottom value is what it is.
101+
var sb = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
102+
var cut = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout());
103+
var gest = insets.GetInsets(WindowInsetsCompat.Type.MandatorySystemGestures());
104+
var tap = insets.GetInsets(WindowInsetsCompat.Type.TappableElement());
105+
var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime());
106+
Log.Debug("FwLiteInsets",
107+
$"SystemBars b={sb?.Bottom} t={sb?.Top}; Cutout b={cut?.Bottom} t={cut?.Top}; " +
108+
$"MandatoryGestures b={gest?.Bottom}; Tappable b={tap?.Bottom}; " +
109+
$"Ime b={ime?.Bottom} (reduced={imeReducedBottom})");
110+
}
111+
}
112+
113+
private static void Apply(AWebView webView, AInsets chrome, AInsets wide, int imeBottomPx)
114+
{
115+
var density = webView.Resources?.DisplayMetrics?.Density ?? 1f;
116+
// The WebView's viewport is measured in CSS pixels; system insets come back in physical px.
117+
// Use ceiling so we never under-reserve by a sub-CSS-pixel - truncation could leak
118+
// up to ~1px of content under the bar at non-integer densities (e.g. 2.75x).
119+
var cTop = (int)Math.Ceiling(chrome.Top / density);
120+
var cRight = (int)Math.Ceiling(chrome.Right / density);
121+
var cBottom = (int)Math.Ceiling(chrome.Bottom / density);
122+
var cLeft = (int)Math.Ceiling(chrome.Left / density);
123+
var wTop = (int)Math.Ceiling(wide.Top / density);
124+
var wRight = (int)Math.Ceiling(wide.Right / density);
125+
var wBottom = (int)Math.Ceiling(wide.Bottom / density);
126+
var wLeft = (int)Math.Ceiling(wide.Left / density);
127+
var ime = (int)Math.Ceiling(imeBottomPx / density);
128+
Log.Debug("FwLiteInsets",
129+
$"Applied CSS px: chrome=({cTop},{cRight},{cBottom},{cLeft}) " +
130+
$"wide=({wTop},{wRight},{wBottom},{wLeft}) ime={ime} density={density}");
131+
var js = $$"""
132+
(function() {
133+
var s = document.documentElement.style;
134+
s.setProperty('--android-safe-top', '{{cTop}}px');
135+
s.setProperty('--android-safe-right', '{{cRight}}px');
136+
s.setProperty('--android-safe-bottom', '{{cBottom}}px');
137+
s.setProperty('--android-safe-left', '{{cLeft}}px');
138+
s.setProperty('--android-wide-top', '{{wTop}}px');
139+
s.setProperty('--android-wide-right', '{{wRight}}px');
140+
s.setProperty('--android-wide-bottom', '{{wBottom}}px');
141+
s.setProperty('--android-wide-left', '{{wLeft}}px');
142+
s.setProperty('--android-ime-bottom', '{{ime}}px');
143+
})();
144+
""";
145+
webView.Post(() => webView.EvaluateJavascript(js, null));
146+
}
147+
}
148+
#endif

backend/FwLite/FwLiteMaui/Platforms/Android/MainActivity.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ public class MainActivity : MauiAppCompatActivity
1616
{
1717
protected override void OnCreate(Bundle? savedInstanceState)
1818
{
19-
//custom style, declared in Android/Resources/values/styles.xml, values-v35 is used based on the android version
20-
Theme?.ApplyStyle(Resource.Style.OptOutEdgeToEdgeEnforcement, force: false);
19+
// Android 15+ (SDK 35) enforces edge-to-edge by default. Rather than opting out
20+
// (which has historically been fragile - the attribute is deprecated on Android 16+
21+
// and CSS env(safe-area-inset-*) inside the BlazorWebView has been observed to
22+
// return 0 even with viewport-fit=cover) we embrace edge-to-edge and propagate
23+
// the real system-bar insets to the WebView as CSS custom properties via
24+
// AndroidEdgeToEdgeInsets (installed when the BlazorWebView is initialized).
2125
base.OnCreate(savedInstanceState);
2226
}
2327

backend/FwLite/FwLiteMaui/Platforms/Android/Resources/values-v35/styles.xml

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)