iOS 软键盘遮挡底部输入框解决报告
文档用途
本文记录本项目聊天页在 iOS Safari、WKWebView、微信小程序 web-view 中出现"文字输入框被软键盘遮挡"的原因、解决方案和验证方法。
以后再次出现类似问题时,可以直接把本文提供给 AI。AI 应先核对当前页面结构,再按本文方案进行最小修改,不要仅凭经验添加 position: fixed、固定键盘高度或全局滚动补丁。
问题结论
问题不是输入框无法获得焦点,也不是 Android 与 iOS 键盘高度不同,而是两个平台对软键盘出现时的视口处理不同:
- Android 浏览器通常会同步缩小页面可布局区域,底部 Flex 输入区会自然移动到键盘上方。
- iOS Safari / WKWebView 通常只缩小
window.visualViewport,页面使用的布局视口及固定100vh/100svh容器不一定同步缩小。 - 本项目聊天页外层固定为一屏高度并设置
overflow-hidden,因此浏览器无法通过页面滚动自动把底部输入区完整推到键盘上方。 - 最终表现为键盘已经占据屏幕下部,但输入区仍停留在原布局视口底部,被键盘覆盖。
本项目采用的解决方式是:文字输入框聚焦期间监听 visualViewport,计算输入区底边被可视视口遮挡的高度,将该高度作为输入根节点的动态 padding-bottom。输入根节点因此在 Flex 布局中增高,实际输入框被抬到键盘上方,消息列表同时自然收缩。
当前页面结构
相关文件:
src/pages/chat/index.tsx:聊天页一屏容器。src/pages/chat/ChatConversation.tsx:消息列表与输入区的纵向 Flex 组合。src/pages/chat/input/ChatInput.tsx:语音、文字输入模式及键盘避让逻辑。src/pages/chat/input/TextInput.tsx:真正的textarea。src/index.css:兼容旧 WebView 的视口高度类。
聊天页外层结构等价于:
tsx
<main className="h-webview flex min-h-0 flex-col overflow-hidden">
<section className="min-h-0 flex-1 overflow-y-auto">消息列表</section>
<ChatInput />
</main>
其中:
.h-webview依次声明height: 100vh和height: 100svh。- 消息列表使用
flex-1 min-h-0 overflow-y-auto,允许在输入区增高时收缩。 ChatInput使用shrink-0,始终作为底部输入区域。- 外层
overflow-hidden保证页面本身不滚动,只有消息列表滚动。
这个结构是动态底部占位方案能够正常工作的前提。不要把同一业务页面改成多个互相竞争的固定定位层。
遮挡高度计算
正确公式
ts
const viewportBottom = visualViewport.offsetTop + visualViewport.height
const keyboardInset = Math.max(0, inputRoot.getBoundingClientRect().bottom - viewportBottom)
含义:
visualViewport.offsetTop + visualViewport.height是用户当前真正能看到的视口底边。inputRoot.getBoundingClientRect().bottom是输入区域在布局视口中的底边。- 两者之差就是输入区域落入键盘遮挡范围的高度。
- 使用
Math.max(0, ...)避免没有遮挡时产生负值。 - 使用
Math.round(...)避免键盘动画期间出现无意义的小数像素抖动。
为什么不直接使用 window.innerHeight - visualViewport.height
该差值不一定等于键盘高度:
- iOS 地址栏、底部工具栏也会改变
visualViewport。 - 键盘出现后
visualViewport.offsetTop可能不为零。 - 输入区不一定恰好位于布局视口最底部。
- Android 可能已经同步缩小布局视口,再计算一次会产生重复偏移。
使用"输入区域底边减去可视视口底边"只补偿当前组件实际受到的遮挡,适用范围更准确。
当前解决实现
实现位置:src/pages/chat/input/ChatInput.tsx。
状态与 DOM 引用
tsx
const [keyboardInset, setKeyboardInset] = useState(0)
const inputRootRef = useRef<HTMLDivElement>(null)
监听文字输入期间的视口和焦点变化
tsx
useEffect(() => {
if (inputMode !== 'text') {
return
}
const inputRoot = inputRootRef.current
const viewport = window.visualViewport
if (!inputRoot || !viewport) {
return
}
let focusFrame = 0
const updateKeyboardInset = () => {
const activeElement = document.activeElement
const isTextareaFocused = activeElement instanceof HTMLTextAreaElement && inputRoot.contains(activeElement)
const viewportBottom = viewport.offsetTop + viewport.height
const nextInset = isTextareaFocused ? Math.max(0, Math.round(inputRoot.getBoundingClientRect().bottom - viewportBottom)) : 0
setKeyboardInset(nextInset)
}
const handleFocusChange = () => {
window.cancelAnimationFrame(focusFrame)
focusFrame = window.requestAnimationFrame(updateKeyboardInset)
}
inputRoot.addEventListener('focusin', handleFocusChange)
inputRoot.addEventListener('focusout', handleFocusChange)
viewport.addEventListener('resize', updateKeyboardInset)
viewport.addEventListener('scroll', updateKeyboardInset)
return () => {
window.cancelAnimationFrame(focusFrame)
inputRoot.removeEventListener('focusin', handleFocusChange)
inputRoot.removeEventListener('focusout', handleFocusChange)
viewport.removeEventListener('resize', updateKeyboardInset)
viewport.removeEventListener('scroll', updateKeyboardInset)
}
}, [inputMode])
关键点:
- 只在文字输入模式注册监听,语音模式不参与键盘避让。
- 只在当前焦点确实是输入根节点内的
textarea时计算偏移。点击发送、停止或语音按钮后不会继续保留键盘占位。 - 同时监听
resize和scroll。iOS 键盘动画既可能改变可视视口高度,也可能改变offsetTop。 focusout时通过下一帧计算,让document.activeElement先完成更新。- 卸载或切换模式时必须移除所有监听并取消待执行的动画帧。
window.visualViewport不存在时直接降级为原布局,不做 UA 判断,也不影响 Android。
将遮挡高度应用到输入根节点
tsx
<div ref={inputRootRef} className="relative w-full shrink-0" style={keyboardInset ? { paddingBottom: keyboardInset } : undefined}>
{/* 文字输入或语音输入 */}
</div>
这里使用 padding-bottom,而不是 transform:
padding-bottom会增加输入区在 Flex 布局中占用的高度。- 输入框被抬高的同时,前面的
flex-1消息列表会自然收缩。 transform: translateY(...)只改变视觉位置,不参与布局,会让输入框覆盖消息内容,并留下错误的可滚动高度。
切回语音输入时应立即清理:
tsx
const switchToVoice = () => {
setInputMode('voice')
setTextMode('idle')
setKeyboardInset(0)
}
为什么不能只用 CSS 解决
以下方法不能稳定解决本项目问题:
只改成 100dvh
- 项目兼容基线包含 Safari 13.1 和旧版 WebView,不能依赖
dvh。 - 不同 iOS / WKWebView 版本对软键盘是否影响动态视口单位的行为并不完全一致。
- 即使容器高度变化,也仍需处理
visualViewport.offsetTop和内嵌 WebView 的视口移动。
给输入框使用 position: fixed; bottom: 0
fixed通常仍以布局视口为参考,iOS 键盘出现后仍可能被覆盖。- 会破坏现有"消息列表收缩、输入区占底部"的 Flex 布局。
- 容易与安全区、抽屉、录音浮层及浏览器工具栏产生新的层叠问题。
输入聚焦时调用 scrollIntoView()
- 外层页面设置了
overflow-hidden,真正可滚动的是消息列表,不一定能滚动输入区。 - iOS 浏览器会同时执行自己的焦点滚动,容易出现二次跳动、页面上移后无法恢复。
- 只能尝试滚动元素,不能持续跟随键盘打开、收起和方向变化。
写死一个键盘高度
- 不同设备、横竖屏、输入法、候选栏和辅助键盘高度都不同。
- 微信小程序 WebView、Safari 和第三方输入法的高度也不同。
- 必须根据实时可视视口计算,不能写死
300px等经验值。
通过 UA 只判断 iOS
- UA 判断容易遗漏 iPadOS、企业 WebView 和微信内核差异。
- 当前计算方式在没有遮挡时结果为零,Android 不需要特殊分支。
- 应优先使用能力检测:
window.visualViewport。
测试方案
自动化测试
测试文件:src/pages/chat/input/ChatInput.test.tsx。
测试应模拟:
- 输入根节点底边为
800px。 visualViewport.height从800px缩小到500px。- 文字输入框处于焦点状态。
- 触发
visualViewport.resize后,根节点得到padding-bottom: 300px。 - 输入框失焦后,
padding-bottom被清除。
运行:
bash
pnpm exec vitest run src/pages/chat/input/ChatInput.test.tsx
pnpm build
真机验证矩阵
至少验证以下场景:
- iPhone Safari:文字框聚焦、输入、发送、失焦。
- 微信小程序 iOS
web-view:键盘打开后输入框完整位于键盘上方。 - Android 微信 / 浏览器:输入框位置不能被重复抬高。
- 横竖屏切换:键盘打开时重新计算位置。
- 多行输入:输入框高度变化后仍不被遮挡。
- 切换文字与语音模式:语音模式不存在遗留空白。
- 点击发送、停止和语音按钮:失焦后底部占位恢复。
- 浏览器顶部或底部工具栏展开、收起:不能把工具栏高度误判成固定键盘高度。
排障时需要记录的数据
如果修复后仍有个别 iOS 设备异常,应在键盘打开时记录以下数据:
ts
{
innerHeight: window.innerHeight,
viewportHeight: window.visualViewport?.height,
viewportOffsetTop: window.visualViewport?.offsetTop,
inputBottom: inputRootRef.current?.getBoundingClientRect().bottom,
activeElement: document.activeElement?.tagName,
keyboardInset
}
根据数据判断:
visualViewport不存在:当前 WebView 不支持该能力,需要确认实际兼容基线后选择降级方案。activeElement不是TEXTAREA:焦点已被按钮或宿主 WebView 抢走,不应继续保留键盘占位。viewportHeight变小但keyboardInset为零:检查输入根节点引用和getBoundingClientRect()是否指向正确容器。keyboardInset正确但输入框未上移:检查父级是否仍是纵向 Flex、消息列表是否允许收缩、输入根节点是否被改成绝对或固定定位。- Android 被重复抬高:检查是否又叠加了
innerHeight差值、transform、全局键盘 CSS 变量或宿主端额外偏移。
AI 后续处理约束
将本文交给 AI 后,应要求 AI 遵循以下顺序:
- 先检查聊天页、消息列表、输入组件、全局视口 CSS 和现有测试,确认调用链及布局没有变化。
- 如果本文实现仍存在,优先查明是哪一层布局被改动,不要重复添加第二套键盘监听。
- 如果实现缺失,按本文公式和生命周期恢复最小实现。
- 不修改鉴权、HTTP、SSE、语音识别或消息发送契约。
- 不新增依赖,不改工程配置,不用 UA 判断或固定键盘高度。
- 只格式化本次改动文件,运行输入组件测试及
pnpm build。 - 最后检查
git diff,避免覆盖工作区中与键盘问题无关的修改。
验收标准
- iOS 文字框聚焦后始终完整显示在软键盘上方。
- 键盘动画、浏览器工具栏变化和可视视口滚动期间,输入区能持续跟随。
- 键盘收起或输入框失焦后不保留多余底部空白。
- Android 原有正常行为不受影响。
- 消息列表仍可滚动,输入区不会直接覆盖最后一条消息。
- 语音输入、发送、停止回答、鉴权限制及其他聊天功能行为不变。
- 所有事件监听都能在模式切换和组件卸载时正确清理。