iOS 鍵盤難題與可見視口(VisualViewport)API

原文:iOS 鍵盤難題與可見視口(VisualViewport)API | AlloyTeam
做者:TAT.rikumihtml

Web 開發者與 iOS 長達四年的較量,終於在 iOS 13 發佈這一刻落下帷幕。前端

iOS 8.2 和它的鍵盤難題

2015 年三月,iOS 發佈了 8.2 版本。這在當時看來也許只是這個現代的操做系統的一次小更新,但在 Web 開發者眼裏,有些微妙的問題產生了。這是一件在 Android 世界裏想象不到的麻煩事兒。react

在此以前 Web 開發者都很是清楚,在 window 全局對象上的 innerWidth/innerHeight 表示瀏覽器窗口中能夠看到頁面的區域的尺寸,而 outerWidth/outerHeight 表示瀏覽器窗口總體的尺寸。能夠看到頁面的區域又被稱爲「視口」(Viewport),在 CSS 的世界裏,任何 position: fixed 的元素都會脫離文檔流並以視口爲基準進行定位,以便在頁面滾動時讓這些元素相對於窗口固定,例如桌面 Web 設計中常見的頭部、側邊欄、「返回頂部」按鈕等等。git

但是從 iOS 8.2 開始,這些概念開始不那麼靈了。github

難題一:不可靠的 fixed

image

iOS 8.2 之後,也許是爲了知足設計上的磨砂半透明鍵盤後面能有點東西,達到若隱若現的效果,又或者是由於交互體驗上,不想由於鍵盤動畫上推過程當中發生屢次從新渲染,iOS 惟一指定瀏覽器內核、Webkit 鼻祖 Safari 將 fixed 元素的佈局基準區域從鍵盤上方的可見區域改爲了鍵盤背後的整個視窗。瀏覽器

上圖是對於通常狀況的呈現。當你使用其餘傳統設備訪問一個頁面時(如左圖),滾動到某個位置(紫色邊框線的頂部)後,使用雙指放大到一個小區域內(圖中「可視區域」+「不透明鍵盤」的區域),而後點擊某個輸入框開始編寫文字。此時,窗口(window 對象)會產生一次 resize 事件,因爲鍵盤的擠壓,fixed 元素的基準區域會變成紫色邊框線標註的區域。前端工程師

在 iOS 8.2+ 設備中(如右圖),滾動到某個位置後,使用雙指放大到一個小區域內(圖中「可視區域」+「半透明鍵盤」的區域),而後點擊某個輸入框開始編寫文字,此時 window 對象再也不產生 resize 事件,CSS 和 JS 都無從得知軟鍵盤的開啓,更不知道鍵盤佔據了多少區域,所以,fixed 元素的基準區保留在右圖紫色區域,再也不變化。工具

由於上圖是一種通常狀況,這裏考慮了放大,彷佛從肉眼看來,可視區域內的佈局沒有受到什麼影響。但在現代移動端 Web 設計中,咱們經常使用 Viewport Meta Tag 以及屏蔽多點觸摸和雙擊手勢等方式來禁止放大頁面,此時問題就會凸顯出來:佈局

image

進入移動互聯網時代以後,咱們在手機上瀏覽的頁面更多變成了專爲移動設備設計的頁面,它們狹長、不須要放大就適合閱讀。這時,在其餘傳統設備上,鍵盤彈起後,window 對象發生 resize,全部 fixed 佈局的元素自動被推至鍵盤上方的區域以內;而到了 iOS 8.2 的設備上,鍵盤彈起後,window 對象再也不發生 resizefixed 元素也保留在原來的位置,絲毫注意不到鍵盤的存在。性能

這對於普通的 Web 應用來講不會帶來太大的影響,但對於一些須要追求特殊交互的應用來講,打擊是巨大的。最大的問題在於,再也沒有東西能夠牢靠地吸附在鍵盤上方了,不管是一行提示語、一條工具欄,仍是一個自動完成列表,都再也作不到了。

難題二:自做聰明的頁面上推

正如上圖右側所呈現的,當鍵盤彈起時,頁面沒法感知到鍵盤的存在。那麼,若是將要輸入的目標(即「輸入框」,例如 inputtextarea 或通常的 contenteditable 元素)正好被彈起的鍵盤遮住,體驗不會很糟糕嗎?

iOS 的設計者想到了這一點,而後它們以一個聰明的方式解決了:滾動。

image

像上圖這樣,點擊輸入框開始輸入時,鍵盤動畫彈起的過程當中,頁面會隨之一塊兒滾動(若是知足必定的條件也會同時進行縮放,此處忽略這種狀況),但滾動的結果有些出乎意料:輸入框自己能夠理解地滾動到了實際可視區域的正中間,但 fixed 元素不會發生從新計算,而是保持原來的相對位置,跟着輸入框一塊兒被上推;在滾動過程當中,還會容許屏幕底部超出頁面底部(「滾動過頭」),以便讓輸入框儘量露出來。收起鍵盤後,「滾動過頭」的部分會被彈回,fixed元素髮生從新計算,但頁面並不會回到與打開鍵盤前相同的位置

