一個滿版容器要同時滿足四件事:填滿整塊螢幕(含瀏海與圓角區)、關鍵 UI 不被硬體遮擋、不產生捲軸、不能被使用者拖動。這四件事各有各的機制,缺一項就會在某支手機上壞掉,而且通常是你手上沒有的那支。

骨架:position: fixed 根容器

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;              /* 文件本身沒有可捲動內容 → 沒有捲軸、沒有橡皮筋 */
  overscroll-behavior: none;     /* 保險:擋掉下拉刷新與捲動串連 */
  background: #000;              /* letterbox 的黑邊顏色由這裡決定 */
}
 
.game-root {
  position: fixed;               /* 包含塊 = 佈局視口,不受工具列展開收合影響 */
  inset: 0;                      /* top/right/bottom/left 全 0 */
  overflow: hidden;
  touch-action: none;            /* 接管所有手勢 */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;         /* 長按不跳「拷貝/分享」選單 */
  -webkit-tap-highlight-color: transparent;  /* 點擊不閃灰色方塊 */
}

position: fixed; inset: 0 比任何 height: 100vh/100dvh 都可靠:vh 家族會在工具列狀態變化時改變值並觸發重排,而固定定位的包含塊是佈局視口本身,瀏覽器已針對「內容應該貼合可視區」做過特殊處理。html/body { overflow: hidden } 則是根本性地移除捲動來源——沒有可捲動的內容,就沒有可以回彈的東西,iOS 的橡皮筋自然不會出現。

overscroll-behavior: none 是第二道防線,用於你的遊戲裡真的有可捲動子區域(排行榜、道具欄)的情況:

.inventory {
  overflow-y: auto;
  overscroll-behavior-y: contain;   /* 捲到底不會把外層一起帶動 */
  -webkit-overflow-scrolling: touch;
}

contain 保留區塊內自己的回彈感但阻斷捲動串連;none 連回彈都拿掉。要擋掉 Chrome Android 的下拉刷新,把 overscroll-behavior: none 設在 html 上(設在 body 上在部分瀏覽器不生效,因為根捲動容器的傳播規則)。

安全區:viewport-fit=coverenv()

沒有 viewport-fit=cover 時,Safari 會自動把內容縮排到安全區內——你會在瀏海旁邊看到兩條背景色的邊。加上 cover 之後內容延伸到螢幕邊緣,同時四個 env(safe-area-inset-*) 才會回傳非零值。

規則很簡單:背景延伸到邊緣,前景縮排到安全區內。

/* 背景/畫布:滿到邊緣,不做任何 inset */
.stage { position: absolute; inset: 0; }
 
/* 前景 UI:一律用 max() 保證最小內距,同時避開瀏海 */
.hud {
  position: absolute;
  inset: 0;
  padding-top:    max(12px, env(safe-area-inset-top));
  padding-right:  max(12px, env(safe-area-inset-right));
  padding-bottom: max(12px, env(safe-area-inset-bottom));
  padding-left:   max(12px, env(safe-area-inset-left));
  pointer-events: none;          /* 讓點擊穿透到畫布 */
}
.hud > * { pointer-events: auto; }

max(12px, env(...)) 是 WebKit 官方部落格建議的寫法:直向時 safe-area-inset-left 是 0,直接用 env() 會讓左右完全貼邊;用 max() 就能保證至少有 12px 的視覺內距,同時在橫向被瀏海侵入時自動加大。

幾個容易踩的點:

  • env() 只在 viewport-fit=cover 下非零,桌機與無瀏海裝置永遠是 0,所以不需要為它們寫例外。
  • 底部的 safe-area-inset-bottom 在有 home indicator 的機型約 34px,橫向時左右各約 44–48px。這些數字不要寫死,一律用 env()
  • env() 可以參與 calc()height: calc(100% - env(safe-area-inset-bottom)) 合法。
  • safe-area-max-inset-* 是對應的靜態最大值(所有可收合 UI 收起時的值),適合用來預留固定空間、避免版面在 inset 變化時跳動。

「安全框」與 safe-area 的差別

兩個容易混淆的概念:

  • env(safe-area-inset-*)硬體造成的不可用區域(瀏海、圓角、home indicator)。
  • 安全框(safe frame)你自己定義的、保證在所有取景策略下都可見的遊戲內矩形(見 畫布縮放策略)。

實作上要把兩者疊起來:HUD 的定位基準 = 安全框 ∩ 安全區。做法是把 env() 值寫進 CSS 變數,讓 JS 與 CSS 共用同一組數字:

:root {
  --sai-top:    env(safe-area-inset-top, 0px);
  --sai-right:  env(safe-area-inset-right, 0px);
  --sai-bottom: env(safe-area-inset-bottom, 0px);
  --sai-left:   env(safe-area-inset-left, 0px);
}
// JS 讀回同一組值(例如要在 canvas 內畫 HUD 時)
const cs = getComputedStyle(document.documentElement)
const safeTop = parseFloat(cs.getPropertyValue('--sai-top')) || 0

注意 getComputedStyle 讀自訂屬性時,未支援 env() 的環境會回傳空字串,parseFloat 得到 NaN,所以 || 0 是必要的。

純 CSS 的 letterbox 舞台

如果你的取景策略是 letterbox(見下一篇),連 JS 都不需要:

.game-root {
  position: fixed; inset: 0;
  display: grid;
  place-items: center;          /* 舞台在剩餘空間中置中 */
  background: #000;
}
 
.stage {
  aspect-ratio: 16 / 9;         /* 鎖定設計比例 */
  max-width: 100%;
  max-height: 100%;
  width: 100%;                  /* 先撐滿寬,再被 max-height 壓回去 */
}

aspect-ratiomax-width/max-height + 置中容器的組合,效果等同 scale = min(cssW/W, cssH/H),由排版引擎代勞、零 JS、resize 時零成本。這是最該優先考慮的方案——只有當你需要 cover 或 expand 取景、或需要精確控制 canvas backing store 時,才需要進到 JS 版本。

<canvas> 放進 .stage 時記得補:

.stage canvas {
  display: block;               /* 消除 inline 元素的行底空隙(會造成幾 px 的捲動) */
  width: 100%;
  height: 100%;
}

display: block 這行很常被漏掉。<canvas> 預設是 inline,行盒的 baseline 會在下方留下約 4–5px 的空隙,在 overflow: hidden 的容器裡看不出來,但在別處就會多出捲軸。

檢查清單

  • html, body { overflow: hidden } + 根容器 position: fixed; inset: 0
  • touch-action: none 只加在畫布層,設定頁保留 manipulation
  • 所有貼邊 UI 用 max(基準值, env(safe-area-inset-*))
  • <canvas>display: block
  • 實測:iPhone 橫向(瀏海在左或右都要試)、有 home indicator 的直向、Android 有導覽手勢列的機型

下一篇是本專欄的核心:畫面該怎麼縮放才對 → 畫布縮放策略