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:
46
CLAUDE.md
46
CLAUDE.md
@@ -117,16 +117,37 @@ code about to be rewritten in React anyway.
|
||||
Explore → go to Explore; otherwise exit. Registering a listener suppresses
|
||||
Capacitor's default history navigation.
|
||||
|
||||
**Edge-to-edge is handled natively, not avoided.** Android 16 is API 36 and
|
||||
Capacitor 8 targets SDK 36, which is exactly the combination where
|
||||
`windowOptOutEdgeToEdgeEnforcement` stops working. An Android WebView never
|
||||
populates `env(safe-area-inset-*)`, so `MainActivity` installs an
|
||||
`OnApplyWindowInsetsListener`, converts px → dp, and pushes the values into
|
||||
`--ins-t/r/b/l` via `window.__applyInsets()`. `native.css` re-points the
|
||||
reference's `--sab` at `--ins-b`, so `.bnav` and `.sheet` need no changes.
|
||||
**System bars: let Capacitor do it, by dropping `viewport-fit=cover`.** The app
|
||||
renders between the status and gesture bars, always.
|
||||
|
||||
The push retries on a bounded schedule (25 × 200ms) because insets typically
|
||||
arrive before the page has run any JS.
|
||||
Capacitor 8 ships a `SystemBars` plugin that owns inset handling from a listener
|
||||
on the **decor view**. Its behaviour hinges on the viewport meta tag:
|
||||
|
||||
- **with `viewport-fit=cover`** (what `reference/` has), `shouldPassthroughInsets`
|
||||
becomes true once `onPageCommitVisible` has checked the tag and called
|
||||
`requestApplyInsets()`. Decor padding drops to 0, the WebView grows from 735 to
|
||||
820, and the page must place itself from injected CSS variables.
|
||||
- **without it**, the plugin pads the decor view instead. The WebView is 735px
|
||||
and already sits between the bars; `env(safe-area-inset-*)` and the
|
||||
reference's `--sab` are 0, and `.bnav` and `.sheet` are correct as written.
|
||||
|
||||
So `sync-www.sh` strips `viewport-fit=cover` from the generated `www/` (patch 5).
|
||||
No native→web handshake exists, so there is nothing to race and no launch-time
|
||||
layout jump. Verified identical across cold launch, resume and in-place reload:
|
||||
`h=735 padTop=8px overflowBy=0`.
|
||||
|
||||
**This costs no space.** 820 − 45 − 114 and 735 − 8 − 66 both leave 661px, because
|
||||
the bars take the same room whether the WebView is padded or draws underneath.
|
||||
Reclaiming that 85px would mean *hiding* the bars, which is a different choice.
|
||||
|
||||
An earlier version did the opposite: `MainActivity` installed its own
|
||||
`OnApplyWindowInsetsListener` on the WebView and pushed `--ins-t/r/b/l` through
|
||||
`window.__applyInsets()`, with a 25 × 200ms retry. That was written without
|
||||
realising Capacitor already had a plugin for this. The two listeners were on
|
||||
different views, so they stacked rather than replaced each other, and the app
|
||||
drew under the bars at random depending on which fired last. `MainActivity` is
|
||||
now empty and bar styling is configured in `capacitor.config.json`
|
||||
(`SystemBars.style: DARK`).
|
||||
|
||||
**No community plugins.** Only first-party: `@capacitor/core`, `/cli`,
|
||||
`/android`, `/haptics`, `/preferences`, `/app`.
|
||||
@@ -176,9 +197,14 @@ function rewrites `#strip` wholesale.
|
||||
|
||||
`cap sync` does not regenerate these — keep them when recreating `android/`:
|
||||
|
||||
- `android/app/src/main/java/dev/ncode/sp404layers/MainActivity.java` — inset bridge
|
||||
- `android/app/src/main/AndroidManifest.xml` — `android:screenOrientation="portrait"`
|
||||
|
||||
That is the only one. `MainActivity.java` is deliberately an empty
|
||||
`BridgeActivity`: everything it used to do is either configured in
|
||||
`capacitor.config.json` or handled by Capacitor's own `SystemBars` plugin. If
|
||||
you find yourself adding an inset listener there, read the system-bars decision
|
||||
above first — that path has already been tried and reverted.
|
||||
|
||||
## Device notes (TCL 50 PRO NXTPAPER 5G, Android 16 / API 36)
|
||||
|
||||
**adb needs a udev rule.** The phone is USB `1bbb:0c01`. Ubuntu's
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -8,5 +8,11 @@
|
||||
},
|
||||
"server": {
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"plugins": {
|
||||
"SystemBars": {
|
||||
"style": "DARK",
|
||||
"insetsHandling": "css"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,45 +2,23 @@
|
||||
reference/ is never edited; sync-www.sh injects this as the last stylesheet
|
||||
in <head>, so on equal specificity these rules win. */
|
||||
|
||||
:root{
|
||||
/* Filled in from Android WindowInsets by MainActivity via __applyInsets().
|
||||
The env() defaults keep the page correct in a plain desktop browser, where
|
||||
env() works and the native bridge is absent. */
|
||||
--ins-t: env(safe-area-inset-top, 0px);
|
||||
--ins-b: env(safe-area-inset-bottom, 0px);
|
||||
--ins-l: env(safe-area-inset-left, 0px);
|
||||
--ins-r: env(safe-area-inset-right, 0px);
|
||||
/* No inset handling here on purpose.
|
||||
|
||||
/* The reference derives --sab from env(safe-area-inset-bottom), which is
|
||||
always 0px inside an Android WebView. Re-point it at the real inset.
|
||||
.bnav and .sheet both key off --sab, so they need no further changes. */
|
||||
--sab: var(--ins-b);
|
||||
}
|
||||
sync-www.sh strips viewport-fit=cover from the generated www/, so Capacitor's
|
||||
SystemBars plugin pads the decor view and the WebView is laid out between the
|
||||
status and gesture bars already. env(safe-area-inset-*) is therefore 0, the
|
||||
reference's --sab resolves to 0, and .bnav and .sheet are correct as written.
|
||||
|
||||
An earlier version mapped --sab onto values pushed from MainActivity. That
|
||||
raced with Capacitor's own listener - see CLAUDE.md. */
|
||||
|
||||
@media (max-width:820px){
|
||||
/* The reference sets `padding: 8px 8px calc(var(--nav) + var(--sab) + 8px)`.
|
||||
Bottom already accounts for --sab; add the top and side insets so the unit
|
||||
panel clears the status bar and any display cutout. */
|
||||
.app{
|
||||
padding-top: calc(8px + var(--ins-t));
|
||||
padding-left: calc(8px + var(--ins-l));
|
||||
padding-right: calc(8px + var(--ins-r));
|
||||
}
|
||||
|
||||
/* Bottom nav is position:fixed and spans the full width, so it needs the
|
||||
side insets itself. Its height/padding-bottom already follow --sab. */
|
||||
.bnav{
|
||||
padding-left: var(--ins-l);
|
||||
padding-right: var(--ins-r);
|
||||
}
|
||||
|
||||
/* ---------- strip ergonomics (Phase 1 UI fix) ----------
|
||||
The reference spaces its two chip rows with `#strip .chips + .chips`, but
|
||||
on phones the .lbl heading between them is display:none and still in the
|
||||
DOM, so the adjacent-sibling selector never matches and the rows sit flush
|
||||
(measured: 0px gap). native.js collapses the 7 context chips into the
|
||||
single .ctxbtn below, leaving one chip row with real touch targets.
|
||||
Phase 2 must carry this - see CLAUDE.md. */
|
||||
(measured: 0px gap). native.js collapses both rows into the two pickers
|
||||
below. Phase 2 must carry this - see CLAUDE.md. */
|
||||
|
||||
/* Mode and layer pickers share one row: only one layer is ever active, so
|
||||
neither needs a scrolling row of its own. One 44px row instead of two gives
|
||||
@@ -73,8 +51,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Context picker rows inside the sheet. Uses CTXHINT from the reference, which
|
||||
the phone layout otherwise never shows. */
|
||||
/* Mode and layer rows inside the sheet. The mode rows use CTXHINT from the
|
||||
reference, which the phone layout otherwise never shows. */
|
||||
.ctxopt{
|
||||
display: block; width: 100%; text-align: left; color: inherit;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||
|
||||
@@ -16,15 +16,10 @@
|
||||
var cap = window.Capacitor;
|
||||
var P = (cap && cap.Plugins) || {};
|
||||
|
||||
/* Called from MainActivity's window-inset listener. Defined unconditionally
|
||||
so the native side never has to feature-detect it. Values arrive in dp. */
|
||||
window.__applyInsets = function (top, right, bottom, left) {
|
||||
var s = document.documentElement.style;
|
||||
s.setProperty("--ins-t", top + "px");
|
||||
s.setProperty("--ins-r", right + "px");
|
||||
s.setProperty("--ins-b", bottom + "px");
|
||||
s.setProperty("--ins-l", left + "px");
|
||||
};
|
||||
/* Insets are deliberately not handled here. sync-www.sh strips
|
||||
viewport-fit=cover, so Capacitor's SystemBars plugin pads the decor view
|
||||
and the WebView is already laid out between the system bars. An earlier
|
||||
__applyInsets bridge raced with that plugin - see CLAUDE.md. */
|
||||
|
||||
if (!(cap && cap.isNativePlatform && cap.isNativePlatform())) return;
|
||||
|
||||
|
||||
@@ -51,8 +51,22 @@ html = patch(html, "</head>",
|
||||
html = patch(html, "</body>",
|
||||
'<script src="native.js"></script>\n</body>', "body close")
|
||||
|
||||
# 5. drop viewport-fit=cover.
|
||||
#
|
||||
# Capacitor's SystemBars plugin switches behaviour on this exact string. With
|
||||
# it, shouldPassthroughInsets becomes true once onPageCommitVisible has checked
|
||||
# the meta tag, decor padding drops to 0, the WebView grows from 735 to 820 and
|
||||
# the page must position itself from injected CSS variables - a native->web
|
||||
# handshake that raced, so the app sometimes drew under the status and gesture
|
||||
# bars. Without it the plugin pads the decor view instead and the page is always
|
||||
# laid out between the bars, with no handshake and no launch-time jump.
|
||||
#
|
||||
# The web build keeps the cover behaviour; this only applies to the APK.
|
||||
html = patch(html, 'content="width=device-width,initial-scale=1,viewport-fit=cover"',
|
||||
'content="width=device-width,initial-scale=1"', "viewport-fit=cover")
|
||||
|
||||
open(path, "w", encoding="utf-8").write(html)
|
||||
print("sync-www: patched index.html (4/4 anchors matched)")
|
||||
print("sync-www: patched index.html (5/5 anchors matched)")
|
||||
PY
|
||||
|
||||
echo "sync-www: $OUT/ built from $SRC/"
|
||||
|
||||
Reference in New Issue
Block a user