如果可以模擬 PointerEvent 進行分發,那么在應用中就可以通過代碼來觸發手勢事件,這樣就能解放雙手。如果結合語音監聽,通過代碼處理,說話也能觸發手勢操作,豈不美哉。
作為探索完手勢機制和滑動機制,又有完成這兩本小冊的我,感覺這個問題應該可解。下面就將整個問題的解決過程進行梳理,帶大家再認識一下手勢底層的相關實現。
一、模擬按下事件
1. 思路分析 1
PointerEvent 作為手勢機制中被傳遞的數據,它記錄著觸點的 id,坐標、觸點類型等信息。所以如果有辦法發送一個 PointerDownEvent 的消息,不就表示按下了嗎?
2. 自己分發事件
然后想到手勢事件分發是由 GestureBinding 處理的,而我們可以通過 GestureBinding.instance 獲取 GestureBinding 對象。那是不是意味著,可以自己來分發一個 PointerDownEvent 的消息。于是創建如下示例界面: 上部有兩個按鈕分別用于模擬滑動和模擬點擊。
我們現在的目標是通過模擬點擊可以點擊右下角的加號按鈕,從而讓上面黃色區域內的數字自加;通過模擬滑動讓列表滑動。
于是寫了如下 48 行的代碼通過 GestureBinding 對象的 dispatchEvent 來分發事件:
現在問題來了,第二入參需要傳入 HitTestResult 對象。
但它是一個可空的入參,所以傳個 null 試試:-
GestureBinding.instance!.dispatchEvent(p0, null);
很不出所料地,拋了異常,看來這樣直接發送消息似乎并不是正解。那么來分析一下這樣為何不可。
3. GestureBinding#dispatchEvent 的邏輯處理下面通過調試來看一下 GestureBinding#dispatchEvent 的邏輯處理: 402 行表示,當 hitTestResult 為 null 時,當前的 event 對象類型必須是 PointerAddedEvent 和 PointerRemovedEvent。而我們上面傳入 PointerDownEvent,使用肯定會拋異常。
所以現在的問題是,如果我們無法創建 HitTestResult,就無法通過 dispatchEvent 方法來分發 PointerDownEvent 事件。但 HitTestResult 是從 hitTest 收集的,我們似乎很難去主動創建,似乎問題進入了死胡同。
二、單擊事件是如何觸發的
1. 回顧單擊事件的觸發
如下是點擊加好按鈕時 FloatingActionButton#onPressed 回調觸發的方法棧情況,可以看到是在分發 PointerUpEvent 類型事件下觸發單擊事件的:
其實這也很好理解: 一個單擊事件的觸發條件并非只是分發 PointerDownEvent 而已,TapGestureRecognizer 手勢檢測器至少需要按下、抬起才會被觸發。
所以我們可以在 GestureBinding#dispatchEvent 分發方法打個斷點,通過點擊 + 按鈕,看看有哪些 PointerDownEvent 會被分發。
2. 單擊事件分發的 PointerEvent
如下所示,首先會分發 PointerAddEvent 事件,此時 hitTestResult 為 null,
接下來分發 PointerDownEvent 事件,可以看出此時 hitTestResult 就已經非空了,這說明在分發 PointerAddEvent 事件后,分發 PointerDownEvent 事件前,肯定有對 HitTestResult 進行收錄的處理。
最后分發 PointerDownEvent 事件,然后就觸發了單擊事件的回調。
3. HitTestResult 的收集
那接下來看一下 PointerDownEvent 事件分發前,HitTestResult 是如何被收集的。其實想知道這點很簡單,dispatchEvent 既然要傳入 HitTestResult 對象,只要通過調試看一下這個對象的來源即可:
只要往下看兩個方法棧,很容易定位到在 GestureBinding._handlePointerEventImmediately 方法中當 event 是 PointerDownEvent、PointerSignalEvent、PointerHoverEvent 時,都會創建 HitTestResult 對象,在通過 hitTest 方法來收集測試結果。
至于 hitTest 方法是如何從頂層的 RenderView 一層層測試的,這里就不展開了。感興趣的可以自己調試看看。
其實這樣一來,我們如果可以觸發這個方法就好了,但可惜它是個私有成員方法。但我們眼睛可以稍微向下瞄一個方法棧,普通成員方法 GestureBinding.handlePointerEvent 可以觸發這個私有方法。到這里,一個解決方案就應運而生了。
三、模擬事件觸發的實現
如下效果所示: 通過模擬點擊可以點擊右下角的加號按鈕,從而讓上面黃色區域內的數字自加;通過模擬滑動讓列表滑動。這樣我們就實現了通過代碼來觸發手勢事件。
1. 單擊事件
其實我們只需要通過 GestureBinding#handlePointerEvent 依次分發這三個 PointerEvent,就能模擬單擊事件的觸發了。沒錯,就是這么簡單,但其中涉及到的手勢體系知識,還是很值得回味的。
*注: 其中 Offset(322.8, 746.9) 是觸點的位置,是剛才通過調試看到的 + 位置。
void _pressAdd() {
const PointerEvent addPointer = PointerAddedEvent(
pointer: 0,
position: Offset(322.8, 746.9)
);
const PointerEvent downPointer = PointerDownEvent(
pointer: 0,
position: Offset(322.8, 746.9)
);
const PointerEvent upPointer = PointerUpEvent(
pointer: 0,
position: Offset(322.8, 746.9)
);
GestureBinding.instance!.handlePointerEvent(addPointer);
GestureBinding.instance!.handlePointerEvent(downPointer);
GestureBinding.instance!.handlePointerEvent(upPointer);
}
2. 滑動事件的觸發
如下,滑動事件的觸發關鍵點在于 tag1 處,通過 for 循環模擬 20 次偏移量是 20 的向上滑動事件。
void _pressMove() async {
const PointerEvent addPointer = PointerAddedEvent(
pointer: 1,
position: Offset(122.8, 746.9)
);
const PointerEvent downPointer = PointerDownEvent(
pointer: 1,
position: Offset(122.8, 746.9)
);
GestureBinding.instance!.handlePointerEvent(addPointer);
GestureBinding.instance!.handlePointerEvent(downPointer);
double dy = 20;
double updateCount = 20;
for (int i = 0; i < 20; i++) { // tag1
await Future.delayed(const Duration(milliseconds: 6));
PointerEvent movePointer = PointerMoveEvent(
pointer: 1,
delta: Offset(0, -dy),
position: Offset(122.8, 746.9 - i * dy)
);
GestureBinding.instance!.handlePointerEvent(movePointer);
}
PointerEvent upPointer = PointerUpEvent(
pointer: 1,
position: Offset(122.8, 746.9 - dy * updateCount)
);
GestureBinding.instance!.handlePointerEvent(upPointer);
}
這樣就可以發現: 只要我們按照各手勢檢測器競技勝利的規則進行模擬處理 PointerEvent 事件,就可以通過代碼完成我們想要觸發的手勢,是不是感覺非常棒。感覺可以結合一下計時器通過發送一系列手勢來完成一些引導操作,或者操作演示。
對于一些流程性的測試,或精準的滑動控制分析,用代碼模擬會顯得更加重要,因為一些性能分析需要控制變量,手動滑動多多少少會有不同,從而影響測試分析的結果。本篇就到這里,希望通過本文您能對 Flutter 的手勢有更深切的認識,也希望 Flutter 模擬事件觸發,在某個時刻可以幫助到您。