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

作者:閒魚技術——子東

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

手勢原理

事件分發

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

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

命中測試

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

手勢識別

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

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

手勢競爭

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

  • 在競爭場中只存在一個手勢識別器時,它將勝出。
  • 當有一個手勢識別器勝出,那麼其他的都將失敗。

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

小結

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

案例分析

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

  1. 點擊關閉圖片
  2. 支持左右滑動切換圖片
  3. 支持雙擊放大
  4. 長按喚起更多操作
    ... ...

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

在 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 會處理這部分滑動的動畫。

Scale 手勢與 HorizontalDrag 手勢的衝突

在極端的情況下,雙指不同時接觸到屏幕,並且至少有一根手指是橫向移動,圖片縮放和位置會出現異常。通過上面的競爭分析,在其中一根手指出現橫向滑動的時,HorizontalDrag 在競爭中勝出,此處圖片的位置會被 HorizontalDrag 手勢的回調改變(圖片瀏覽器 ImageWrapper 實現是在 Scale 和 HorizontalDrag 手勢回調中協同控制圖片的縮放和位移)。
由於兩個手勢在以上的情況下會互相切換導致異常。首先將 Scale 和 HorizontalDrag 兩個手勢的職責劃分清楚,HorizontalDrag 的回調處理圖片滾動到邊界時將 Drag 事件拋出給 PageView 的 PageController 處理;Scale 的回調只處理縮放和除邊界以外的位移。劃分清職責之後,讓兩個手勢同時存在那麼就不存在競爭勝出者的切換的問題,那麼圖片縮放和位置會也就不會出現異常。
通過繼承 ScaleGestureRecognizer 重寫 rejectGesture 方法強制讓 Scale 手勢生效。從 GestureArena 的源碼分析,rejectGesture 方法只在競爭結束之後收尾處理調用的,所以不會影響競爭場的競爭。並且重寫 rejectGesture 方法之後可以繼續追蹤事件(ScaleGestureRecognizer 中 rejectGesture 實現是停止事件追蹤)。

小結

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

展望

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章