Android 16 Shrinks WebView Viewports by 40dp; a Two-Layer Fix Stops the Gap
Android 16 WebView Page Viewport Shrinking Issue Troubleshooting
Project: Beast Chess (Capacitor 8 + HTML single-page game, targetSdk 36) Date: 2026-08-02 | Conclusion: Fixed (v1.10)
1. Problem Phenomenon
When running the game app on a phone, a solid color band of about 40dp appears at the top of the screen (notification bar/punch-hole area), and the game UI shifts downward as a whole:
- The main menu title does not stick to the top, leaving a solid dark green background band at the top.
- The top bar of the game page (menu button/title/sound button) is also shifted downward as a whole.
- Status bar icons are not visible (immersive hiding is in effect), and the color band only contains the background color.
- It looks awkward and wastes screen space.
2. Operating Environment
| Item | Value |
|---|---|
| Phone | OPPO/OnePlus PHY110, Android 16.0.9.400 (CN01), punch-hole screen |
| Screen | 1440×3168 physical pixels, dpr = 4 → CSS viewport 360×792 |
| Tech Stack | Capacitor 8.5, Android WebView (Chromium), targetSdk 36, minSdk 24 |
| App | Full-screen immersive game (native hidden system bars + edge-to-edge) |
3. Troubleshooting Process (Detours Taken)
3.1 Configurations Confirmed Effective (but not the root cause)
| Configuration | Conclusion |
|---|---|
WindowCompat.setDecorFitsSystemWindows(window, false) |
Window is edge-to-edge ✅ |
System bars fully transparent + immersive hiding (WindowInsetsControllerCompat.hide) |
Status bar is indeed hidden (no icons) ✅ |
windowLayoutInDisplayCutoutMode=always (theme + Manifest + runtime enforcement) |
Verified compiled value = 3 (always) using aapt2 ✅ |
viewport-fit=cover meta tag |
Present ✅ |
All of the above are correct, but the color band persists — indicating the problem is not at these levels.
3.2 Ineffective Solutions Tried One by One
- Changing CSS padding (menu 8vh →
max(12px, env())→ 12px): Only moved content within the viewport, the color band remained. ❌ - Cutout mode shortEdges → always (three places: values / values-v35 / Manifest / runtime): Compiled value confirmed = always, ineffective. ❌
- Intercepting insets at the WebView layer (
setOnApplyWindowInsetsListenerconsuming statusBars+cutout): Ineffective — Chrome does not go through dispatch updates. ❌ - Intercepting at WebView layer then feeding zeroed insets back to default handling (
v.onApplyWindowInsets(zeroed)): Ineffective — Chrome directly reads window root insets. ❌ FLAG_LAYOUT_NO_LIMITS(window ignores all inset layouts): Ineffective. ❌
3.3 Key Turning Point: Page Diagnostic Overlay
Added a position:fixed;top:0 overlay in the page to print viewport values (inline in page script):
innerH=752 innerW=360 // window.innerHeight / innerWidth
clientH=752 clientW=360 // documentElement.clientHeight
visualVp.offsetTop=0 vpH=752 // visualViewport
body.top=0.0 body.h=752.0 // body position relative to viewport
menu.top=0.0 menu.paddingTop=12px
safe-inset-top=0 // env(safe-area-inset-top)
dpr=4
Conclusion: Page viewport = 360×752, while screen = 360×792 — the viewport is exactly 40dp shorter than the screen.
The overlay itself, with top:0, appears at 160 physical pixels, proving that the positioning reference (viewport) for fixed elements is shifted downward by 40dp as a whole.
⚠️ Lesson: Initially miscalculated 160px as 53dp based on dpr=3, but actually dpr=4 → 40dp. Confirm
devicePixelRatiobefore converting!
3.4 Key Turning Point: Red-Blue Discriminant Color Experiment
Set WebView background to red and window background to blue (v1.9), the color of the top band reveals "which layer is exposed":
Result: The color band is dark green
(13,26,17) (the WebView background color #0d1a12 configured by Capacitor), not red nor blue.
→ The WebView view itself fills the screen; it is Chrome that shrinks the page viewport according to the window root insets (top safe area 40dp).
4. Root Cause Analysis
- After Android 15/16 enforces edge-to-edge, the WebView page viewport shrinks according to window root insets — the top safe area (status bar + punch hole) of 40dp is counted outside the viewport, so the page can only layout within the lower 752dp;
viewport-fit=coverdoes not take effect on this WebView (known Chromium WebView safe-area/viewport handling issues, fixed only in some versions);- Chrome reads window root insets (
getRootWindowInsets), not the dispatch chain — so attaching inset listeners on WebView, consuming insets, or feeding back zeroed values are all ineffective; FLAG_LAYOUT_NO_LIMITSonly affects window layout, not Chrome's reading of root insets.
5. Final Solution (v1.10, Dual Safeguard)
5.1 Native: Decor Root Node Consumes System Bar Insets (Root Fix)
// Called in both MainActivity.onCreate and onWindowFocusChanged
private void consumeSystemBarsAtRoot() {
getWindow().getDecorView().setOnApplyWindowInsetsListener((v, insets) ->
insets.consumeSystemWindowInsets());
}
Principle: Consume insets at the root node → WebView receives zero insets → Chrome thinks there are no system bars → viewport fills the screen. Note that it must be attached to decor (root); attaching to WebView is ineffective (see 3.2).
5.2 Webpage JS Offset Compensation (Fallback, Ensures Visual Correctness)
(function(){
try{
const fullH = Math.max((window.screen && screen.height)||0,
window.outerHeight || 0,
window.innerHeight || 0);
const off = fullH - window.innerHeight; // This device = 792-752 = 40
if (off > 0) {
const els = document.querySelectorAll('.screen,.overlay');
for (let i = 0; i < els.length; i++) els[i].style.top = (-off) + 'px';
}
}catch(e){}
})();
Principle: Fixed full-screen layers are positioned relative to the viewport; since the viewport is 40dp shorter, they are shifted down by 40dp as a whole. Detect the offset and shift the full-screen layers up, and the content fills the screen top. When the native fix takes effect, off = 0, and this snippet automatically does nothing.
Note: Only compensate full-screen layers (containers with
position:fixed;inset:0). Content arranged inside the container using padding will move up with the container as a whole, no need to handle element by element.
6. Diagnostic Methodology (Reusable)
- Increment versionName/versionCode: Allows users to confirm which version is installed in "Settings → Apps", avoiding the illusion of "changes not taking effect".
- Page diagnostic overlay: Print
innerHeight / clientHeight / visualViewport.offsetTop / body.getBoundingClientRect().top / env(safe-area-inset-top) / dpr, capturing all key values in one screenshot. - Confirm dpr before converting physical pixels: 1440px calculated as 480dp at dpr=3, but actually 360dp at dpr=4 — the difference between calculating 53px and 40px directly determines the fix direction.
- Discriminant color experiment: Set WebView background and window background to different colors, see which layer's color "leaks out" in the color band, confirming "who covers whom" with one image.
- Distinguish "view pushed down" vs "viewport shrunk":
- Color band = window background color → WebView view is pushed down (decor padding issue)
- Color band = WebView surface color → WebView is full-screen, Chrome shrunk the page viewport
- aapt2 verify compilation results:
aapt2 dump xmltree/dump resourcesconfirms attributes are actually compiled into the package (e.g., cutout mode always=3), ruling out "changed but not compiled in".
7. Experience Summary
- Full-screen WebView adaptation check order: edge-to-edge → cutout mode → root insets consumption → page fallback;
- Do not intercept insets at the WebView layer expecting Chrome to take effect — Chrome reads window root insets;
- Each change requires users to reinstall for testing, which is costly, so native root fix + page fallback dual safeguard packaged together avoids multiple rounds of back-and-forth;
- Related known issues: Chromium WebView safe-area handling (Tauri issue #14240), Android 16 forced edge-to-edge (WebView edge-to-edge adaptation guide), punch-hole screen adaptation (Notch/Punch-hole screen adaptation details).
8. Related Fixes (Completed in Same Session)
| Problem | Fix |
|---|---|
| Top color band (this article) | v1.10: decor root consumes insets + JS offset compensation |
| No explanation for stalemate win | v1.3: Result popup shows "Opponent has no moves (stalemate), you win!" |
| Icon corners cropped too much | v1.2: Original image scaled to 62% of adaptive canvas on dark green background, visibility 35%→87% |
Post-fix effect: The main menu title starts from the very top of the screen, with the punch hole pressing against the upper edge of the title; the game page top bar also sticks to the top.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
This debugging process is textbook-level 👏 I fell into the exact same pit last year working on an H5 container project: after Android 15/16 forced edge-to-edge, the WebView viewport was shrunk by the window root insets. I also tried attaching inset listeners on the WebView, consuming them, and feeding them back—went through the whole cycle to no avail. The deepest lesson is exactly what the article says—Chrome reads the root window insets and doesn't go through the dispatch chain, so intercepting at the child View layer is futile. Two small tricks I've used myself: 1) Before fixing, use Layout Inspector or adb shell dumpsys window to confirm the source of the insets, to avoid repeatedly misdiagnosing it as a punch-hole config issue; 2) The trick of consuming insets at the decor root node works great for full-screen game pages, but if the Activity has a custom top bar that relies on systemBars padding, it'll get hit too—recommend enabling it only on full-screen pages. Also, the lesson about 'confirm dpr before converting' really hits home, I've made that mistake too haha. Thanks for sharing, bookmarked!