本篇將帶你深入瞭解 Flutter 中的手勢事件傳遞、事件分發、事件衝突競爭,滑動流暢等等的原理,幫你構建一個完整的 Flutter 閉環手勢知識體系,這也許是目前最全面的手勢事件和滑動原始碼的深入文章了。
前文:
Flutter 中預設情況下,以 Android 為例,所有的事件都是起原生源於 io.flutter.view.FlutterView
這個 SurfaceView
的子類,整個觸控手勢事件實質上經歷了 JAVA => C++ => Dart 的一個流程,整個流程如下圖所示,無論是 Android 還是 IOS ,原生層都只是將所有事件打包下發,比如在 Android 中,手勢資訊被打包成 ByteBuffer
進行傳遞,最後在 Dart 層的 _dispatchPointerDataPacket
方法中,通過 _unpackPointerDataPacket
方法解析成可用的 PointerDataPacket
物件使用。
那麼具體在 Flutter 中是如何分發使用手勢事件的呢?
1、事件流程
在前面的流程圖中我們知道,在 Dart 層中手勢事件都是從 _dispatchPointerDataPacket
開始的,之後會通過 Zone
判斷環境回撥,會執行 GestureBinding
這個膠水類中的 _handlePointerEvent
方法。(如果對 Zone
或者 GestureBinding
有疑問可以翻閱前面的篇章)
如下程式碼所示, GestureBinding
的 _handlePointerEvent
方法中主要是 hitTest
和 dispatchEvent
: 通過 hitTest
碰撞,得到一個包含控制元件的待處理成員列表 HitTestResult
,然後通過 dispatchEvent
分發事件併產生競爭,得到勝利者相應。
void _handlePointerEvent(PointerEvent event) {
assert(!locked);
HitTestResult hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent) {
hitTestResult = HitTestResult();
///開始碰撞測試了,會新增各個控制元件,得到一個需要處理的控制元件成員列表
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
///複用機制,抬起和取消,不用hitTest,移除
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
///複用機制,手指處於滑動中,不用hitTest
hitTestResult = _hitTests[event.pointer];
}
if (hitTestResult != null ||
event is PointerHoverEvent ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
///開始分發事件
dispatchEvent(event, hitTestResult);
}
}
複製程式碼
瞭解了結果後,接下來深入分析這兩個關鍵方法:
1.1 、hitTest
hitTest
方法主要為了得到一個 HitTestResult
,這個 HitTestResult
內有一個 List<HitTestEntry>
是用於分發和競爭事件的,而每個 HitTestEntry.target
都會儲存每個控制元件的 RenderObject
。
因為 RenderObject
預設都實現了 HitTestTarget
介面,所以可以理解為: HitTestTarget
大部分時候都是 RenderObject
,而 HitTestResult
就是一個帶著碰撞測試後的控制元件列表。
事實上 hitTest
是 HitTestable
抽象類的方法,而 Flutter 中所有實現 HitTestable
的類有 GestureBinding
和 RendererBinding
,它們都是 mixins
在 WidgetsFlutterBinding
這個入口類上,並且因為它們的 mixins
順序的關係,所以 RendererBinding
的 hitTest
會先被呼叫,之後才呼叫 GestureBinding
的 hitTest
。
那麼這兩個 hitTest 又分別幹了什麼事呢?
1.2、RendererBinding.hitTest
在 RendererBinding.hitTest
中會執行 renderView.hitTest(result, position: position);
,如下程式碼所示,renderView.hitTest
方法內會執行 child.hitTest
,它將嘗試將符合條件的 child 控制元件新增到 HitTestResult
裡,最後把自己新增進去。
///RendererBinding
bool hitTest(HitTestResult result, { Offset position }) {
if (child != null)
child.hitTest(result, position: position);
result.add(HitTestEntry(this));
return true;
}
複製程式碼
而檢視 child.hitTest
方法原始碼,如下所示,RenderObjcet
中的hitTest
,會通過 _size.contains
判斷自己是否屬於響應區域,確認響應後執行 hitTestChildren
和 hitTestSelf
,嘗試新增下級的 child 和自己新增進去,這樣的遞迴就讓我們自下而上的得到了一個 HitTestResult
的相應控制元件列表了,最底下的 Child 在最上面。
///RenderObjcet
bool hitTest(HitTestResult result, { @required Offset position }) {
if (_size.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
複製程式碼
1.3、GestureBinding.hitTest
最後 GestureBinding.hitTest
方法不過最後把 GestureBinding
自己也新增到 HitTestResult
裡,最後因為後面我們的流程還會需要回到 GestureBinding
中去處理。
1.4、dispatchEvent
dispatchEvent
中主要是對事件進行分發,並且通過上述新增進去的 target.handleEvent
處理事件,如下程式碼所示,在存在碰撞結果的時候,是會通過迴圈對每個控制元件內部的handleEvent
進行執行。
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
///如果沒有碰撞結果,那麼通過 `pointerRouter.route` 將事件分發到全域性處理。
if (hitTestResult == null) {
try {
pointerRouter.route(event);
} catch (exception, stack) {
return;
}
///上面我們知道 HitTestEntry 中的 target 是一系自下而上的控制元件
///還有 renderView 和 GestureBinding
///迴圈執行每一個的 handleEvent 方法
for (HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event, entry);
} catch (exception, stack) {
}
}
}
複製程式碼
事實上並不是所有的控制元件的 RenderObject
子類都會處理 handleEvent
,大部分時候,只有帶有 RenderPointerListener
(RenderObject) / Listener
(Widget) 的才會處理 handleEvent
事件,並且從上述原始碼可以看出,handleEvent 的執行是不會被攔截打斷的。
那麼問題來了,如果同一個區域內有多個控制元件都實現了 handleEvent
時,那最後事件應該交給誰消耗呢?
更具體為一個場景問題就是:比如一個列表頁面內,存在上下滑動和 Item 點選時,Flutter 要怎麼分配手勢事件? 這就涉及到事件的競爭了。
核心要來了,高能預警!!!
2、事件競爭
Flutter 在設計事件競爭的時候,定義了一個很有趣的概念:通過一個競技場,各個控制元件參與競爭,直接勝利的或者活到最後的第一位,你就獲勝得到了勝利。 那麼為了分析接下來的“戰爭”,我們需要先看幾個概念:
-
GestureRecognizer
:手勢識別器基類,基本上RenderPointerListener
中需要處理的手勢事件,都會分發到它對應的GestureRecognizer
,並經過它處理和競技後再分發出去,常見有 :OneSequenceGestureRecognizer
、MultiTapGestureRecognizer
、VerticalDragGestureRecognizer
、TapGestureRecognizer
等等。 -
GestureArenaManagerr
:手勢競技管理,它管理了整個“戰爭”的過程,原則上競技勝出的條件是 :第一個競技獲勝的成員或最後一個不被拒絕的成員。 -
GestureArenaEntry
:提供手勢事件競技資訊的實體,內封裝參與事件競技的成員。 -
GestureArenaMember
:參與競技的成員抽象物件,內部有acceptGesture
和rejectGesture
方法,它代表手勢競技的成員,預設GestureRecognizer
都實現了它,所有競技的成員可以理解為就是GestureRecognizer
之間的競爭。 -
_GestureArena
:GestureArenaManager
內的競技場,內部持參與競技的members
列表,官方對這個競技場的解釋是: 如果一個手勢試圖在競技場開放時(isOpen=true)獲勝,它將成為一個帶有“渴望獲勝”的屬性的物件。當競技場關閉(isOpen=false)時,競技場將尋找一個“渴望獲勝”的物件成為新的參與者,如果這時候剛好只有一個,那這一個參與者將成為這次競技場勝利的青睞存在。
好了,知道這些概念之後我們開始分析流程,我們知道 GestureBinding
在 dispatchEvent
時會先判斷是否有 HitTestResult
是否有結果,一般情況下是存在的,所以直接執行迴圈 entry.target.handleEvent
。
2.1、PointerDownEvent
迴圈執行過程中,我們知道 entry.target.handleEvent
會觸發RenderPointerListener
的 handleEvent
,而事件流程中第一個事件一般都會是 PointerDownEvent
。
PointerDownEvent
的流程在事件競技流程中相當關鍵,因為它會觸發GestureRecognizer.addPointer
。
GestureRecognizer
只有通過 addPointer
方法將 PointerDownEvent
事件和自己繫結,並新增到 GestureBinding
的 PointerRouter
事件路由和 GestureArenaManager
事件競技中,後續的事件這個控制元件的 GestureRecognizer
才能響應和參與競爭。
事實上 Down 事件在 Flutter 中一般都是用來做新增判斷的,如果存在競爭時,大部分時候是不會直接出結果的,而 Move 事件在不同
GestureRecognizer
中會表現不同,而 UP 事件之後,一般會強制得到一個結果。
所以我們知道了事件在 GestureBinding
開始分發的時候,在 PointerDownEvent
時需要響應事件的 GestureRecognizer
們,會呼叫 addPointer
將自己新增到競爭中。之後流程中如果沒有特殊情況,一般會執行到參與競爭成員列表的 last,也就是 GestureBinding
自己這個 handleEvent 。
如下程式碼所示,走到 GestureBinding
的 handleEvent
,在 Down 事件的流程中,一般 pointerRouter.route
不會怎麼處理邏輯,然後就是 gestureArena.close
關閉競技場了,嘗試得到勝利者。
@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
/// 導航事件去觸發 `GestureRecognizer` 的 handleEvent
/// 一般 PointerDownEvent 在 route 執行中不怎麼處理。
pointerRouter.route(event);
///gestureArena 就是 GestureArenaManager
if (event is PointerDownEvent) {
///關閉這個 Down 事件的競技,嘗試得到勝利
/// 如果沒有的話就留到 MOVE 或者 UP。
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
///已經到 UP 了,強行得到結果。
gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
}
}
複製程式碼
讓我們看 GestureArenaManager
的 close
方法,下面程式碼我們可以看到,如果前面 Down 事件中沒有通過 addPointer
新增成員到 _arenas
中,那會連參加的機會都沒有,而進入 _tryToResolveArena
之後,如果 state.members.length == 1
,說明只有一個成員了,那就不競爭了,直接它就是勝利者,直接響應後續所有事件。 那麼如果是多個的話,就需要後續的競爭了。
void close(int pointer) {
/// 拿到我們上面 addPointer 時新增的成員封裝
final _GestureArena state = _arenas[pointer];
if (state == null)
return; // This arena either never existed or has been resolved.
state.isOpen = false;
///開始打起來吧
_tryToResolveArena(pointer, state);
}
void _tryToResolveArena(int pointer, _GestureArena state) {
if (state.members.length == 1) {
scheduleMicrotask(() => _resolveByDefault(pointer, state));
} else if (state.members.isEmpty) {
_arenas.remove(pointer);
} else if (state.eagerWinner != null) {
_resolveInFavorOf(pointer, state, state.eagerWinner);
}
}
複製程式碼
2.2 開始競爭
那競爭呢?接下來我們以 TapGestureRecognizer
為例子,如果控制元件區域記憶體在兩個 TapGestureRecognizer
,那麼在 PointerDownEvent
流程是不會產生勝利者的,這時候如果沒有 MOVE 打斷的話,到了 UP 事件時,就會執行 gestureArena.sweep(event.pointer);
強行選取一個。
而選擇的方式也是很簡單,就是 state.members.first
,從我們之前 hitTest
的結果上理解的話,就是控制元件樹的最裡面 Child 了。 這樣勝利的 member 會通過 members.first.acceptGesture(pointer)
回撥到 TapGestureRecognizer.acceptGesture
中,設定 _wonArenaForPrimaryPointer
為 ture 標誌為勝利區域,然後執行
_checkDown
和 _checkUp
發出事件響應觸發給這個控制元件。
而這裡有個有意思的就是 ,Down 流程的 acceptGesture
中的 _checkUp
因為沒有 _finalPosition
此時是不會被執行的,_finalPosition
會在 handlePrimaryPointer
方法中,獲得_finalPosition
並判斷 _wonArenaForPrimaryPointer
標誌為,再次執行 _checkUp
才會成功。
handlePrimaryPointer
是在 UP 流程中pointerRouter.route
觸發TapGestureRecognizer
的handleEvent
觸發的。
那麼問題來了,_checkDown
和 _checkUp
時在 UP 事件一次性被執行,那麼如果我長按住的話,_checkDown
不是沒辦法正確回撥了?
當然不會,在 TapGestureRecognizer
中有一個 didExceedDeadline
的機制,在前面 Down 流程中,在 addPointer
時 TapGestureRecognizer
會建立一個定時器,這個定時器的時間時 kPressTimeout = 100毫秒
,如果我們長按住的話,就會等待到觸發 didExceedDeadline
去執行 _checkDown
發出 onTabDown
事件了。
_checkDown
執行傳送過程中,會有一個標誌為_sentTapDown
判斷是否已經傳送過,如果傳送過了也不會在重發,之後回到原本流程去競爭,手指抬起後得到勝利者相應,同時在_checkUp
之後_sentTapDown
標識為會被重置。
這也可以分析點選下的幾種場景:
普通按下:
-
1、區域內只有一個
TapGestureRecognizer
:Down 事件時直接在競技場close
時就得到競出勝利者,呼叫acceptGesture
執行_checkUp
,到 Up 事件的時候通過handlePrimaryPointer
執行_checkUp
,結束。 -
2、區域內有多個
TapGestureRecognizer
:Down 事件時在競技場close
不會競出勝利者,在 Up 事件的時候,會在route
過程通過handlePrimaryPointer
設定好_finalPosition
,之後經過競技場sweep
選取排在第一個位置的為勝利者,呼叫acceptGesture
,執行_checkDown
和_checkUp
。
長按之後抬起:
1、區域內只有一個 TapGestureRecognizer
:除了 Down 事件是在 didExceedDeadline
時發出 _checkDown
外其他和上面基本沒區別。
- 2、區域內有多個
TapGestureRecognizer
:Down 事件時在競技場close
時不會競出勝利者,但是會觸發定時器didExceedDeadline
,先發出_checkDown
,之後再經過sweep
選取第一個座位勝利者,呼叫acceptGesture
,觸發_checkUp
那麼問題又來了,你有沒有疑問,如果有區域兩個 TapGestureRecognizer
,長按的時候因為都觸發了 didExceedDeadline
執行 _checkDown
嗎?
答案是:會的!因為定時器都觸發了 didExceedDeadline
,所以 _checkDown
都會被執行,從而都發出了 onTapDown
事件。但是後續競爭後,只會執行一個 _checkUp
,所有隻會有一個控制元件響應 onTap
。
競技失敗:
在競技場競爭失敗的成員會被移出競技場,移除後就沒辦法參加後面事件的競技了 ,比如 TapGestureRecognizer
在接受到 PointerMoveEvent
事件時就會直接 rejected
, 並觸發 rejectGesture
,之後定時器會被關閉,並且觸發 onTapCancel
,然後重置標誌位.
總結下:
Down 事件時通過 addPointer
加入了 GestureRecognizer
競技場的區域,在沒移除的情況下,事件可以參加後續事件的競技,在某個事件階段移除的話,之後的事件序列也會無法接受。事件的競爭如果沒有勝利者,在 UP 流程中會強制指定第一個為勝利者。
2.3 滑動事件
滑動事件也是需要在 Down 流程中 addPointer
,然後 MOVE 流程中,通過在 PointerRouter.route
之後執行 DragGestureRecognizer.handleEvent
。
在 PointerMoveEvent
事件的 DragGestureRecognizer.handleEvent
裡,會通過在 _hasSufficientPendingDragDeltaToAccept
判斷是否符合條件,如:
bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;
複製程式碼
如果符合條件就直接執行 resolve(GestureDisposition.accepted);
,將流程回到競技場裡,然後執行 acceptGesture
,然後觸發onStart
和 onUpdate
。
回到我們前面的上下滑動可點選列表,是不是很明確了:如果是點選的話,沒有產生 MOVE 事件,所以 DragGestureRecognizer
沒有被接受,而Item 作為 Child 第一位,所以響應點選。如果有 MOVE 事件, DragGestureRecognizer
會被 acceptGesture
,而點選 GestureRecognizer
會被移除事件競爭,也就沒有後續 UP 事件了。
那這個 onUpdate
是怎麼讓節目動起來的?
我們以 ListView
為例子,通過原始碼可以知道, onUpdate
最後會呼叫到 Scrollable
的 _handleDragUpdate
,這時候會執行 Drag.update
。
通過原始碼我們知道 ListView
的 Drag
實現其實是 ScrollDragController
, 它在 Scrollable
中是和 ScrollPositionWithSingleContext
關聯的在一起的。那麼 ScrollPositionWithSingleContext
又是什麼?
ScrollPositionWithSingleContext
其實就是這個滑動的關鍵,它其實就是 ScrollPosition
的子類,而 ScrollPosition
又是 ViewportOffset
的子類,而 ViewportOffset
又是一個 ChangeNotifier
,出現如下關係:
繼承關係:ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier
所以 ViewportOffset 就是滑動的關鍵點。上面我們知道響應區域 DragGestureRecognizer
勝利之後執行 Drag.update
,最終會呼叫到 ScrollPositionWithSingleContext
的 applyUserOffset
,導致內部確定位置的 pixels
發生改變,並執行父類 ChangeNotifier
的方法notifyListeners
通知更新。
而在 ListView
內部 RenderViewportBase
中,這個 ViewportOffset
是通過 _offset.addListener(markNeedsLayout);
繫結的,so ,觸控滑動導致 Drag.update
,最終會執行到 RenderViewportBase
中的 markNeedsLayout
觸發頁面更新。
至於 markNeedsLayout
如何更新介面和滾動列表,這裡暫不詳細描述了,給個圖感受下:
自此,第十三篇終於結束了!(///▽///)
資源推薦
- Github : github.com/CarGuo
- 本文Demo :github.com/CarGuo/stat…
- 本文程式碼 :github.com/CarGuo/GSYG…
完整開源專案推薦:
文章
《Flutter完整開發實戰詳解(一、Dart語言和Flutter基礎)》
《Flutter完整開發實戰詳解(四、Redux、主題、國際化)》
《Flutter完整開發實戰詳解(六、 深入Widget原理)》
《Flutter完整開發實戰詳解(十、 深入圖片載入流程)》
《Flutter完整開發實戰詳解(十一、全面深入理解Stream)》