天天看點

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 共同梳理了實作一個圖檔浏覽器所包含的功能點:

  1. 點選關閉圖檔
  2. 支援左右滑動切換圖檔
  3. 支援輕按兩下放大
  4. 長按喚起更多操作

    ... ...

從上面的功能點分析之後,我們采用 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 是開源的,我們可以通過源碼找到問題解決的思路和方法。希望以上的解決方案能幫助到開發者,提供解決問題的思路。

展望

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

繼續閱讀