跪拜 Guibai
← Back to the summary

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:

Screenshot_2026-08-02-14-27-31-46_c4450006be83fbd20e05dc015aaaccb3.jpg

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

  1. Changing CSS padding (menu 8vh → max(12px, env()) → 12px): Only moved content within the viewport, the color band remained. ❌
  2. Cutout mode shortEdges → always (three places: values / values-v35 / Manifest / runtime): Compiled value confirmed = always, ineffective. ❌
  3. Intercepting insets at the WebView layer (setOnApplyWindowInsetsListener consuming statusBars+cutout): Ineffective — Chrome does not go through dispatch updates. ❌
  4. Intercepting at WebView layer then feeding zeroed insets back to default handling (v.onApplyWindowInsets(zeroed)): Ineffective — Chrome directly reads window root insets. ❌
  5. 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 devicePixelRatio before 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":

Screenshot_2026-08-02-16-11-25-74_c4450006be83fbd20e05dc015aaaccb3.jpg

Screenshot_2026-08-02-16-11-29-17_c4450006be83fbd20e05dc015aaaccb3.jpg 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

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)

  1. Increment versionName/versionCode: Allows users to confirm which version is installed in "Settings → Apps", avoiding the illusion of "changes not taking effect".
  2. Page diagnostic overlay: Print innerHeight / clientHeight / visualViewport.offsetTop / body.getBoundingClientRect().top / env(safe-area-inset-top) / dpr, capturing all key values in one screenshot.
  3. 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.
  4. 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.
  5. 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
  6. aapt2 verify compilation results: aapt2 dump xmltree / dump resources confirms attributes are actually compiled into the package (e.g., cutout mode always=3), ruling out "changed but not compiled in".

7. Experience Summary

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.

Comments

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!