VirtualKeyboard API:viewport 与 inset 边界
协调 geometrychange、visualViewport 与键盘 inset,让移动表单过渡期间的固定操作保持可达。
BotBrowser Team
BotBrowser 可以在声明键盘策略、profile 和 viewport 的授权移动 fixture 中重复测试,但不能复现每一种原生键盘动画,也不能认证物理设备。更具体的问题是:可信的可编辑字段打开虚拟键盘时,哪一个表面改变、哪个 inset 可用、哪个操作仍然可达?本文把 VirtualKeyboard API 与通用 resize 处理,以及设备 profile 测试和viewport 测量指南中的选择分开说明。
要点
navigator.virtualKeyboard.overlaysContent是页面选择的策略:键盘可以覆盖内容,而不是缩小 layout viewport。geometrychange报告当前键盘几何的DOMRect;写入布局前应与visualViewport变化合并处理。- 只有浏览器和策略提供时,
env(keyboard-inset-*)才会暴露 CSS inset。保留0px的普通 padding 备用路径。 - layout viewport、visual viewport 和键盘矩形回答不同问题,不能用一个
innerHeight代替它们。 - 将焦点、滚动、旋转、缩放和键盘关闭作为过渡测试;只截取打开状态并不完整。
目录
三个坐标表面与策略
layout viewport 是文档布局使用的坐标空间。visual viewport 是浏览器界面、捏合缩放或虚拟键盘占用空间后仍可见的部分。页面选择 overlay 行为时,VirtualKeyboard API 会增加键盘矩形。这些表面可以独立移动,因此单独的 window.innerHeight 不能描述聚焦表单的状态。
| 表面 | 回答的问题 | 示例字段 |
|---|---|---|
| layout viewport | 文档布局区域有多大? | window.innerHeight |
| visual viewport | 用户此刻能看到什么? | visualViewport.height |
| 键盘矩形 | 虚拟键盘覆盖哪些像素? | virtualKeyboard.boundingRect |
键盘让页面 resize 时,layout viewport 可能缩小,键盘矩形也可能为零或不在页面可用区域中。选择 overlay 模式时,layout viewport 可以保持稳定,而键盘覆盖底部边缘。Viewport resize 指南解释相关事件顺序;页面还需要明确的键盘策略与 CSS inset 契约。
不要在没有记录浏览器界面和缩放的情况下,仅用 innerHeight - visualViewport.height 推导键盘高度。这个差值只是估计,不是 API 保证。overlay 模式优先使用 virtualKeyboard.boundingRect,实际可见区域则记录 visualViewport.height、offsetTop 和 scale。
navigator.virtualKeyboard.overlaysContent = true 告诉支持该能力的浏览器由页面处理键盘覆盖区域。浏览器不会自动为每个固定页脚让路。页面必须使用几何值或 CSS environment variables,并保留可用的关闭和焦点路径。应在表单旅程打开键盘前设置策略,而不是每次 focus 都切换。
记录 API 是否存在、overlaysContent 赋值结果、活动 profile 标签和使用的 CSS 路径。桌面 profile 可能忽略该选项,这是能力边界,不是用户类别信号。
Fixture 与 inset 实现
下面的 fixture 固定提交操作,给表单提供滚动区域,并写入脱敏几何记录。记录不收集文本、账号标识符、原始按键事件或键盘内容。保持标记稳定,每次只改变矩阵中的一行。
<main class="screen">
<form class="card" id="checkout">
<label>Email <input name="email" type="email" autocomplete="email" /></label>
<label>Address <input name="address" autocomplete="street-address" /></label>
<p class="status" role="status" aria-live="polite"></p>
<button class="submit" type="submit">Continue</button>
</form>
</main>
<script>
const vk = navigator.virtualKeyboard;
if (vk) {
vk.overlaysContent = true;
vk.addEventListener('geometrychange', () => {
const r = vk.boundingRect;
document.documentElement.style.setProperty('--vk-bottom', `${r.height}px`);
console.log(JSON.stringify({ type: 'keyboard', height: r.height, top: r.top }));
});
}
</script>
:root {
--vk-bottom: 0px;
}
.screen {
min-block-size: 100dvh;
padding: 1rem;
}
.card {
min-block-size: 140vh;
padding-block-end: calc(5rem + var(--vk-bottom));
}
.submit {
position: fixed;
inset-inline: 1rem;
inset-block-end: calc(1rem + var(--vk-bottom) + env(safe-area-inset-bottom, 0px));
}
@supports (padding: env(keyboard-inset-bottom)) {
.card {
padding-block-end: calc(5rem + env(keyboard-inset-bottom));
}
.submit {
inset-block-end: calc(1rem + env(keyboard-inset-bottom) + env(safe-area-inset-bottom, 0px));
}
}
JavaScript 自定义属性用于兼容只暴露几何值而不暴露 CSS environment variable 的引擎。@supports 只能证明语法可解析,不能证明 keyboard-inset-bottom 非零。焦点进入后和关闭后都检查计算出的 padding。键盘隐藏、overlay 关闭或浏览器不提供该功能时,0px 都是有效值。
keyboard-inset-top、keyboard-inset-right、keyboard-inset-bottom、keyboard-inset-left 以及宽高变体描述被遮挡的边缘。它们可用于为固定操作让出空间或为滚动容器预留空间,但不能替代 safe-area-inset-*。设备可能同时存在键盘和底部指示条安全区。用 max() 或 calc() 合并约束,并在 RTL 或竖排文字中优先使用逻辑属性。
如果关闭后备用路径保留了过多空间,在下一个几何事件中清除自定义属性,不要用负偏移隐藏操作。JavaScript 启动、导航恢复或 profile 不支持 navigator.virtualKeyboard 时,普通文档流仍应可用。
事件顺序与滚动所有者
geometrychange 可能接近 visualViewport.resize 和 visualViewport.scroll 到达。这些事件描述相关过渡,但没有保证的顺序。用一个调度器包住三个事件,每个动画帧只读取一次当前值:
let pending = false;
function scheduleLayout() {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
const vv = window.visualViewport;
const k = navigator.virtualKeyboard?.boundingRect;
document.documentElement.dataset.keyboard = k && k.height > 0 ? 'open' : 'closed';
console.log(
JSON.stringify({
layoutHeight: window.innerHeight,
visualHeight: vv?.height ?? null,
visualOffsetTop: vv?.offsetTop ?? null,
keyboardHeight: k?.height ?? 0,
})
);
});
}
window.visualViewport?.addEventListener('resize', scheduleLayout);
window.visualViewport?.addEventListener('scroll', scheduleLayout);
navigator.virtualKeyboard?.addEventListener('geometrychange', scheduleLayout);
记录可以区分键盘过渡、浏览器界面和捏合缩放。keyboardHeight: 0 且 visualHeight 变小,并不能证明键盘关闭;也可能是工具栏或缩放变化。将 scale、offsetTop 和聚焦元素矩形一起保存。每帧一条记录可以避免竞争处理器写入过期 padding。
焦点落在可见区域下方的 input 时,浏览器可能滚动文档或内部 overflow: auto 容器。固定提交按钮可能保持可见而字段被遮住,也可能字段可见而按钮被键盘覆盖。滚动稳定后同时断言两个矩形。只选一个滚动所有者:等待焦点调整,只在字段底部超过可见键盘顶部时修正;不要每次 geometrychange 都调用 scrollIntoView()。
旋转和缩放是不同过渡。旋转可能在键盘事件前改变 layout viewport、visual viewport 和安全区 inset。捏合缩放以不同方式改变视觉坐标与 CSS 尺寸。把 scale、offsetTop 和安全区值放在独立矩阵行中。
记录验收结果
验收记录要指出失败的过渡,而不是只说键盘可见。保存行标识、聚焦控件、策略赋值结果、window.innerHeight、visualViewport.height、visualViewport.offsetTop、visualViewport.scale、键盘矩形、计算出的底部 padding 和最终操作矩形。
{
"row": "D",
"focused": "address",
"overlaysContent": true,
"layoutHeight": 844,
"visualHeight": 512,
"visualOffsetTop": 0,
"keyboardTop": 512,
"keyboardHeight": 332,
"actionBottom": 496,
"keyboardTopMinusActionBottom": 16,
"actionReachable": true
}
如果 keyboardHeight 为正且 actionBottom 超过 keyboardTop,固定操作被覆盖。如果操作避开键盘但字段仍低于 keyboardTop,滚动所有者不正确。如果两个矩形都安全而 visualHeight 意外变小,应先检查浏览器界面或缩放,再修改 CSS。不要记录字段值、账号标识符、原始按键或设备序列数据。
诊断第一个分歧表面
如果没有 navigator.virtualKeyboard,将该行分类为不支持并保留普通 viewport 路径。如果属性存在但 overlaysContent 仍为 false,浏览器正在管理 resize;零矩形不能证明键盘关闭。如果 geometrychange 触发但计算出的 keyboard-inset-bottom 为零,使用 JavaScript 矩形路径并记录 CSS inset 不可用。如果矩形正确但操作被覆盖,检查 containing block 和安全区计算。如果操作避开 inset 而输入仍被隐藏,检查唯一滚动所有者和焦点时序。
让备用行为可测试
分别在缺少 navigator.virtualKeyboard、忽略 overlaysContent 赋值以及 CSS environment variables 解析但保持零值时渲染同一个表单。操作应通过普通文档流保持可达,用户也应能关闭键盘或改用其他输入路径。使用 feature flag 比较托管路径和显式路径,不改变表单标记;flag 只放在 fixture 记录中,不放入 fingerprinting 数据。
边界矩阵与 BotBrowser 能力
每一行使用一个新 profile 运行同一个 fixture。除非矩阵行明确改变,否则保持文本、字体加载、缩放和设备 scale factor 不变。有效失败信息要指出行和第一个分歧表面,例如“D 行内部滚动后操作矩形位于键盘顶部下方 18px”,而不是“移动键盘坏了”。
| 行 | 变化 | 预期观察 | 含义 |
|---|---|---|---|
| A | 关闭键盘策略 | 普通 resize 或浏览器托管行为 | 基线兼容性 |
| B | 开启 overlay 并聚焦字段 | geometrychange、正 height、操作避开 inset | 显式键盘契约 |
| C | 关闭键盘 | 高度回到 0,没有旧 padding | 清理过渡 |
| D | 内部滚动容器 | 字段和操作都保持可达 | 滚动所有权 |
| E | 旋转或 200% 缩放 | viewport 与安全区字段独立变化 | 非键盘过渡 |
| F | 不支持或桌面 profile | API 缺失或选项被忽略 | 能力备用路径 |
保存 browserVersion、profile 标签、策略赋值、聚焦控件、layout 与 visual viewport 值、键盘矩形、安全区值和最终操作矩形。不要保存表单文本或原始键盘数据。每次打开后重复 C 行,发现过期的 --vk-bottom。
BotBrowser 能力边界
BotBrowser 支持匹配的移动 profile,profile 定义触控支持,并提供文档化的 --bot-mobile-keyboard 选项。在支持的 profile 中,可信地聚焦可编辑字段可能降低 visualViewport.height,同时保持 layout viewport 稳定,因此 fixture 可以重复键盘可见和触控检查。桌面 profile 可能忽略该选项。BotBrowser 不能替代物理设备、操作系统原生键盘及其动画,也不能覆盖每种应用布局,并且不保证每个浏览器提供非零键盘 inset。profile 选择可参考移动浏览器 profile 质量评审。
FAQ
overlaysContent 会调整 layout viewport 吗?
它用于让键盘覆盖内容,因此支持的浏览器可以保持 layout viewport 稳定;visual viewport 和键盘矩形仍会变化。应在每个浏览器中核对 innerHeight、visualViewport.height 和 boundingRect.height。
键盘 inset 与安全区 inset 相同吗?
不相同。键盘 inset 描述临时遮挡,安全区 inset 描述需要永久 padding 的设备或浏览器边缘。用 calc() 或 max() 合并它们,并测试关闭过程以清除旧 padding。
每次 geometrychange 都应调用 scrollIntoView() 吗?
不应。等待焦点滚动完成,在一个动画帧内检查控件和操作,仅在控件仍位于可见键盘边界后方时修正。一个滚动容器只保留一个所有者。
来源
- VirtualKeyboard API(W3C)
- MDN:VirtualKeyboard API
- MDN:
geometrychange - MDN:CSS
env()函数 - BotBrowser 设备仿真文档
实用结论
把 VirtualKeyboard 当作策略与几何契约。分别保存 layout、visual viewport、键盘矩形、安全区和滚动所有者;合并相关事件,并测试打开、关闭、旋转、缩放和不支持的 profile。BotBrowser 可以重复声明的移动条件并采集脱敏几何,但物理设备与原生键盘行为不属于这份证据。
本文属于指南: Web 平台能力面:一致性复核地图