Flutter完整開發實戰詳解(十3、全面深刻觸摸和滑動原理)

本篇將帶你深刻了解 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 方法中主要是 hitTestdispatchEvent經過 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

1.1 、hitTest

hitTest 方法主要爲了獲得一個 HitTestResult ,這個 HitTestResult 內有一個 List<HitTestEntry> 是用於分發和競爭事件的,而每一個 HitTestEntry.target 都會存儲每一個控件的 RenderObject學習

由於 RenderObject 默認都實現了 HitTestTarget 接口,因此能夠理解爲: HitTestTarget 大部分時候都是 RenderObject ,而 HitTestResult 就是一個帶着碰撞測試後的控件列表。測試

事實上 hitTestHitTestable 抽象類的方法,而 Flutter 中全部實現 HitTestable 的類有 GestureBindingRendererBinding ,它們都是 mixinsWidgetsFlutterBinding 這個入口類上,而且由於它們的 mixins 順序的關係,因此 RendererBindinghitTest 會先被調用,以後才調用 GestureBindinghitTestui

那麼這兩個 hitTest 又分別幹了什麼事呢?

1.二、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 判斷本身是否屬於響應區域,確認響應後執行 hitTestChildrenhitTestSelf ,嘗試添加下級的 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.三、GestureBinding.hitTest

最後 GestureBinding.hitTest 方法不過最後把 GestureBinding 本身也添加到 HitTestResult 裏,最後由於後面咱們的流程還會須要回到 GestureBinding 中去處理。

1.四、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 要怎麼分配手勢事件? 這就涉及到事件的競爭了。

核心要來了,高能預警!!!

二、事件競爭

Flutter 在設計事件競爭的時候,定義了一個頗有趣的概念:經過一個競技場,各個控件參與競爭,直接勝利的或者活到最後的第一位,你就獲勝獲得了勝利。 那麼爲了分析接下來的「戰爭」,咱們須要先看幾個概念:

  • GestureRecognizer :手勢識別器基類,基本上 RenderPointerListener 中須要處理的手勢事件,都會分發到它對應的 GestureRecognizer,並通過它處理和競技後再分發出去,常見有 :OneSequenceGestureRecognizerMultiTapGestureRecognizerVerticalDragGestureRecognizerTapGestureRecognizer 等等。

  • GestureArenaManagerr :手勢競技管理,它管理了整個「戰爭」的過程,原則上競技勝出的條件是 :第一個競技獲勝的成員或最後一個不被拒絕的成員。

  • GestureArenaEntry :提供手勢事件競技信息的實體,內封裝參與事件競技的成員。

  • GestureArenaMember:參與競技的成員抽象對象,內部有 acceptGesturerejectGesture 方法,它表明手勢競技的成員,默認 GestureRecognizer 都實現了它,全部競技的成員能夠理解爲就是 GestureRecognizer 之間的競爭。

  • _GestureArenaGestureArenaManager 內的競技場,內部持參與競技的 members 列表,官方對這個競技場的解釋是: 若是一個手勢試圖在競技場開放時(isOpen=true)獲勝,它將成爲一個帶有「渴望獲勝」的屬性的對象。當競技場關閉(isOpen=false)時,競技場將尋找一個「渴望獲勝」的對象成爲新的參與者,若是這時候恰好只有一個,那這一個參與者將成爲此次競技場勝利的青睞存在。

好了,知道這些概念以後咱們開始分析流程,咱們知道 GestureBindingdispatchEvent 時會先判斷是否有 HitTestResult 是否有結果,通常狀況下是存在的,因此直接執行循環 entry.target.handleEvent

2.一、PointerDownEvent

循環執行過程當中,咱們知道 entry.target.handleEvent 會觸發RenderPointerListenerhandleEvent ,而事件流程中第一個事件通常都會是 PointerDownEvent

PointerDownEvent 的流程在事件競技流程中至關關鍵,由於它會觸發 GestureRecognizer.addPointer

GestureRecognizer 只有經過 addPointer 方法將 PointerDownEvent 事件和本身綁定,並添加到 GestureBindingPointerRouter 事件路由和 GestureArenaManager 事件競技中,後續的事件這個控件的 GestureRecognizer 才能響應和參與競爭。

事實上 Down 事件在 Flutter 中通常都是用來作添加判斷的,若是存在競爭時,大部分時候是不會直接出結果的,而 Move 事件在不一樣 GestureRecognizer 中會表現不一樣,而 UP 事件以後,通常會強制獲得一個結果。

