虛擬鍵盤彈出時,行動瀏覽器的預設行為是縮小視覺視口而不動佈局視口。這代表 100vh100dvhposition: fixed 的元素全部維持原樣,只是有一半被鍵盤蓋住;瀏覽器接著會自動捲動讓輸入框可見,於是你精心貼齊底部的 HUD 被推到螢幕外,而 position: fixed 的元素在 iOS 上還會出現「浮在鍵盤上方又跳回去」的抖動。

window.visualViewport 是唯一能可靠觀測這件事的 API。它自 2021 年 8 月起為 Baseline Widely available。

visualViewport 的資料模型

屬性意義
width / height視覺視口尺寸(CSS 像素)。鍵盤彈出時 height 變小
offsetLeft / offsetTop視覺視口左上角相對於佈局視口的偏移
pageLeft / pageTop相對於文件初始包含塊的座標
scale捏合縮放倍率。1 表示未縮放

事件有 resizescrollscrollend注意這些事件掛在 window.visualViewport 上,不是 window

推算鍵盤高度

沒有直接的「鍵盤高度」API(navigator.virtualKeyboard 有,但 Safari 不支援),標準做法是用差值推算:

const vv = window.visualViewport
 
function updateKeyboardInset() {
  if (!vv) return
  // 佈局視口高 − 視覺視口高 − 視覺視口向下的偏移 = 被鍵盤佔掉的高度
  const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
  document.documentElement.style.setProperty('--kb-inset', inset + 'px')
  document.documentElement.classList.toggle('kb-open', inset > 100)
}
 
vv?.addEventListener('resize', updateKeyboardInset)
vv?.addEventListener('scroll', updateKeyboardInset)
updateKeyboardInset()

> 100 這個門檻是為了排除工具列展開收合造成的小幅變化(工具列約 44–56px,鍵盤至少 250px)。門檻寫得太低會把捲動時的工具列動畫誤判成鍵盤。

CSS 端就能直接用:

.chat-bar {
  position: fixed;
  left: 0; right: 0;
  bottom: calc(var(--kb-inset, 0px) + max(8px, env(safe-area-inset-bottom)));
  transition: bottom .12s ease-out;
}
 
/* 鍵盤開啟時把遊戲畫布縮到剩餘空間,避免被蓋住 */
.kb-open .stage {
  height: calc(100% - var(--kb-inset));
}

transition 要短(100–150ms)。太長會落後於系統鍵盤動畫,看起來像 bug;完全不加則會在某些裝置上一格跳到位,也不好看。

三種讓瀏覽器代勞的路線

自己算不是唯一選擇,先看能不能讓瀏覽器處理。

一、interactive-widget=resizes-content 改變預設行為,讓鍵盤縮小佈局視口——dvh100%position: fixed 全部自動跟著縮,你完全不用寫 JS。

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">

支援度:Chrome 108+、Firefox 132+,Safari 尚未支援(已出現在 Safari Technology Preview 的功能旗標中)。Chrome on iOS/iPadOS 因為底層是 WebKit,同樣不支援。所以這是「Android 上免費拿到正確行為,iOS 仍要 JS 補償」的分裂狀態。

二、interactive-widget=overlays-content 兩個視口都不縮,鍵盤純粹疊在上面。適合你完全接管版面、要自己用 --kb-inset 決定一切的情況——它避免了瀏覽器自作主張的自動捲動。

三、scrollIntoViewscroll-margin 若只是要讓輸入框可見,讓瀏覽器捲就好,用 CSS 控制留白:

input, textarea { scroll-margin-block-end: 96px; }

滿版遊戲的具體策略

最好的策略是不要在遊戲畫面內用原生輸入框。 需要打字的場景(暱稱、聊天、密碼)改成獨立的覆蓋層(overlay),開啟時暫停遊戲、隱藏畫布。這樣鍵盤怎麼推擠版面都不影響遊戲,因為此刻沒有遊戲畫面要維持。

若必須在遊戲中即時輸入(多人聊天),做法是:

const input = document.querySelector('#chat-input')
 
input.addEventListener('focus', () => {
  document.documentElement.classList.add('chat-mode')
  game.pauseRendering()        // 或降到低幀率,省電並避免與鍵盤動畫搶資源
})
 
input.addEventListener('blur', () => {
  document.documentElement.classList.remove('chat-mode')
  game.resumeRendering()
  // iOS 上 blur 之後視覺視口可能沒有立刻復原,補一次強制捲回
  window.scrollTo(0, 0)
})

window.scrollTo(0, 0) 那行是 iOS 專屬的補救:關閉鍵盤後文件有時仍停在被捲動的位置,導致 position: fixed 的元素看起來偏移。因為滿版遊戲的 html/bodyoverflow: hidden,這行呼叫沒有副作用。

捏合縮放:vv.scale

visualViewport.scale 大於 1 表示使用者捏合放大了。在遊戲中通常是誤觸(touch-action: none 可以擋掉畫布內的,但畫布外的 UI 區域仍可能被捏)。可以據此隱藏會錯位的固定元素,或提示使用者:

vv?.addEventListener('resize', () => {
  document.documentElement.classList.toggle('zoomed', vv.scale > 1.05)
})

MDN 也給了「模擬 position: device-fixed」的範例——讓元素無視捏合縮放永遠貼在視覺視口上:

function pinToVisualViewport(el) {
  el.style.transform =
    `translate(${vv.offsetLeft}px, ${vv.offsetTop}px) scale(${1 / vv.scale})`
  el.style.transformOrigin = '0 0'
}
vv?.addEventListener('scroll', () => pinToVisualViewport(bar))
vv?.addEventListener('resize', () => pinToVisualViewport(bar))

這在遊戲中很少需要,但對「暫停」「離開」這類必須永遠可及的按鈕是合理的保險。

桌機的實體鍵盤:別忘了 inert 與焦點

輸入覆蓋層開啟時,遊戲的鍵盤操作必須停止,否則玩家打字時角色會亂跑。不要用「檢查 document.activeElement」這種脆弱的做法,用 inert

overlay.hidden = false
gameRoot.inert = true        // 整棵子樹不可聚焦、不接收事件、對輔助技術隱藏
input.focus()

inert 同時處理了焦點陷阱與無障礙,比手動 preventDefault() 全部按鍵乾淨得多。

檢查清單

  • 事件掛在 window.visualViewport 上,不是 window
  • 鍵盤高度用 innerHeight − vv.height − vv.offsetTop 推算,有門檻過濾工具列
  • 有寫 interactive-widget,並知道 Safari 上不生效、要靠 JS 補
  • 盡量用覆蓋層取代遊戲內原生輸入框
  • 輸入時遊戲有暫停/降幀,且 inert 有設
  • iOS 上 blur 後有 scrollTo(0, 0) 補救
  • 實測:iOS Safari、Android Chrome、注音/emoji 鍵盤(高度不同)、外接鍵盤

下一篇處理讓所有裝置都跑得動的最後一塊 → 效能:render scale 與動態解析度