原文: iOS 键盘难题与可见视口(VisualViewport)API | AlloyTeam
做者: TAT.rikumi
Web 开发者与 iOS 长达四年的较量,终于在 iOS 13 发布这一刻落下帷幕。
2015 年三月,iOS 发布了 8.2 版本。这在当时看来也许只是这个现代的操做系统的一次小更新,但在 Web 开发者眼里,有些微妙的问题产生了。这是一件在 Android 世界里想象不到的麻烦事儿。html
在此以前 Web 开发者都很是清楚,在 window
全局对象上的 innerWidth
/innerHeight
表示浏览器窗口中能够看到页面的区域的尺寸,而 outerWidth
/outerHeight
表示浏览器窗口总体的尺寸。能够看到页面的区域又被称为「视口」(Viewport),在 CSS 的世界里,任何 position: fixed
的元素都会脱离文档流并以视口为基准进行定位,以便在页面滚动时让这些元素相对于窗口固定,例如桌面 Web 设计中常见的头部、侧边栏、「返回顶部」按钮等等。前端
但是从 iOS 8.2 开始,这些概念开始不那么灵了。react
<!--more-->git
iOS 8.2 之后,也许是为了知足设计上的磨砂半透明键盘后面能有点东西,达到若隐若现的效果,又或者是由于交互体验上,不想由于键盘动画上推过程当中发生屡次从新渲染,iOS 惟一指定浏览器内核、Webkit 鼻祖 Safari 将 fixed
元素的布局基准区域从键盘上方的可见区域改为了键盘背后的整个视窗。github
上图是对于通常状况的呈现。当你使用其余传统设备访问一个页面时(如左图),滚动到某个位置(紫色边框线的顶部)后,使用双指放大到一个小区域内(图中「可视区域」+「不透明键盘」的区域),而后点击某个输入框开始编写文字。此时,窗口(window
对象)会产生一次 resize
事件,因为键盘的挤压,fixed
元素的基准区域会变成紫色边框线标注的区域。浏览器
在 iOS 8.2+ 设备中(如右图),滚动到某个位置后,使用双指放大到一个小区域内(图中「可视区域」+「半透明键盘」的区域),而后点击某个输入框开始编写文字,此时 window
对象再也不产生 resize
事件,CSS 和 JS 都无从得知软键盘的开启,更不知道键盘占据了多少区域,所以,fixed
元素的基准区保留在右图紫色区域,再也不变化。前端工程师
由于上图是一种通常状况,这里考虑了放大,彷佛从肉眼看来,可视区域内的布局没有受到什么影响。但在现代移动端 Web 设计中,咱们经常使用 Viewport Meta Tag 以及屏蔽多点触摸和双击手势等方式来禁止放大页面,此时问题就会凸显出来:工具
进入移动互联网时代以后,咱们在手机上浏览的页面更多变成了专为移动设备设计的页面,它们狭长、不须要放大就适合阅读。这时,在其余传统设备上,键盘弹起后,window
对象发生 resize
,全部 fixed
布局的元素自动被推至键盘上方的区域以内;而到了 iOS 8.2 的设备上,键盘弹起后,window
对象再也不发生 resize
,fixed
元素也保留在原来的位置,丝毫注意不到键盘的存在。布局
这对于普通的 Web 应用来讲不会带来太大的影响,但对于一些须要追求特殊交互的应用来讲,打击是巨大的。最大的问题在于,再也没有东西能够牢靠地吸附在键盘上方了,不管是一行提示语、一条工具栏,仍是一个自动完成列表,都再也作不到了。性能
正如上图右侧所呈现的,当键盘弹起时,页面没法感知到键盘的存在。那么,若是将要输入的目标(即「输入框」,例如 input
、textarea
或通常的 contenteditable
元素)正好被弹起的键盘遮住,体验不会很糟糕吗?
iOS 的设计者想到了这一点,而后它们以一个聪明的方式解决了:滚动。
像上图这样,点击输入框开始输入时,键盘动画弹起的过程当中,页面会随之一块儿滚动(若是知足必定的条件也会同时进行缩放,此处忽略这种状况),但滚动的结果有些出乎意料:输入框自己能够理解地滚动到了实际可视区域的正中间,但 fixed
元素不会发生从新计算,而是保持原来的相对位置,跟着输入框一块儿被上推;在滚动过程当中,还会容许屏幕底部超出页面底部(「滚动过头」),以便让输入框尽量露出来。收起键盘后,「滚动过头」的部分会被弹回,fixed
元素发生从新计算,但页面并不会回到与打开键盘前相同的位置。
这看起来并无太多问题,但这里的问题是:假如咱们有一个单屏 Web 应用,即将 html
元素设置为 overflow: hidden
,问题就会变成这样:
打开键盘前,页面处于不可滚动的状态,这彻底符合咱们的预期;但打开键盘后,不管键盘是否遮住输入框,页面变得可滚动了。换句话说,视口(Viewport)这个概念在这样的状况下居然「悬空」,与屏幕上实际的显示区域脱离,而且能够上下滚动起来。这个滚动能够经过阻止 touchmove
事件的默认行为来屏蔽,但键盘刚刚弹出时,仍然会自动向上滚动那一大段距离。
更加瓜熟蒂落却又没法接受的问题是,假如刚好页面内有不当心垂直溢出的内容的话,当键盘收起后,进入了一个「奇怪的状态」:明明没法滚动的 html
区域,却显示了向下滚动一段距离后的内容(例如,底部出现大量留白),且由于 overflow: hidden
的做用而没法滚动回来。
在不少不便使用 100% 的状况下,咱们会在 CSS 中使用 100vh 的的概念来表明视口高度,而这个高度在 Safari 中彷佛是表示工具栏自动收起时,视口的
最大高度,所以会致使 100vh 高度的元素极可能已经溢出了
html
区域。这也是这里会提到单屏 Web 应用的页面中可能会存在垂直溢出内容的主要缘由。
有必要提到,若是咱们在这样的「奇怪状态」下,依然认为页面是单屏不会滚动的页面,而继续使用触摸事件到屏幕/视口顶部的距离(screenY
或 clientY
)来参与一些比较复杂的逻辑计算的话,会致使触摸的位置与换算到页面上须要响应的位置之间存在误差。
在 iOS 13 出现以前,fixed 不可靠问题是没法解决的,除非在 Native 侧对 WKWebView
的 scrollView
作一些判断,并经过 JS API 暴露给 Web —— 但把 Web 应用的能力限制在某个特定的客户端内,是一件很不优雅的事情。
针对键盘打开时发生强制滚动且没法手动滚回的问题(难题 2),有三种可行的解决思路:
这是一种较为通用且简便易行的办法:在输入目标(input
等)发生 touchend
时,阻止默认行为,提早从新布局,将输入框移到不太可能被键盘遮挡的位置(固然,具体多高才不受遮挡,当时只能靠猜),而后当即调用 focus()
方法主动聚焦输入框。
但键盘打开后,仍然须要使用防止滚动的措施(阻止整个页面上 touchmove
的默认行为),来防止用户手动将页面上推。
在键盘弹起的瞬间(focus
事件的下一个宏任务周期),咱们能够从 window.scrollY
得知页面滚动的目标位置。很容易想到,此时咱们能够经过 window.scrollTo(0, 0)
来恢复到原位置,但在实际尝试中,咱们会发现,这样处理会致使页面总体向下瞬移,而后再逐渐移回到屏幕上。
这是为何呢?咱们能够用上面这张图来解释。在以前的图中咱们看到了,iOS 对键盘弹出时的视口处理是浮动的,所以咱们能够大胆猜想,在键盘弹起的瞬间,视口事实上发生了瞬移。 在页面 window.scrollY
变成目标值的同时,视口瞬移到页面下方一样的距离,这使得从肉眼看起来,页面依然处于原来的位置。随后,视口带着页面开始一块儿上移,直到再次与屏幕重合,产生了页面被强制滚动的效果,而在此过程当中 window.scrollY
并不会逐渐变化,而是只在开始的一瞬间发生变化。所以,若是咱们直接在键盘打开时执行 window.scrollTo(0, 0)
,页面会跟随视口一同瞬移到较低的位置,而后随视口一块儿回到屏幕上。
换句话说,键盘打开时的强制滚动并不是 window.scrollTo
的 smooth
模式,而是由 iOS Native 的滚动容器来驱动的。只要在 focus
的瞬间,键盘可能会遮住输入框,咱们就没法阻止强制滚动的发生和进行。
既然咱们没法阻止,咱们能够用一个反向滚动的动画来抵消它。以聚焦后的 window.scrollY
为起点,聚焦前的 window.scrollY
(一般为 0)为终点,构造与 iOS Spring Animation 相反的缓动曲线,用向下滚动的动画抵消向上滚动的动画,能够容许输入框在键盘弹起时被遮住,而页面只会发生轻微的抖动。
咱们的目的固然不是让键盘遮住输入框,而是首先保证页面不受强制滚动的影响。所以,在执行反向滚动后,一样能够将输入框的位置移动到可视范围以内,避开键盘。
使用这种方案,一样须要配合上面所说的防止手动滚动的措施。
上面两种方案是针对于不但愿强制滚动的状况。若是能够容许键盘弹起时强制滚动,但但愿键盘收起时回到原位,只须要在键盘收起的 blur
事件中,使用 window.scrollTo
让页面回到原位置便可。
昨天,我在 Google 搜索 iOS Safari 的键盘问题,已经不知道是第几回这样绝望地寻找了,直到我找到了这篇 Safari 13, Mobile Keyboards, And The VisualViewport API.。文章指出,Safari 13(iOS 13)已经支持了 VisualViewport API,这是一个能够反映实际可视区域的实验性标准。根据 MDN 页面,目前只有 IE 和 Legacy Edge 不支持这个 API。
通过测试,iOS 13 对于这个 API 支持很是完善,已经可以彻底体现页面上不含键盘的可视区域所在的位置了。但是,明明只有 iOS 8.2 不会报告键盘弹出,为什么却有一个跨平台的 API 来补偿呢?其余浏览器有 window.innerWidth
、window.innerHeight
和 resize
事件不是就足够好了吗?
这就须要回归到本文的第一张图片来解释了:
没错,问题在于页面缩放。能够看出,当页面发生放大后,fixed 元素是不会一块儿移动到实际可视区域的。并且通过测试发现,Android 下的 window.innerWidth
、window.innerHeight
也不会随页面放大而一块儿变化。反而在 iOS 下,window.innerWidth
、window.innerHeight
会随着页面放大而等比例减少,虽然不会去掉键盘高度,但确实反映了显示在屏幕内的页面区域尺寸。
而 VisualViewport API 在 Android 和 iOS 两端,都完整反映了在缩放和键盘弹出等一系列影响下,实际可视区域在页面中的位置和大小。
所以,VisualViewport API 对于 iOS 之外的平台,最大的意义是能够反映页面的放大区域;而对于 iOS Safari 浏览器,最大的意义是能够反映键盘的弹出。 基于这一点,咱们能够实现一个真正相对于可视区域 fixed(固定)的 fixed
容器。
如何实现一个 fixed
容器?关于这一点,也许有一部分 Web 开发者并不知情。在 Web 开发者的直觉中,fixed
元素是始终相对于视口定位,没有任何一个元素可以改变它的定位方式;但事实上,问题却有些不一样。
若是你曾经使用过一些性能优良的滚动容器,如 iScroll、BetterScroll、AlloyTouch 等,你可能会遇到这样一个问题:fixed
「不灵了」,它们可能再也不相对于视口定位,而是被限制在了滚动容器以内。
这是由于,在滚动容器常常会遇到的性能瓶颈中,组件的开发者一般会选择 CSS 3D Transform 来强制硬件加速,让滚动体验更顺畅。在开启了 3D Transform 的容器内,因为渲染限制,fixed
元素没法再相对于视口布局,而是被「圈」在了 3D Transform 容器以内。咱们只须要反其道而行之,给一个容器开启 3D Transform,就可让内部的 fixed
元素相对于该容器布局了。
下面咱们以 React 为例,实现一个能够兼容 Android/iOS 13+,始终贴着可视区域的 VisualViewport 组件。
因为我目前使用的 TypeScript 3.7.5 尚未定义 VisualViewport API,首先咱们须要手动进行类型抹平。
interface VisualViewport extends EventTarget { width: number; height: number; scale: number; offsetTop: number; offsetLeft: number; pageTop: number; pageLeft: number; } // eslint-disable-next-line declare global { interface Window { visualViewport?: VisualViewport; } }
在组件中,咱们对于支持 VisualViewport API 的平台使用 VisualViewport API,对于不支持的平台可使用 window.innerWidth
/window.innerHeight
进行兼容。
import * as React from 'react'; interface VisualViewportComponentProps { className?: string; style?: React.CSSProperties; } interface VisualViewportComponentState { visualViewport: VisualViewport | null; windowInnerWidth: number; windowInnerHeight: number; } export default class VisualViewportComponent extends React.Component<{}, VisualViewportComponentState> { state: VisualViewportComponentState = { visualViewport: null, windowInnerWidth: window.innerWidth, windowInnerHeight: window.innerHeight, } componentDidMount() { // TODO: 挂载事件监听器 } componentWillUnmount() { // TODO: 卸载事件监听器 } getStyles(): React.CSSProperties { // TODO: 根据 state 计算样式 return {}; } render() { return <div className={'visual-viewport ' + (this.props.className || '')} style={this.getStyles()}> {this.props.children} </div>; } }
经过监听 window.visualViewport
的 resize
和 scroll
事件以及 window
的 resize
事件,咱们将可见视口和实际视口的尺寸变化转化为组件内的 state 变化,以便触发重渲染。
componentDidMount() { if (typeof window.visualViewport !== 'undefined') { window.visualViewport.addEventListener('resize', this.onVisualViewportChange); window.visualViewport.addEventListener('scroll', this.onVisualViewportChange); } window.addEventListener('resize', this.onResize); } componentWillUnmount() { if (typeof window.visualViewport !== 'undefined') { window.visualViewport.removeEventListener('resize', this.onVisualViewportChange); window.visualViewport.removeEventListener('scroll', this.onVisualViewportChange); } window.removeEventListener('resize', this.onResize); } onVisualViewportChange = (e: Event) => { this.setState({ visualViewport: e.target as VisualViewport || window.visualViewport }); } onResize = () => { this.setState({ windowInnerWidth: window.innerWidth, windowInnerHeight: window.innerHeight }); }
下面,咱们根据 state 中提供的可见视口和实际视口尺寸,对可见视口在实际视口中的相对位置进行计算,并应用到组件容器的样式中。
getStyles() { const { visualViewport, windowInnerWidth, windowInnerHeight, } = this.state; // 开启 3D Transform,让 fixed 的子元素相对于容器定位 // 同时自身也设置为 fixed,以便在非放大状况下不须要频繁移动位置 const styles: React.CSSProperties = { position: 'fixed', transform: 'translateZ(0)', ...this.props.style || {} }; // 支持 VisualViewport API 状况下直接计算 if (visualViewport != null) { // 须要针对 iOS 越界弹性滚动的状况进行边界检查 styles.left = Math.max(0, Math.min( document.documentElement.scrollWidth - visualViewport.width, visualViewport.offsetLeft )) + 'px'; // 须要针对 iOS 越界弹性滚动的状况进行边界检查 styles.top = Math.max(0, Math.min( document.documentElement.scrollHeight - visualViewport.height, visualViewport.offsetTop )) + 'px'; styles.width = visualViewport.width + 'px'; styles.height = visualViewport.height + 'px'; } else { // 不支持 VisualViewport API 状况下(如 iOS 8~12) styles.top = '0'; styles.left = '0'; styles.width = windowInnerWidth + 'px'; styles.height = windowInnerHeight + 'px'; } return styles; }
通过这样的实现,咱们的组件能够在支持的浏览器中正肯定位到当前可见视口的位置(上图中的靛蓝色区域),并将内部的元素以可见视口为基准进行定位。对于移动端 Web 应用来讲,这样的组件有不少用途,例如吸附键盘的工具栏或自动完成列表、须要避开键盘居中的对话框等等。值得一提的是,在 PC 浏览器上,这个 API 也一样适用(能够响应页面的放大)。
在 iOS 下,这样的实现还存在一些迟钝和小 bug(例如,键盘展开后的强制滚动状态下向上滑动,能够露出不管是 Viewport 仍是 VisualViewport 都没法到达的白色衬底区域)。
但至少,在 iOS 8.2 发布四年后,iOS 13 对 VisualViewport 的支持,让获取键盘高度、避开键盘、吸附键盘这三件事终于有了相对优雅的办法。
AlloyTeam 欢迎优秀的小伙伴加入。
简历投递: alloyteam@qq.com
详情可点击 腾讯AlloyTeam招募Web前端工程师(社招)