虛擬鍵盤彈出時,行動瀏覽器的預設行為是縮小視覺視口而不動佈局視口。這代表 100vh、100dvh、position: fixed 的元素全部維持原樣,只是有一半被鍵盤蓋住;瀏覽器接著會自動捲動讓輸入框可見,於是你精心貼齊底部的 HUD 被推到螢幕外,而 position: fixed 的元素在 iOS 上還會出現「浮在鍵盤上方又跳回去」的抖動。
window.visualViewport 是唯一能可靠觀測這件事的 API。它自 2021 年 8 月起為 Baseline Widely available。
visualViewport 的資料模型
| 屬性 | 意義 |
|---|---|
width / height | 視覺視口尺寸(CSS 像素)。鍵盤彈出時 height 變小 |
offsetLeft / offsetTop | 視覺視口左上角相對於佈局視口的偏移 |
pageLeft / pageTop | 相對於文件初始包含塊的座標 |
scale | 捏合縮放倍率。1 表示未縮放 |
事件有 resize、scroll、scrollend。注意這些事件掛在 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。 改變預設行為,讓鍵盤縮小佈局視口——dvh、100%、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 決定一切的情況——它避免了瀏覽器自作主張的自動捲動。
三、scrollIntoView 與 scroll-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/body 是 overflow: 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 與動態解析度