今天,我和你分享的主題是,如何響應用戶交互事件。
在前面兩篇文章中,我和你一起學習了 Flutter 依賴的包管理機制。在 Flutter 中,包是包含了外部依賴的功能抽象。對于資源和工程代碼依賴,我們采用包配置文件 pubspec.yaml 進行統一管理。
通過前面幾個章節的學習,我們已經掌握了如何在 Flutter 中通過內部實現和外部依賴去實現自定義 UI,完善業務邏輯。但除了按鈕和 ListView 這些動態的組件之外,我們還無法響應用戶交互行為。那今天的分享中,我就著重與你講述 Flutter 是如何監聽和響應用戶的手勢操作的。
手勢操作在 Flutter 中分為兩類:
* 第一類是原始的指針事件(Pointer Event),即原生開發中常見的觸摸事件,表示屏幕上觸摸(或鼠標、手寫筆)行為觸發的位移行為;
* 第二類則是手勢識別(Gesture Detector),表示多個原始指針事件的組合操作,如點擊、雙擊、長按等,是指針事件的語義化封裝。
接下來,我們先看一下原始的指針事件。
## 指針事件
指針事件表示用戶交互的原始觸摸數據,如手指接觸屏幕 PointerDownEvent、手指在屏幕上移動 PointerMoveEvent、手指抬起 PointerUpEvent,以及觸摸取消 PointerCancelEvent,這與原生系統的底層觸摸事件抽象是一致的。
在手指接觸屏幕,觸摸事件發起時,Flutter 會確定手指與屏幕發生接觸的位置上究竟有哪些組件,并將觸摸事件交給最內層的組件去響應。與瀏覽器中的事件冒泡機制類似,事件會從這個最內層的組件開始,沿著組件樹向根節點向上冒泡分發。
不過 Flutter 無法像瀏覽器冒泡那樣取消或者停止事件進一步分發,我們只能通過 hitTestBehavior 去調整組件在命中測試期內應該如何表現,比如把觸摸事件交給子組件,或者交給其視圖層級之下的組件去響應。
關于組件層面的原始指針事件的監聽,Flutter 提供了 Listener Widget,可以監聽其子 Widget 的原始指針事件。
現在,我們一起看一個 Listener 的案例。我定義了一個寬度為 300 的紅色正方形 Container,利用 Listener 監聽其內部 Down、Move 及 Up 事件:
~~~
Listener(
child: Container(
color: Colors.red,// 背景色紅色
width: 300,
height: 300,
),
onPointerDown: (event) => print("down $event"),// 手勢按下回調
onPointerMove: (event) => print("move $event"),// 手勢移動回調
onPointerUp: (event) => print("up $event"),// 手勢抬起回調
);
~~~
我們試著在紅色正方形區域內進行觸摸點擊、移動、抬起,可以看到 Listener 監聽到了一系列原始指針事件,并打印出了這些事件的位置信息:
~~~
I/flutter (13829): up PointerUpEvent(Offset(97.7, 287.7))
I/flutter (13829): down PointerDownEvent(Offset(150.8, 313.4))
I/flutter (13829): move PointerMoveEvent(Offset(152.0, 313.4))
I/flutter (13829): move PointerMoveEvent(Offset(154.6, 313.4))
I/flutter (13829): up PointerUpEvent(Offset(157.1, 312.3))
~~~
## 手勢識別
使用 Listener 可以直接監聽指針事件。不過指針事件畢竟太原始了,如果我們想要獲取更多的觸摸事件細節,比如判斷用戶是否正在拖拽控件,直接使用指針事件的話就會非常復雜。
通常情況下,響應用戶交互行為的話,我們會使用封裝了手勢語義操作的 Gesture,如點擊 onTap、雙擊 onDoubleTap、長按 onLongPress、拖拽 onPanUpdate、縮放 onScaleUpdate 等。另外,Gesture 可以支持同時分發多個手勢交互行為,意味著我們可以通過 Gesture 同時監聽多個事件。
**Gesture 是手勢語義的抽象,而如果我們想從組件層監聽手勢,則需要使用 GestureDetector**。GestureDetector 是一個處理各種高級用戶觸摸行為的 Widget,與 Listener 一樣,也是一個功能性組件。
接下來,我們通過一個案例來看看 GestureDetector 的用法。
我定義了一個 Stack 層疊布局,使用 Positioned 組件將 1 個紅色的 Container 放置在左上角,并同時監聽點擊、雙擊、長按和拖拽事件。在拖拽事件的回調方法中,我們更新了 Container 的位置:
~~~
// 紅色 container 坐標
double _top = 0.0;
double _left = 0.0;
Stack(// 使用 Stack 組件去疊加視圖,便于直接控制視圖坐標
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(// 手勢識別
child: Container(color: Colors.red,width: 50,height: 50),// 紅色子視圖
onTap: ()=>print("Tap"),// 點擊回調
onDoubleTap: ()=>print("Double Tap"),// 雙擊回調
onLongPress: ()=>print("Long Press"),// 長按回調
onPanUpdate: (e) {// 拖動回調
setState(() {
// 更新位置
_left += e.delta.dx;
_top += e.delta.dy;
});
},
),
)
],
);
~~~
運行這段代碼,并查看控制臺輸出,可以看到,紅色的 Container 除了可以響應我們的拖拽行為外,還能夠同時響應點擊、雙擊、長按這些事件。
:-: 
圖 1 GestureDetector 示例
盡管在上面的例子中,我們對一個 Widget 同時監聽了多個手勢事件,但最終只會有一個手勢能夠得到本次事件的處理權。對于多個手勢的識別,Flutter 引入了**手勢競技場(Arena)**的概念,用來識別究竟哪個手勢可以響應用戶事件。手勢競技場會考慮用戶觸摸屏幕的時長、位移以及拖動方向,來確定最終手勢。
**那手勢競技場具體是怎么實現的呢?**
實際上,GestureDetector 內部對每一個手勢都建立了一個工廠類(Gesture Factory)。而工廠類的內部會使用手勢識別類(GestureRecognizer),來確定當前處理的手勢。
而所有手勢的工廠類都會被交給 RawGestureDetector 類,以完成監測手勢的大量工作:使用 Listener 監聽原始指針事件,并在狀態改變時把信息同步給所有的手勢識別器,然后這些手勢會在競技場決定最后由誰來響應用戶事件。
有些時候我們可能會在應用中給多個視圖注冊同類型的手勢監聽器,比如微博的信息流列表中的微博,點擊不同區域會有不同的響應:點擊頭像會進入用戶個人主頁,點擊圖片會進入查看大圖頁面,點擊其他部分會進入微博詳情頁等。
像這樣的手勢識別發生在多個存在父子關系的視圖時,手勢競技場會一并檢查父視圖和子視圖的手勢,并且通常最終會確認由子視圖來響應事件。而這也是合乎常理的:從視覺效果上看,子視圖的視圖層級位于父視圖之上,相當于對其進行了遮擋,因此從事件處理上看,子視圖自然是事件響應的第一責任人。
在下面的示例中,我定義了兩個嵌套的 Container 容器,分別加入了點擊識別事件:
~~~
GestureDetector(
onTap: () => print('Parent tapped'),// 父視圖的點擊回調
child: Container(
color: Colors.pinkAccent,
child: Center(
child: GestureDetector(
onTap: () => print('Child tapped'),// 子視圖的點擊回調
child: Container(
color: Colors.blueAccent,
width: 200.0,
height: 200.0,
),
),
),
),
);
~~~
運行這段代碼,然后在藍色區域進行點擊,可以發現:盡管父容器也監聽了點擊事件,但 Flutter 只響應了子容器的點擊事件。
~~~
I/flutter (16188): Child tapped
~~~
:-: 
圖 2 父子嵌套 GestureDetector 示例
為了讓父容器也能接收到手勢,我們需要同時使用 RawGestureDetector 和 GestureFactory,來改變競技場決定由誰來響應用戶事件的結果。
在此之前,**我們還需要自定義一個手勢識別器**,讓這個識別器在競技場被 PK 失敗時,能夠再把自己重新添加回來,以便接下來還能繼續去響應用戶事件。
在下面的代碼中,我定義了一個繼承自點擊手勢識別器 TapGestureRecognizer 的類,并重寫了其 rejectGesture 方法,手動地把自己又復活了:
~~~
class MultipleTapGestureRecognizer extends TapGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
~~~
接下來,我們需要將手勢識別器和其工廠類傳遞給 RawGestureDetector,以便用戶產生手勢交互事件時能夠立刻找到對應的識別方法。事實上,RawGestureDetector 的初始化函數所做的配置工作,就是定義不同手勢識別器和其工廠類的映射關系。
這里,由于我們只需要處理點擊事件,所以只配置一個識別器即可。工廠類的初始化采用 GestureRecognizerFactoryWithHandlers 函數完成,這個函數提供了手勢識別對象創建,以及對應的初始化入口。
在下面的代碼中,我們完成了自定義手勢識別器的創建,并設置了點擊事件回調方法。需要注意的是,由于我們只需要在父容器監聽子容器的點擊事件,所以只需要將父容器用 RawGestureDetector 包裝起來就可以了,而子容器保持不變:
~~~
RawGestureDetector(// 自己構造父 Widget 的手勢識別映射關系
gestures: {
// 建立多手勢識別器與手勢識別工廠類的映射關系,從而返回可以響應該手勢的 recognizer
MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
MultipleTapGestureRecognizer>(
() => MultipleTapGestureRecognizer(),
(MultipleTapGestureRecognizer instance) {
instance.onTap = () => print('parent tapped ');// 點擊回調
},
)
},
child: Container(
color: Colors.pinkAccent,
child: Center(
child: GestureDetector(// 子視圖可以繼續使用 GestureDetector
onTap: () => print('Child tapped'),
child: Container(...),
),
),
),
);
~~~
運行一下這段代碼,我們可以看到,當點擊藍色容器時,其父容器也收到了 Tap 事件。
~~~
I/flutter (16188): Child tapped
I/flutter (16188): parent tapped
~~~
## 總結
好了,今天的分享就到這里。我們來簡單回顧下 Flutter 是如何響應用戶事件的。
首先,我們了解了 Flutter 底層原始指針事件,以及對應的監聽方式和冒泡分發機制。
然后,我們學習了封裝了底層指針事件手勢語義的 Gesture,了解了多個手勢的識別方法,以及其同時支持多個手勢交互的能力。
最后,我與你介紹了 Gesture 的事件處理機制:在 Flutter 中,盡管我們可以對一個 Widget 監聽多個手勢,或是對多個 Widget 監聽同一個手勢,但 Flutter 會使用手勢競技場來進行各個手勢的 PK,以保證最終只會有一個手勢能夠響應用戶行為。如果我們希望同時能有多個手勢去響應用戶行為,需要去自定義手勢,利用 RawGestureDetector 和手勢工廠類,在競技場 PK 失敗時,手動把它復活。
在處理多個手勢識別場景,很容易出現手勢沖突的問題。比如,當需要對圖片進行點擊、長按、旋轉、縮放、拖動等操作的時候,如何識別用戶當前是點擊還是長按,是旋轉還是縮放。如果想要精確地處理復雜交互手勢,我們勢必需要介入手勢識別過程,解決異常。
不過需要注意的是,沖突的只是手勢的語義化識別過程,原始指針事件是不會沖突的。所以,在遇到復雜的沖突場景通過手勢很難搞定時,我們也可以通過 Listener 直接識別原始指針事件,從而解決手勢識別的沖突。
我把今天分享所涉及到的[事件處理 demo](https://github.com/cyndibaby905/19_gesture_demo)放到了 GitHub 上,你可以下載下來自己運行,進一步鞏固學習效果。
## 思考題
最后,我給你留下兩個思考題吧。
1. 對于一個父容器中存在按鈕 FlatButton 的界面,在父容器使用 GestureDetector 監聽了 onTap 事件的情況下,如果我們點擊按鈕,父容器的點擊事件會被識別嗎,為什么?
2. 如果監聽的是 onDoubleTap 事件,在按鈕上雙擊,父容器的雙擊事件會被識別嗎,為什么?
- 前言
- 開篇詞
- 預習篇
- 01丨預習篇 · 從0開始搭建Flutter工程環境
- 02丨預習篇 · Dart語言概覽
- Flutter開發起步
- 03丨深入理解跨平臺方案的歷史發展邏輯
- 04丨Flutter區別于其他方案的關鍵技術是什么?
- 05丨從標準模板入手,體會Flutter代碼是如何運行在原生系統上的
- Dart語言基礎
- 06丨基礎語法與類型變量:Dart是如何表示信息的?
- 07丨函數、類與運算符:Dart是如何處理信息的?
- 08丨綜合案例:掌握Dart核心特性
- Flutter基礎
- 09丨Widget,構建Flutter界面的基石
- 10丨Widget中的State到底是什么?
- 11丨提到生命周期,我們是在說什么?
- 12丨經典控件(一):文本、圖片和按鈕在Flutter中怎么用?
- 13丨ListView在Flutter中是什么?
- 14 丨 經典布局:如何定義子控件在父容器中排版位置?
- 15 丨 組合與自繪,我該選用何種方式自定義Widget?
- 16 丨 從夜間模式說起,如何定制不同風格的App主題?
- 17丨依賴管理(一):圖片、配置和字體在Flutter中怎么用?
- 18丨依賴管理(二):第三方組件庫在Flutter中要如何管理?
- 19丨用戶交互事件該如何響應?
- 20丨關于跨組件傳遞數據,你只需要記住這三招
- 21丨路由與導航,Flutter是這樣實現頁面切換的
- Flutter進階
- 22丨如何構造炫酷的動畫效果?
- 23丨單線程模型怎么保證UI運行流暢?
- 24丨HTTP網絡編程與JSON解析
- 25丨本地存儲與數據庫的使用和優化
- 26丨如何在Dart層兼容Android-iOS平臺特定實現?(一)
- 27丨如何在Dart層兼容Android-iOS平臺特定實現?(二)
- 28丨如何在原生應用中混編Flutter工程?
- 29丨混合開發,該用何種方案管理導航棧?
- 30丨為什么需要做狀態管理,怎么做?
- 31丨如何實現原生推送能力?
- 32丨適配國際化,除了多語言我們還需要注意什么
- 33丨如何適配不同分辨率的手機屏幕?
- 34丨如何理解Flutter的編譯模式?
- 35丨HotReload是怎么做到的?
- 36丨如何通過工具鏈優化開發調試效率?
- 37丨如何檢測并優化FlutterApp的整體性能表現?
- 38丨如何通過自動化測試提高交付質量?
- Flutter綜合應用
- 39丨線上出現問題,該如何做好異常捕獲與信息采集?
- 40丨衡量FlutterApp線上質量,我們需要關注這三個指標
- 41丨組件化和平臺化,該如何組織合理穩定的Flutter工程結構?
- 42丨如何構建高效的FlutterApp打包發布環境?
- 43丨如何構建自己的Flutter混合開發框架(一)?
- 44丨如何構建自己的Flutter混合開發框架(二)?
- 結束語
- 結束語丨勿畏難,勿輕略