Render between the system bars deterministically; delete my inset bridge

The app sometimes drew under the status and gesture bars and sometimes
between them. Cause: Capacitor 8 ships a SystemBars plugin that already
handles insets, from a listener on the decor view, and my MainActivity
had installed another one on the WebView. Different views, so they
stacked rather than replaced each other and the result depended on which
fired last.

SystemBars switches on the viewport meta tag. With viewport-fit=cover it
passes insets through once onPageCommitVisible has checked the tag, decor
padding drops to 0 and the WebView grows 735 -> 820, leaving the page to
place itself from injected CSS vars. Without it the plugin pads the decor
view and the WebView is laid out between the bars already.

So sync-www.sh now strips viewport-fit=cover from the generated www/
(patch 5), MainActivity is an empty BridgeActivity, native.css and
native.js no longer touch insets, and bar style moves to
capacitor.config.json. No native->web handshake remains, so there is
nothing to race and no launch-time layout jump.

Verified identical across 3 cold launches, 2 resumes and an in-place
reload: h=735 padTop=8px overflowBy=0, panel 485px, pad rows 44.8px.

Costs no space: 820-45-114 and 735-8-66 both leave 661px. The bars take
the same room either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 23:26:31 +03:00
parent 5f9d89d9fb
commit 066b9e8c34
6 changed files with 83 additions and 128 deletions

View File

@@ -1,81 +1,17 @@
package dev.ncode.sp404layers;
import android.os.Bundle;
import android.view.View;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
/**
* Android 15+ forces edge-to-edge for apps targeting SDK 35, and Android 16
* removes the windowOptOutEdgeToEdgeEnforcement escape hatch entirely at
* targetSdk 36 (which is Capacitor 8's default). An Android WebView does not
* populate env(safe-area-inset-*), so the web layer has no way to discover the
* system bar geometry on its own.
* Deliberately empty.
*
* This bridges the gap: the app draws behind the bars, and the real insets are
* pushed into CSS custom properties that native.css consumes.
* An earlier version installed its own OnApplyWindowInsetsListener on the
* WebView and pushed the insets into CSS custom properties. That was written
* before I found that Capacitor 8 ships a SystemBars plugin which already does
* this, from a listener on the decor view.
*
* The two stacked rather than replaced each other, and the app ended up drawing
* under the system bars at random. See the "Device notes" section of CLAUDE.md.
* Bar styling and inset handling are now configured in capacitor.config.json.
*/
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Draw behind the system bars.
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
// The UI is dark, so the status/nav bar icons must be light.
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
}
final View webView = getBridge().getWebView();
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, windowInsets) -> {
Insets bars = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
| WindowInsetsCompat.Type.displayCutout());
// CSS pixels are density-independent; WindowInsets are physical px.
float density = getResources().getDisplayMetrics().density;
final int top = Math.round(bars.top / density);
final int right = Math.round(bars.right / density);
final int bottom = Math.round(bars.bottom / density);
final int left = Math.round(bars.left / density);
// Returns "1" once applied, "0" while native.js has not loaded yet.
final String js = String.format(
java.util.Locale.US,
"(window.__applyInsets?(window.__applyInsets(%d,%d,%d,%d),1):0)",
top, right, bottom, left);
// Insets usually arrive before the page has run any JS, so retry on
// a bounded schedule instead of firing once into the void.
applyWithRetry(js, 0);
// Do not consume: other listeners (and the IME) may still need these.
return windowInsets;
});
}
private static final int INSET_RETRY_LIMIT = 25; // ~5s at 200ms
private static final long INSET_RETRY_DELAY_MS = 200L;
/** Pushes the inset JS, retrying until native.js has defined __applyInsets. */
private void applyWithRetry(final String js, final int attempt) {
final View webView = getBridge().getWebView();
webView.post(() -> getBridge().getWebView().evaluateJavascript(js, value -> {
boolean applied = value != null && value.contains("1");
if (!applied && attempt < INSET_RETRY_LIMIT) {
webView.postDelayed(() -> applyWithRetry(js, attempt + 1), INSET_RETRY_DELAY_MS);
}
}));
}
}
public class MainActivity extends BridgeActivity {}