因此咱們知道了事件在 GestureBinding 開始分發的時候,在 PointerDownEvent 時須要響應事件的 GestureRecognizer 們,會調用 addPointer 將本身添加到競爭中。以後流程中若是沒有特殊狀況,通常會執行到參與競爭成員列表的 last,也就是 GestureBinding 本身這個 handleEvent 。

以下代碼所示,走到 GestureBindinghandleEvent ,在 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);
    }
  }
複製代碼

讓咱們看 GestureArenaManagerclose 方法,下面代碼咱們能夠看到,若是前面 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 觸發 TapGestureRecognizerhandleEvent 觸發的。

那麼問題來了,_checkDown_checkUp 時在 UP 事件一次性被執行,那麼若是我長按住的話,_checkDown 不是沒辦法正確回調了?

固然不會,在 TapGestureRecognizer 中有一個 didExceedDeadline 的機制,在前面 Down 流程中,addPointerTapGestureRecognizer 會建立一個定時器,這個定時器的時間時 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 流程中會強制指定第一個爲勝利者。

2.3 滑動事件

滑動事件也是須要在 Down 流程中 addPointer ,而後 MOVE 流程中,經過在 PointerRouter.route 以後執行 DragGestureRecognizer.handleEvent

image.png

PointerMoveEvent 事件的 DragGestureRecognizer.handleEvent 裏,會經過在 _hasSufficientPendingDragDeltaToAccept判斷是否符合條件,如:

bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;
複製代碼

若是符合條件就直接執行 resolve(GestureDisposition.accepted); ,將流程回到競技場裏,而後執行 acceptGesture ,而後觸發onStartonUpdate

回到咱們前面的上下滑動可點擊列表,是否是很明確了:若是是點擊的話,沒有產生 MOVE 事件,因此 DragGestureRecognizer 沒有被接受,而Item 做爲 Child 第一位,因此響應點擊。若是有 MOVE 事件, DragGestureRecognizer 會被 acceptGesture,而點擊 GestureRecognizer 會被移除事件競爭,也就沒有後續 UP 事件了。

那這個 onUpdate 是怎麼讓節目動起來的?

咱們以 ListView 爲例子,經過源碼能夠知道, onUpdate 最後會調用到 Scrollable_handleDragUpdate ,這時候會執行 Drag.update

image.png

經過源碼咱們知道 ListViewDrag 實現實際上是 ScrollDragController, 它在 Scrollable 中是和 ScrollPositionWithSingleContext 關聯的在一塊兒的。那麼 ScrollPositionWithSingleContext 又是什麼?

ScrollPositionWithSingleContext 其實就是這個滑動的關鍵,它其實就是 ScrollPosition 的子類,而 ScrollPosition 又是 ViewportOffset 的子類,而 ViewportOffset 又是一個 ChangeNotifier,出現以下關係:

繼承關係:ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier

因此 ViewportOffset 就是滑動的關鍵點。上面咱們知道響應區域 DragGestureRecognizer 勝利以後執行 Drag.update ,最終會調用到 ScrollPositionWithSingleContextapplyUserOffset,致使內部肯定位置的 pixels 發生改變,並執行父類 ChangeNotifier 的方法notifyListeners 通知更新。

而在 ListView 內部 RenderViewportBase 中,這個 ViewportOffset 是經過 _offset.addListener(markNeedsLayout); 綁定的,so ,觸摸滑動致使 Drag.update ,最終會執行到 RenderViewportBase 中的 markNeedsLayout 觸發頁面更新。

至於 markNeedsLayout 如何更新界面和滾動列表,這裏暫不詳細描述了,給個圖感覺下:

image.png

自此,第十三篇終於結束了!(///▽///)

資源推薦

完整開源項目推薦:
文章

《Flutter完整開發實戰詳解(1、Dart語言和Flutter基礎)》

《Flutter完整開發實戰詳解(2、 快速開發實戰篇)》

《Flutter完整開發實戰詳解(3、 打包與填坑篇)》

《Flutter完整開發實戰詳解(4、Redux、主題、國際化)》

《Flutter完整開發實戰詳解(5、 深刻探索)》

《Flutter完整開發實戰詳解(6、 深刻Widget原理)》

《Flutter完整開發實戰詳解(7、 深刻佈局原理)》

《Flutter完整開發實戰詳解(8、 實用技巧與填坑)》

《Flutter完整開發實戰詳解(9、 深刻繪製原理)》

《Flutter完整開發實戰詳解(10、 深刻圖片加載流程)》

《Flutter完整開發實戰詳解(11、全面深刻理解Stream)》

《Flutter完整開發實戰詳解(12、全面深刻理解狀態管理設計)》

《跨平臺項目開源項目推薦》

《移動端跨平臺開發的深度解析》

咱們還會再見嗎?
相關文章
相關標籤/搜索