移动端输入框聚焦跳动与自动放大
前言
iPhone 上点输入框,最常见的两种病:
- 页面自动放大,收起键盘也不缩回去
- 页面/弹窗跳一下:居中弹窗被顶上去、关闭按钮出屏;收键盘后也不回来,多点几次越偏越多
目标是:缩放永远只由用户手动捏合;点输入框前后画面不动;键盘只在用户亲手点输入框时出现。
做法是封装成一个公共模块,在应用入口统一接管,不在各组件里逐个修。会弹键盘的地方遍布全站(弹窗、页面搜索、登录、设置、行内改名……),逐个改 autoFocus 或加监听必然漏,新加的输入框也不会自动受保护。正确形态是:CSS 一条全局规则 + JS 一个模块在入口调用一次,业务组件零改动,照常写 <input> / autoFocus。
适用范围:任何手机端网页,与框架无关。JS 部分是原生 DOM 代码,React / Vue / 纯 HTML 都能用。
根因
| 症状 | 根因 |
|---|---|
| 自动放大 | iOS 在输入框字号 < 16px 时聚焦会放大页面,且失焦后不还原 |
| 画面跳动 | 输入框获得焦点时,iOS 会把根文档滚到它认为“输入框可见”的位置,哪怕输入框本来就在键盘上方。根文档一滚,position: fixed 的弹窗/底栏就在屏幕上跟着移动。这次滚动收键盘后不滚回,下次再点又叠一段 |
| 切页签/开弹窗时键盘自己弹出 | autoFocus 或代码里的 .focus() 正好跟在一次用户点击后面,iOS 认作用户意图,弹键盘;而且这种聚焦不经过下文的拦截,照样会跳 |
桌面浏览器和 devtools 移动模拟都复现不出来(没有真实软键盘,也没有 iOS 的聚焦滚动),只能真机看。
一组真机实测数据(iPhone,iOS Chrome / WebKit):弹窗里点搜索框,scrollY 0→51,visualViewport.offsetTop 0→51,visualViewport.height 684→355;收键盘后 scrollY 仍是 51,再点变 102。输入框明明在可视区内,这 51px 完全没必要。
先量,再改
真机没有 devtools,把数打回服务端终端(打点方式见《移动端网页问题复现与调试指南》)。贴一段临时探针:
// 临时探针:确认后删除。/api/boot-log 换成你项目里能把 body 打进服务端终端的接口
{
const v = window.visualViewport
const rows: string[] = []
let timer = 0
const snap = (tag: string) => {
const box = document.querySelector('.dlg') // ← 换成会跳的那个 fixed 元素
const r = box?.getBoundingClientRect()
rows.push(`${tag} top=${r?.top.toFixed(1)} vvTop=${v?.offsetTop.toFixed(1)} vvH=${v?.height.toFixed(1)} ih=${innerHeight} sy=${scrollY} act=${document.activeElement?.tagName}`)
clearTimeout(timer)
timer = window.setTimeout(() => {
const body = JSON.stringify({ detail: '[vvprobe] ' + rows.splice(0).slice(-12).join(' | ') })
navigator.sendBeacon('/api/boot-log', new Blob([body], { type: 'application/json' }))
}, 1200)
}
document.addEventListener('touchstart', () => snap('touch'), { capture: true, passive: true })
v?.addEventListener('resize', () => snap('resize'))
v?.addEventListener('scroll', () => snap('scroll'))
}别只挂
focusin:探针挂在focusin上时,真机一条都没回来。挂touchstart+visualViewport事件才稳。
读法:
| 看到的 | 结论 |
|---|---|
聚焦后 sy 变大、vvTop 跟着变,收键盘后 sy 不回 0 | 就是本病,用下文的 JS 接管 |
sy 不变,弹窗 top 变了 | 弹窗高度用了 dvh/% 跟着工具栏变,查弹窗 CSS(改 svh),不是本病 |
| 页面整体变大、字变粗 | 自动放大,用下文的 16px 规则 |
修复
CSS:触屏输入框 16px
治自动放大。
/* iOS 在输入框字号 < 16px 时聚焦会自动放大页面且不还原;抬到 16px 就不触发。
不用 viewport 的 maximum-scale / user-scalable=no:那会锁死用户手动缩放。 */
@media (hover: none) and (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]),
textarea,
select { font-size: 16px !important; }
}放在全局样式最后,保证压过组件里的字号。只在触屏生效,桌面设计不变。不要用 viewport 的 maximum-scale / user-scalable=no,那会锁死用户手动缩放。
JS:全站接管 iOS 聚焦
治跳动和键盘乱弹。做法来自 Adobe React Aria 的 usePreventScroll(业界弹窗库的标准解),三条规则:
- 用户点输入框:拦下
touchend,自己focus;聚焦那一帧把输入框挪到屏幕外,iOS 判定“无需滚动”,下一帧挪回。 - 代码触发的聚焦(
autoFocus、点“改名”后.focus()):在 iOS 上不执行,键盘只在用户亲手点时出现。 - 键盘弹出后输入框真被挡住(比如贴底的输入框):只滚它所在的滚动容器把它露出来,没有容器才退回滚根文档。没被挡住就一像素都不动。
// iosKeyboardFocus.ts —— 在应用入口、首屏渲染前调用一次 installIosKeyboardFocus()
const TEXT_INPUT = /^(text|search|email|url|tel|password|number)$/
const KEYBOARD_MARGIN = 12
type TextField = HTMLInputElement | HTMLTextAreaElement
function isTextField(el: unknown): el is TextField {
if (el instanceof HTMLTextAreaElement) return !el.readOnly && !el.disabled
return el instanceof HTMLInputElement && TEXT_INPUT.test(el.type) && !el.readOnly && !el.disabled
}
function scrollParent(el: Element): Element | null {
for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
const { overflowY } = getComputedStyle(node)
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node
}
return null
}
function revealAboveKeyboard(field: TextField): void {
const vv = window.visualViewport
if (!vv) return
const hidden = field.getBoundingClientRect().bottom - (vv.offsetTop + vv.height) + KEYBOARD_MARGIN
if (hidden <= 0) return
const box = scrollParent(field)
if (box && box.scrollTop + box.clientHeight < box.scrollHeight) box.scrollBy({ top: hidden, behavior: 'smooth' })
else window.scrollBy({ top: hidden, behavior: 'smooth' })
}
export function installIosKeyboardFocus(): void {
if (!/iP(hone|ad|od)/.test(navigator.userAgent)) return
const nativeFocus = HTMLElement.prototype.focus
let userTap = false
// 规则 2:代码触发的聚焦在 iOS 上跳过(autoFocus 底层也走这里)
HTMLElement.prototype.focus = function (options?: FocusOptions) {
if (isTextField(this) && !userTap) return
nativeFocus.call(this, options)
}
let startX = 0
let startY = 0
document.addEventListener('touchstart', (e) => {
startX = e.touches[0]?.clientX ?? 0
startY = e.touches[0]?.clientY ?? 0
}, { capture: true, passive: true })
document.addEventListener('touchend', (e) => {
// 从输入框上起手的滑动不是点击
const end = e.changedTouches[0]
if (!end || Math.hypot(end.clientX - startX, end.clientY - startY) > 10) return
// 点 <label> 文字也会聚焦它的输入框,一并接管
const label = e.target instanceof Element ? e.target.closest('label') : null
const field = isTextField(e.target) ? e.target : label?.control
if (!isTextField(field) || e.touches.length) return
const vv = window.visualViewport
// 键盘已经弹着时再点同一个框是在挪光标,交还给系统
if (document.activeElement === field && vv && vv.height < window.innerHeight - 100) return
e.preventDefault()
// 已聚焦但键盘被收起时,再点同样会触发 iOS 滚动,先 blur 走同一条路径
if (document.activeElement === field) field.blur()
// 规则 1:聚焦那一帧挪出屏幕,iOS 就不滚根文档
field.style.transform = 'translateY(-2000px)'
userTap = true
nativeFocus.call(field, { preventScroll: true })
userTap = false
requestAnimationFrame(() => { field.style.transform = '' })
// 规则 3:键盘弹出时 visualViewport 会 resize;键盘本已显示时不会,再兜一个定时
let done = false
const reveal = () => { if (!done) { done = true; revealAboveKeyboard(field) } }
window.visualViewport?.addEventListener('resize', reveal, { once: true })
window.setTimeout(reveal, 600)
}, { capture: true, passive: false })
}在应用入口、首屏渲染前调用一次(React 示例,其他框架同理):
import { installIosKeyboardFocus } from './iosKeyboardFocus'
installIosKeyboardFocus()代价(可接受,React Aria 同样如此):首次点输入框时光标落在文字末尾而不是点击处;键盘弹着时再点就恢复系统的光标定位,长按也照常可用。
接入时要确认的
| # | 要素 | 怎么定 |
|---|---|---|
| 1 | 哪些 input type 算“会弹键盘” | 默认 text/search/email/url/tel/password/number + textarea。用了 contenteditable 编辑器、date 等,按需加进 isTextField |
| 2 | 入口位置 | 全项目只调一次,放在应用挂载前。多页站点每页都要引到 |
| 3 | 有没有依赖“程序聚焦弹键盘”的交互 | grep -rn "autoFocus|\.focus("。规则 2 会让它们在 iOS 上不弹键盘。确实要弹的(如聊天页进入即输入),产品上确认后在 focus 补丁里按选择器白名单放行 |
| 4 | 贴底的输入框 | 规则 3 会兜底滚出来;但如果它是 position: fixed; bottom: 0 且外面没滚动容器,只能退回滚根文档,会有一次位移,属于正常行为 |
| 5 | 16px 会不会撑破设计 | 在 375px 宽下逐个看输入框;挤的话调 padding/高度,不要把字号降回去 |
| 6 | Android | 方案只在 iOS 生效(UA 判断)。Android Chrome 默认按 interactive-widget=resizes-visual 处理,一般无此病 |
验收
iPhone 真机,iOS Safari 与 iOS Chrome 各一遍,逐个过所有会弹键盘的地方(弹窗搜索框、页面搜索框、密码框、行内改名……):
已证明无效的做法
这些都上过真机:
- 用
transform: translateY(visualViewport.offsetTop)反向补偿 fixed 弹窗:真机量到补偿根本没生效(弹窗getBoundingClientRect().top恒为 0)。 - iOS 滚完后再
scrollTo把根文档钉回:和 iOS 自己的滚动打架,点输入框闪一下、键盘弹不出来,比原来更糟。要在滚动发生前让 iOS 判定不需要滚,不是事后纠正。 - 把输入框挪高 / 删副标题让它离键盘远一点:改了用户能看到的布局,而且根因不是输入框位置。
- viewport 加
maximum-scale=1/user-scalable=no防放大:会锁死用户手动缩放,用 16px。 - 只在弹窗里接管:页面里的搜索框、设置里的密码框照样跳,接管要全站统一。
- 只去掉某个弹窗的
autoFocus:其他autoFocus/.focus()照样乱弹键盘、照样跳,要在focus原型上统一拦。
小结
先在真机把 scrollY / visualViewport.offsetTop / height 打回终端量出来,一眼就能看出是“iOS 滚了根文档”;然后去找成熟库(React Aria usePreventScroll)怎么做,而不是自己补偿或事后纠正。