這看起來並無太多問題,但這裏的問題是:假如咱們有一個單屏 Web 應用,即將 html 元素設置爲 overflow: hidden,問題就會變成這樣:

image

打開鍵盤前,頁面處於不可滾動的狀態,這徹底符合咱們的預期;但打開鍵盤後,不管鍵盤是否遮住輸入框,頁面變得可滾動了。換句話說,視口(Viewport)這個概念在這樣的狀況下居然「懸空」,與屏幕上實際的顯示區域脫離,而且能夠上下滾動起來。這個滾動能夠經過阻止 touchmove 事件的默認行爲來屏蔽,但鍵盤剛剛彈出時,仍然會自動向上滾動那一大段距離。

更加瓜熟蒂落卻又沒法接受的問題是,假如剛好頁面內有不當心垂直溢出的內容的話,當鍵盤收起後,進入了一個「奇怪的狀態」:明明沒法滾動的 html 區域,卻顯示了向下滾動一段距離後的內容(例如,底部出現大量留白),且由於 overflow: hidden 的做用而沒法滾動回來。

在不少不便使用 100% 的狀況下,咱們會在 CSS 中使用 100vh 的的概念來表明視口高度,而這個高度在 Safari 中彷佛是表示工具欄自動收起時,視口的最大高度,所以會致使 100vh 高度的元素極可能已經溢出了 html 區域。這也是這裏會提到單屏 Web 應用的頁面中可能會存在垂直溢出內容的主要緣由。

有必要提到,若是咱們在這樣的「奇怪狀態」下,依然認爲頁面是單屏不會滾動的頁面,而繼續使用觸摸事件到屏幕/視口頂部的距離(screenYclientY)來參與一些比較複雜的邏輯計算的話,會致使觸摸的位置與換算到頁面上須要響應的位置之間存在誤差。

以往的解決辦法

在 iOS 13 出現以前,fixed 不可靠問題是沒法解決的,除非在 Native 側對 WKWebViewscrollView 作一些判斷,並經過 JS API 暴露給 Web —— 但把 Web 應用的能力限制在某個特定的客戶端內,是一件很不優雅的事情。

針對鍵盤打開時發生強制滾動且沒法手動滾回的問題(難題 2),有三種可行的解決思路:

1. 主動避開鍵盤後再聚焦

image

這是一種較爲通用且簡便易行的辦法:在輸入目標(input 等)發生 touchend 時,阻止默認行爲,提早從新佈局,將輸入框移到不太可能被鍵盤遮擋的位置(固然,具體多高才不受遮擋,當時只能靠猜),而後當即調用 focus() 方法主動聚焦輸入框。

但鍵盤打開後,仍然須要使用防止滾動的措施(阻止整個頁面上 touchmove 的默認行爲),來防止用戶手動將頁面上推。

2. 反向滾動

image

在鍵盤彈起的瞬間(focus 事件的下一個宏任務週期),咱們能夠從 window.scrollY 得知頁面滾動的目標位置。很容易想到,此時咱們能夠經過 window.scrollTo(0, 0) 來恢復到原位置,但在實際嘗試中,咱們會發現,這樣處理會致使頁面總體向下瞬移,而後再逐漸移回到屏幕上。

這是爲何呢?咱們能夠用上面這張圖來解釋。在以前的圖中咱們看到了,iOS 對鍵盤彈出時的視口處理是浮動的,所以咱們能夠大膽猜想,在鍵盤彈起的瞬間,視口事實上發生了瞬移。 在頁面 window.scrollY 變成目標值的同時,視口瞬移到頁面下方一樣的距離,這使得從肉眼看起來,頁面依然處於原來的位置。隨後,視口帶着頁面開始一塊兒上移,直到再次與屏幕重合,產生了頁面被強制滾動的效果,而在此過程當中 window.scrollY 並不會逐漸變化,而是隻在開始的一瞬間發生變化。所以,若是咱們直接在鍵盤打開時執行 window.scrollTo(0, 0),頁面會跟隨視口一同瞬移到較低的位置,而後隨視口一塊兒回到屏幕上。

換句話說,鍵盤打開時的強制滾動並不是 window.scrollTosmooth 模式,而是由 iOS Native 的滾動容器來驅動的。只要在 focus 的瞬間,鍵盤可能會遮住輸入框,咱們就沒法阻止強制滾動的發生和進行。

image

