Flutter手勢探索——原理與實現的背後

在日常開發中,手勢和事件無處不在,比如在 Flutter 應用中點選一個點贊按鈕,長按彈出 BottomSheet 和商品列表的滑動等等都存在事件傳遞和手勢識別,Flutter 內部是如何確定哪個控制元件響應了事件,事件是如何在控制元件之間傳遞的,包括像 Tap 和 DoubleTap 等手勢是如何區分的。為了回答以上的問題,我們接下來深入探索 Flutter 手勢的原理。

手勢原理

事件分發

Flutter 中的事件是從 Window。onPointerDataPacket 的回撥中獲取的,將原始事件轉化成 PointerEvent 加入到待處理的事件佇列中,然後逐個處理佇列中的 PointerEvent。

Flutter手勢探索——原理與實現的背後

其中 _handlePointerEvent 將生成 HitTestResult 將所有的命中測試結果存在 _path (HitTestResult 中的一個命中測試物件的集合),最後遍歷 HitTestResult 的 _path 進行事件分發。

Flutter手勢探索——原理與實現的背後

命中測試

那麼 HitTestResult 是如何收集這些命中測試結果的呢,與 Native 的 HitTest 類似,Flutter 中也是不斷在遍歷(呼叫 HitTest)child 判斷 point 和 child 的大小比較直到找到最深一個 child 也就是離我們最近的一個 RenderBox。如果把 Widget 的結構理解成樹的結構,那麼 _path 中 entry 的順序正好是從葉子節點往根節點回溯的順序。

Flutter手勢探索——原理與實現的背後

手勢識別

瞭解了 Flutter 的事件分發與命中測試,接下來我們看看手勢是如何識別。在 Flutter 提供了一個封裝各種手勢監聽的 Widget —— GestureDetector,其內部實現了各種手勢識別器和其回撥,然後傳給 RawGestureDetector 。在 RawGestureDetector 裡監聽了 PointerDownEvent 事件,並遍歷所有識別器並呼叫 addPointer 方法。

Flutter手勢探索——原理與實現的背後

我們以最簡單的識別器 TapGestureRecognizer 為例,先了解 addPointer 的實現中做了哪些事情,最終呼叫 startTrackingPointer 方法,在事件路由裡註冊 handleEvent,並將其加入到競爭場(後面會講手勢競爭)中。當事件分發時根據 pointer 呼叫對應的 handleEvent 方法。在 handleEvent 方法實現中判斷 pointer 的移動距離是否超過閾值,這個閾值的預設大小是 18 個畫素點。如果超過這個閾值將拒絕事件並停止事件追蹤。反之呼叫 TapGestureRecognizer 識別器實現的 handlePrimaryPointer,最終處理監聽的回撥。

Flutter手勢探索——原理與實現的背後

手勢競爭

當我們同時使用多種手勢時會產生衝突,為了解決這個問題,Flutter 引入了 GestureArena(手勢競爭場)的概念。在處理多種手勢時把這些手勢加入到競爭場中,勝出的手勢會繼續響應接下來的事件。在手勢競爭場中勝出者遵循兩個規律:

•在競爭場中只存在一個手勢識別器時,它將勝出。

•當有一個手勢識別器勝出,那麼其他的都將失敗。

舉個例子,在一個 Widget 上同時監聽 Horizontal 和 Vertical 手勢時,當手指按下的時候兩者都會進入手勢競爭場,當用戶手指在水平方向上移動一定距離,Horizontal 手勢將勝出並響應事件。相同的,使用者手指在垂直方向上移動 Vertical 手勢勝出。

小結

上面分析了在 Flutter 中從事件分發到手勢識別的原理,其中以 TapGestureRecognizer 為例介紹了手勢識別,除了此以外還有 ScaleGestureRecognizer,PanGestureRecognizer 等等,識別這些手勢的原理基本相同,重寫 handleEvent 實現各自具體手勢判斷。接下來具體介紹在實際專案中遇到的手勢衝突問題以及解決方案。

案例分析

近期團隊正在最佳化圖片瀏覽器的使用者體驗。我們與 UED 共同梳理了實現一個圖片瀏覽器所包含的功能點:

點選關閉圖片

支援左右滑動切換圖片

支援雙擊放大

長按喚起更多操作 。。。 。。。

從上面的功能點分析之後,我們採用 Flutter 的系統控制元件 PageView 作為圖片瀏覽器的基礎元件,在其基礎之上擴展出圖片放大、雙擊和長按等手勢。所以元件的框架圖如下所示:

Flutter手勢探索——原理與實現的背後

