本篇將帶你深刻了解 Flutter 中的手勢事件傳遞、事件分發、事件衝突競爭,滑動流暢等等的原理,幫你構建一個完整的 Flutter 閉環手勢知識體系,這也許是目前最全面的手勢事件和滑動源碼的深刻文章了。git
前文:github
Flutter 中默認狀況下,以 Android 爲例,全部的事件都是起原生源於 io.flutter.view.FlutterView
這個 SurfaceView
的子類,整個觸摸手勢事件實質上經歷了 JAVA => C++ => Dart 的一個流程,整個流程以下圖所示,不管是 Android 仍是 IOS ,原生層都只是將全部事件打包下發,好比在 Android 中,手勢信息被打包成 ByteBuffer
進行傳遞,最後在 Dart 層的 _dispatchPointerDataPacket
方法中,經過 _unpackPointerDataPacket
方法解析成可用的 PointerDataPacket
對象使用。bash
那麼具體在 Flutter 中是如何分發使用手勢事件的呢?app
在前面的流程圖中咱們知道,在 Dart 層中手勢事件都是從 _dispatchPointerDataPacket
開始的,以後會經過 Zone
判斷環境回調,會執行 GestureBinding
這個膠水類中的 _handlePointerEvent
方法。(若是對 Zone
或者 GestureBinding
有疑問能夠翻閱前面的篇章)ide
以下代碼所示, 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);
}
}
複製代碼
瞭解告終果後,接下來深刻分析這兩個關鍵方法:post
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
。ui
那麼這兩個 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;
}
複製代碼
最後 GestureBinding.hitTest
方法不過最後把 GestureBinding
本身也添加到 HitTestResult
裏,最後由於後面咱們的流程還會須要回到 GestureBinding
中去處理。
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 要怎麼分配手勢事件? 這就涉及到事件的競爭了。
核心要來了,高能預警!!!
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
。
循環執行過程當中,咱們知道 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);
}
}
複製代碼
那競爭呢?接下來咱們以 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
標識爲會被重置。
這也能夠分析點擊下的幾種場景:
一、區域內只有一個 TapGestureRecognizer
:Down 事件時直接在競技場 close
時就獲得競出勝利者,調用 acceptGesture
執行 _checkUp
,到 Up 事件的時候經過 handlePrimaryPointer
執行 _checkUp
,結束。
二、區域內有多個 TapGestureRecognizer
:Down 事件時在競技場 close
不會競出勝利者,在 Up 事件的時候,會在 route
過程經過handlePrimaryPointer
設置好 _finalPosition
,以後通過競技場 sweep
選取排在第一個位置的爲勝利者,調用 acceptGesture
,執行 _checkDown
和 _checkUp
。
一、區域內只有一個 TapGestureRecognizer
:除了 Down 事件是在 didExceedDeadline
時發出 _checkDown
外其餘和上面基本沒區別。
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 流程中會強制指定第一個爲勝利者。
滑動事件也是須要在 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
如何更新界面和滾動列表,這裏暫不詳細描述了,給個圖感覺下:
自此,第十三篇終於結束了!(///▽///)
《Flutter完整開發實戰詳解(1、Dart語言和Flutter基礎)》
《Flutter完整開發實戰詳解(4、Redux、主題、國際化)》
《Flutter完整開發實戰詳解(6、 深刻Widget原理)》
《Flutter完整開發實戰詳解(10、 深刻圖片加載流程)》
《Flutter完整開發實戰詳解(11、全面深刻理解Stream)》