既然咱們沒法阻止,咱們能夠用一個反向滾動的動畫來抵消它。以聚焦後的 window.scrollY 爲起點,聚焦前的 window.scrollY(一般爲 0)爲終點,構造與 iOS Spring Animation 相反的緩動曲線,用向下滾動的動畫抵消向上滾動的動畫,能夠容許輸入框在鍵盤彈起時被遮住,而頁面只會發生輕微的抖動。

咱們的目的固然不是讓鍵盤遮住輸入框,而是首先保證頁面不受強制滾動的影響。所以,在執行反向滾動後,一樣能夠將輸入框的位置移動到可視範圍以內,避開鍵盤。

使用這種方案,一樣須要配合上面所說的防止手動滾動的措施。

3. 收起鍵盤時恢復原位

上面兩種方案是針對於不但願強制滾動的狀況。若是能夠容許鍵盤彈起時強制滾動,但但願鍵盤收起時回到原位,只須要在鍵盤收起的 blur 事件中,使用 window.scrollTo 讓頁面回到原位置便可。

iOS 13 VisualViewport API 與新思路

昨天,我在 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.innerWidthwindow.innerHeightresize 事件不是就足夠好了嗎?

這就須要迴歸到本文的第一張圖片來解釋了:

image

沒錯,問題在於頁面縮放。能夠看出,當頁面發生放大後,fixed 元素是不會一塊兒移動到實際可視區域的。並且通過測試發現,Android 下的 window.innerWidthwindow.innerHeight 也不會隨頁面放大而一塊兒變化。反而在 iOS 下,window.innerWidthwindow.innerHeight 會隨着頁面放大而等比例減少,雖然不會去掉鍵盤高度,但確實反映了顯示在屏幕內的頁面區域尺寸。

而 VisualViewport API 在 Android 和 iOS 兩端,都完整反映了在縮放和鍵盤彈出等一系列影響下,實際可視區域在頁面中的位置和大小

所以,VisualViewport API 對於 iOS 之外的平臺,最大的意義是能夠反映頁面的放大區域;而對於 iOS Safari 瀏覽器,最大的意義是能夠反映鍵盤的彈出。 基於這一點,咱們能夠實現一個真正相對於可視區域 fixed(固定)的 fixed 容器。

實現一個 VisualViewport 組件

如何實現一個 fixed 容器?關於這一點,也許有一部分 Web 開發者並不知情。在 Web 開發者的直覺中,fixed 元素是始終相對於視口定位,沒有任何一個元素可以改變它的定位方式;但事實上,問題卻有些不一樣。

若是你曾經使用過一些性能優良的滾動容器,如 iScroll、BetterScroll、AlloyTouch 等,你可能會遇到這樣一個問題:fixed「不靈了」,它們可能再也不相對於視口定位,而是被限制在了滾動容器以內。

這是由於,在滾動容器常常會遇到的性能瓶頸中,組件的開發者一般會選擇 CSS 3D Transform 來強制硬件加速,讓滾動體驗更順暢。在開啓了 3D Transform 的容器內,因爲渲染限制,fixed 元素沒法再相對於視口布局,而是被「圈」在了 3D Transform 容器以內。咱們只須要反其道而行之,給一個容器開啓 3D Transform,就可讓內部的 fixed 元素相對於該容器佈局了。

下面咱們以 React 爲例,實現一個能夠兼容 Android/iOS 13+,始終貼着可視區域的 VisualViewport 組件。

定義 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.visualViewportresizescroll 事件以及 windowresize 事件,咱們將可見視口和實際視口的尺寸變化轉化爲組件內的 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;
    }
複製代碼

效果和總結

image

通過這樣的實現,咱們的組件能夠在支持的瀏覽器中正肯定位到當前可見視口的位置(上圖中的靛藍色區域),並將內部的元素以可見視口爲基準進行定位。對於移動端 Web 應用來講,這樣的組件有不少用途,例如吸附鍵盤的工具欄或自動完成列表、須要避開鍵盤居中的對話框等等。值得一提的是,在 PC 瀏覽器上,這個 API 也一樣適用(能夠響應頁面的放大)。

在 iOS 下,這樣的實現還存在一些遲鈍和小 bug(例如,鍵盤展開後的強制滾動狀態下向上滑動,能夠露出不管是 Viewport 仍是 VisualViewport 都沒法到達的白色襯底區域)。

但至少,在 iOS 8.2 發佈四年後,iOS 13 對 VisualViewport 的支持,讓獲取鍵盤高度、避開鍵盤、吸附鍵盤這三件事終於有了相對優雅的辦法。


AlloyTeam 歡迎優秀的小夥伴加入。
簡歷投遞: alloyteam@qq.com
詳情可點擊 騰訊AlloyTeam招募Web前端工程師(社招)

clipboard.png
相關文章
相關標籤/搜索