如何巧妙应对iOS键盘难题?
前言
写过移动端的同学或多或少都遇到过软键盘带来的各种各样的问题,最典型的就是输入框被软键盘遮挡、fixed元素失效等问题,并且这些问题在iOS上的表现让人难以接受。
webview的差异
在移动端上,我们的H5页面一般是运行在宿主APP提供的webview
中,简单点理解,你其实可以把它当作浏览器,就是用来展现页面内容的。目前移动端主流系统分为Android
与iOS
,然而两者提供的webview
容器也存在着诸多差异,今天我们就只探讨两者软键盘带来的影响。
首先,我们先来写个简单的页面布局:头部fixed+中间自适应+底部fixed
![k2.png](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411105930881-229208537.png)
Android
事实上Android的表现并不会有太大问题,它只不过是在键盘弹起来之后把webview
的高度减小了,变成了:原来的webview高度减去键盘的高度
![k2.png](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411110120218-774213835.png)
这样的表现正是我们期待的,完全没有影响整个页面的布局
iOS
软键盘
在iOS 8.2 之后,iOS 唯一指定浏览器内核、Webkit 鼻祖 Safari 将 fixed
元素的布局基准区域从键盘上方的可见区域改成了键盘背后的整个视窗,也就是说此时的webview高度并不会发生变化,键盘是直接盖在webview上方的。
这样是为了在键盘弹起来之后,不用重新渲染页面,他们是方便了,但遭殃的是我们前端开发人员...
比如上面这个页面,我们看看iOS的表现是怎样的:
![k3.png](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411110300340-811223716.png)
可以看到,iOS为了不让webview
压缩,并且为了不让软键盘遮挡输入框,他们自作聪明地把webview
整体往上移动,最大移动距离为软键盘的高度。
这样就导致我们的头部以及页面上半部分内容移动到了可视区之外,这个表现是难以接受的,至少头部应该还要在可视区。(这就会让我们误以为fixed失效,实际上它相对于webview的位置并没有变,只不过是webview发生了移动)
这个移动似乎没有逻辑,不信大家可以试试把输入框放到页面的各个位置,我发现只有输入框在最顶部,webview
才不会发生上移,其它位置都或多或少的会产生移动。
还有一个问题就是,此时的webview是可以滑动的,那么就会出现有用户会将输入框滑动到键盘下方,想想这个体验也是难以接受的...
![k3-1.gif](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411110830430-857913599.gif)
并且你会发现,在页面的上方与下方都多出了一个不论是 Viewport
还是 VisualViewport
都无法到达的白色衬底区域,我们可以尝试把页面所有元素背景都改成黑色再来看,会更加明显
![k3-2.gif](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411111043025-1277094285.gif)
看到这些奇奇怪怪的问题你心里作何感想??
所有问题产生的根本原因是:iOS为了不用在键盘弹起之后重新渲染页面,他们并没有去压缩webview
容器的高度,而是对webview整体进行平移处理
软键盘监听
对于Android,我们通常可以通过监听resize
事件来实现,但对于iOS,我们从上面了解到键盘弹起,iOS的webview
高度并不会发生变化,所以也就触发不了resize
事件。
在iOS中,可以通过focusin & focusout
事件来进行监听
export const watchKeyBoard = (callback: (isShow: boolean) => void) => {
// IOS
if (isIOSByUA()) {
document.body.addEventListener('focusin', () => {
//软键盘弹出的事件处理
callback(true)
})
document.body.addEventListener('focusout', () => {
//软键盘收起的事件处理
callback(false)
})
} else {
// Android
const originalHeight =
document.documentElement.clientHeight || document.body.clientHeight
window.addEventListener('resize', () => {
const resizeHeight =
document.documentElement.clientHeight || document.body.clientHeight
if (resizeHeight - 0 < originalHeight - 0) {
// 键盘弹起事件
callback(true)
} else {
// 键盘收起事件
callback(false)
}
})
}
}
解决方案
了解完产生问题的原因,我们就可以来尝试着解决问题,但想要纯前端去解决这个问题,或多或少都会存在一些体验问题,也许你可以去推动你们的客户端同学来协助处理这个问题,只要让iOS的webview在键盘弹起时的表现与Android一致,就不会存在这些奇怪的问题了,但似乎他们处理起来也非常棘手...
模仿Android的处理
虽然我们改不了webview的高度,但我们可以改我们布局的高度,我们只需要将页面高度改为页面可视区的高度即可,如果页面内容有滚动交互的话,需要额外处理,要与webview的滚动隔离开。
VisualViewport
先来了解下这个API,它可以用来获取对应 window 的视觉视口
VisualViewport.offsetLeft
:返回视觉视口的左边框到布局视口的左边框的 CSS 像素距离。VisualViewport.offsetTop
:返回视觉视口的上边框到布局视口的上边框的 CSS 像素距离。VisualViewport.pageLeft
:返回相对于初始的 viewport 属性的 X 轴坐标所对应的 CSS 像素数。VisualViewport.pageTop
:返回相对于初始的 viewport 属性的 Y 轴坐标所对应的 CSS 像素数。VisualViewport.width
:返回视觉视口的宽度所对应的 CSS 像素数。VisualViewport.height
:返回视觉视口的高度所对应的 CSS 像素数。VisualViewport.scale
:返回当前视觉视口所应用的缩放比例。
这里我们需要的就是这个VisualViewport.height
,用来获取可视区的高度。
但需要注意的是,这个API最低只支持iOS13,ios13以下的使用window.innerHeight
兜底
页面布局
整体布局采用flex布局,头部和底部也就不需要fixed来定位了,中间自适应撑满剩余高度,超长滚动
![k4.png](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411111247323-1355442802.png)
键盘打开计算高度重新布局
我们需要在键盘弹起后,计算可视区的高度,并将最外层容器高度赋值为可视区高度
watchKeyBoard((status) => {
setTimeout(() => {
console.log(
'status',
status ? '键盘打开' : '键盘关闭',
)
const container = document.getElementById('container')
if (status) {
container.style.height = `${
window.visualViewport.height || window.innerHeight
}px`
window.scrollTo(0, 0)
} else {
container.style.height = `100vh`
document.removeEventListener('touchmove', this.stopMove)
}
}, 100)
})
这样页面展示算是正常了
![k5.png](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411111332370-79461169.png)
但是随之而来的是滚动问题😓
![k6.gif](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411111410914-1894145969.gif)
处理滚动
我们需要禁用全局的滚动,但对一些需要滚动的区域需要放开,比如中间的列表部分
if (utils.isIOSByUA()) {
watchKeyBoard((status) => {
setTimeout(() => {
console.log(
'status',
status ? '键盘打开' : '键盘关闭',
window.innerHeight,
)
const container = document.getElementById('container')
if (status) {
container.style.height = `${
window.visualViewport.height || window.innerHeight
}px`
window.scrollTo(0, 0)
document.addEventListener('touchmove', this.stopMove, {
passive: false,
})
document.addEventListener('touchend', this.scroll)
} else {
container.style.height = `100vh`
document.removeEventListener('touchmove', this.stopMove)
document.removeEventListener('touchend', this.scroll)
}
}, 100)
})
}
stopMove(e) {
// 排除可以滚动的区域
if (['content', 'keyboard_center'].includes(e.target?.className)) return
e.preventDefault()
}
scroll() {
window.scrollTo(0, 0)
}
完整体验如下
![k7.gif](https://img2024.cnblogs.com/blog/1525717/202404/1525717-20240411111458636-1204200256.gif)
比起它原本带来的遮挡、滚动、fixed失效等体验,现在的体验算是可以接受的(这里所有的操作我们只需要在iOS上执行即可)