iOS 软键盘遮挡底部输入框解决报告

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: 100vhheight: 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 时计算偏移。点击发送、停止或语音按钮后不会继续保留键盘占位。
  • 同时监听 resizescroll。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

测试应模拟:

  1. 输入根节点底边为 800px
  2. visualViewport.height800px 缩小到 500px
  3. 文字输入框处于焦点状态。
  4. 触发 visualViewport.resize 后,根节点得到 padding-bottom: 300px
  5. 输入框失焦后,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 遵循以下顺序:

  1. 先检查聊天页、消息列表、输入组件、全局视口 CSS 和现有测试,确认调用链及布局没有变化。
  2. 如果本文实现仍存在,优先查明是哪一层布局被改动,不要重复添加第二套键盘监听。
  3. 如果实现缺失,按本文公式和生命周期恢复最小实现。
  4. 不修改鉴权、HTTP、SSE、语音识别或消息发送契约。
  5. 不新增依赖,不改工程配置,不用 UA 判断或固定键盘高度。
  6. 只格式化本次改动文件,运行输入组件测试及 pnpm build
  7. 最后检查 git diff,避免覆盖工作区中与键盘问题无关的修改。

验收标准

  • iOS 文字框聚焦后始终完整显示在软键盘上方。
  • 键盘动画、浏览器工具栏变化和可视视口滚动期间,输入区能持续跟随。
  • 键盘收起或输入框失焦后不保留多余底部空白。
  • Android 原有正常行为不受影响。
  • 消息列表仍可滚动,输入区不会直接覆盖最后一条消息。
  • 语音输入、发送、停止回答、鉴权限制及其他聊天功能行为不变。
  • 所有事件监听都能在模式切换和组件卸载时正确清理。
相关推荐
思盛iOS签名上架3 小时前
为什么 iOS 需要签名?未签名 App 无法安装的底层逻辑
ios
Data_Journal21 小时前
Golang 中解析 HTML 的指南
ios·iphone
jike_20261 天前
销售拜访记录APP推荐:客户沟通内容怎么快速整理?
ios·语音识别·iphone
00后程序员张1 天前
iOS 开发全流程需要哪些工具?编码、调试、构建、发布阶段选择
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
游戏开发爱好者82 天前
WebSocket 抓包怎么查看内容?从握手到消息帧完整解读
网络协议·计算机网络·网络安全·ios·adb·https·udp
悟空瞎说2 天前
# Alamofire 使用文档 完整翻译
ios
WeiAreYoung2 天前
Flutter+iOS 实现 Live Activity
flutter·ios
2501_916008892 天前
Swift多环境配置:利用Scheme实现灵活的多环境管理
开发语言·ide·vscode·ios·个人开发·swift·敏捷流程