在 PageView 的 ItemView 使用 ImageWrapper 封裝之後接管 ItemView 的手勢來處理自定義的手勢,比如縮放 ScaleGestureRecognizer 和 TapGestureRecognizer 等等。從上面的框架圖看,基於系統控制元件 PageView 的框架分層比較簡單,儘可能利用系統控制元件原有的功能,即能減少實現複雜邏輯的實現,同時也避免了在多種系統和裝置上的相容性問題。在這個過程中也遇到一些手勢衝突的問題。

圖片放大滾動與 PageView 滑動的衝突

分析衝突原因:在 ImageWrapper 中使用 ScaleGestureRecognizer 追蹤縮放事件。PageView 是在 Scrollable 的基礎上實現的,Scrollable 則是利用 HorizontalDragGestureRecognizer 追蹤水平拖拽事件來實現滑動。Scale 和 HorizontalDrag 同時存在必然會發生競爭,因為在水平滑動時 HorizontalDrag 手勢勝出,圖片無法滾動直接滑到下一頁。透過上面的分析,我們需要解決兩個問題:

•圖片支援滾動

•圖片滾動到邊界時滑到下一頁

一個簡單的想法是在圖片放大時禁止 PageView 滑動(PageView 的 physics 設定為 NeverScrollableScrollPhysics),當放大圖片滾動到邊界時允許 PageView 滑動下一頁。該方案在實現之後,發現滾動到邊界時與 PageView 滑動到下一頁兩者銜接的體驗並不流暢。

從上面對 PageView 的原始碼分析,在 ImageWrapper 中實現 HorizontalDragGestureRecognizer 手勢攔截了 PageView 內部的水平拖拽手勢,圖片放大時透過 Scale 手勢回撥計算位置(圖片移動),當圖片移動到邊界時,將手勢描述(DragStartDetails)傳給外部的 PageView,在回撥中 PageController 的 ScrollPosition 生成一個 Drag,緊接著 DragUpdateDetails 用於 drag 物件的更新。需要注意在手勢事件結束時需要呼叫 drag。end 保持手勢事件的完整性。這種方法較完美的解決了上面衝突的問題,並且透過 Flutter 自身提供的方法實現,在 HorizontalDrag 手勢結束時 PageController 會處理這部分滑動的動畫。

Flutter手勢探索——原理與實現的背後

Scale 手勢與 HorizontalDrag 手勢的衝突

在極端的情況下,雙指不同時接觸到螢幕,並且至少有一根手指是橫向移動,圖片縮放和位置會出現異常。透過上面的競爭分析,在其中一根手指出現橫向滑動的時,HorizontalDrag 在競爭中勝出,此處圖片的位置會被 HorizontalDrag 手勢的回撥改變(圖片瀏覽器 ImageWrapper 實現是在 Scale 和 HorizontalDrag 手勢回撥中協同控制圖片的縮放和位移)。

由於兩個手勢在以上的情況下會互相切換導致異常。首先將 Scale 和 HorizontalDrag 兩個手勢的職責劃分清楚,HorizontalDrag 的回撥處理圖片滾動到邊界時將 Drag 事件丟擲給 PageView 的 PageController 處理;Scale 的回撥只處理縮放和除邊界以外的位移。劃分清職責之後,讓兩個手勢同時存在那麼就不存在競爭勝出者的切換的問題,那麼圖片縮放和位置會也就不會出現異常。透過繼承 ScaleGestureRecognizer 重寫 rejectGesture 方法強制讓 Scale 手勢生效。從 GestureArena 的原始碼分析,rejectGesture 方法只在競爭結束之後收尾處理呼叫的,所以不會影響競爭場的競爭。並且重寫 rejectGesture 方法之後可以繼續追蹤事件(ScaleGestureRecognizer 中 rejectGesture 實現是停止事件追蹤)。

Flutter手勢探索——原理與實現的背後

小結

解決完上面兩個比較棘手的衝突問題,圖片瀏覽器元件的雛形也有了。由於篇幅原因,很多實現的細節沒有一一列舉,比如如何去計算邊界,圖片移動距離計算等等。在解決上面的問題也花費一定的時間,在解決問題沒有思路可能要回歸到問題本身,拆解問題,再逐個突破。好在 Flutter 是開源的,我們可以透過原始碼找到問題解決的思路和方法。希望以上的解決方案能幫助到開發者,提供解決問題的思路。

展望

圖片瀏覽器想要更好體驗接下來還需要對互動細節和臨界狀態處理更加細緻。比如在圖片放大之後滾動支援一定的加速度;圖片放大之後滾動到邊緣時增加阻尼等等。要想極致的使用者體驗,這幾個內容都是我們將來可能要探索的方向。

Flutter手勢探索——原理與實現的背後