## 手勢識別GestureDetector
GestureDetector是一個用于手勢識別的功能性Widget,我們通過它可以來識別各種手勢,它是指針事件的語義化封裝,接下來我們詳細介紹一下各種手勢識別:
### 點擊、雙擊、長按
我們通過GestureDetector對Container進行手勢識別,觸發相應事件后,在Container上顯示事件名,為了增大點擊區域,將Container設置為200×100,代碼如下:
```
class GestureDetectorTestRoute extends StatefulWidget {
@override
_GestureDetectorTestRouteState createState() =>
new _GestureDetectorTestRouteState();
}
class _GestureDetectorTestRouteState extends State<GestureDetectorTestRoute> {
String _operation = "No Gesture detected!"; //保存事件名
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: 200.0,
height: 100.0,
child: Text(_operation,
style: TextStyle(color: Colors.white),
),
),
onTap: () => updateText("Tap"),//點擊
onDoubleTap: () => updateText("DoubleTap"), //雙擊
onLongPress: () => updateText("LongPress"), //長按
),
);
}
void updateText(String text) {
//更新顯示的事件名
setState(() {
_operation = text;
});
}
}
```
運行效果:

**注意**: 當同時監聽`onTap`和`onDoubleTap`事件時,當用戶觸發tap事件時,會有200毫秒左右的延時,這是因為當用戶點擊完之后很可能會再次點擊以觸發雙擊事件,所以GestureDetector會等一斷時間來確定是否為雙擊事件。如果用戶只監聽了`onTap`(沒有監聽`onDoubleTap`)事件時,則沒有延時。
### 拖動、滑動
一次完整的手勢過程是指用戶手指按下到抬起的整個過程,期間,用戶按下手指后可能會移動,也可能不會移動。GestureDetector對于拖動和滑動事件是沒有區分的,他們本質上是一樣的。GestureDetector會將要監聽的widget的原點(左上角)作為本次手勢的原點,當用戶在監聽的widget上按下手指時,手勢識別就會開始。下面我們看一個拖動圓形字母A的示例:
```
class _Drag extends StatefulWidget {
@override
_DragState createState() => new _DragState();
}
class _DragState extends State<_Drag> with SingleTickerProviderStateMixin {
double _top = 0.0; //距頂部的偏移
double _left = 0.0;//距左邊的偏移
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
//手指按下時會觸發此回調
onPanDown: (DragDownDetails e) {
//打印手指按下的位置(相對于屏幕)
print("用戶手指按下:${e.globalPosition}");
},
//手指滑動時會觸發此回調
onPanUpdate: (DragUpdateDetails e) {
//用戶手指滑動時,更新偏移,重新構建
setState(() {
_left += e.delta.dx;
_top += e.delta.dy;
});
},
onPanEnd: (DragEndDetails e){
//打印滑動結束時在x、y軸上的速度
print(e.velocity);
},
),
)
],
);
}
}
```
運行后,就可以在任意方向拖動了:

日志:
```
I/flutter ( 8513): 用戶手指按下:Offset(26.3, 101.8)
I/flutter ( 8513): Velocity(235.5, 125.8)
```
代碼解釋:
- `DragDownDetails.globalPosition`:當用戶按下時,此屬性為用戶按下的位置相對于**屏幕**(而非父widget)原點(左上角)的偏移。
- `DragUpdateDetails.delta`:當用戶在屏幕上滑動時,會觸發多次Update事件,`delta`指一次Update事件的滑動的偏移量。
- `DragEndDetails.velocity`:該屬性代表用戶抬起手指時的滑動速度(包含x、y兩個軸的),示例中并沒有處理手指抬起時的速度,常見的效果是根據用戶抬起手指時的速度做一個減速動畫。
### 單一方向拖動
在本示例中,是可以朝任意方向拖動的,但是在很多場景,我們只需要沿一個方向來拖動,如一個垂直方向的列表,GestureDetector可以只識別特定方向的手勢事件,我們將上面的例子改為只能沿垂直方向拖動:
```
class _DragVertical extends StatefulWidget {
@override
_DragVerticalState createState() => new _DragVerticalState();
}
class _DragVerticalState extends State<_DragVertical> {
double _top = 0.0;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
//垂直方向拖動事件
onVerticalDragUpdate: (DragUpdateDetails details) {
setState(() {
_top += details.delta.dy;
});
}
),
)
],
);
}
}
```
這樣就只能在垂直方向拖動了,如果只想在水平方向滑動同理。
### 縮放
GestureDetector可以監聽縮放事件,下面示例演示了一個簡單的圖片縮放效果:
```
class _ScaleTestRouteState extends State<_ScaleTestRoute> {
double _width = 200.0; //通過修改圖片寬度來達到縮放效果
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
//指定寬度,高度自適應
child: Image.asset("./images/sea.png", width: _width),
onScaleUpdate: (ScaleUpdateDetails details) {
setState(() {
//縮放倍數在0.8到10倍之間
_width=200*details.scale.clamp(.8, 10.0);
});
},
),
);
}
}
```
運行效果:

現在在圖片上雙指張開、收縮就可以放大、縮小圖片。本示例比較簡單,實際中我們通常還需要一些其它功能,如雙擊放大或縮小一定倍數、雙指張開離開屏幕時執行一個減速放大動畫等,我們將在后面“動畫”一章中實現一個完整的縮放Widget。
### GestureRecognizer
GestureDetector內部是使用一個或多個GestureRecognizer來識別各種手勢的,而GestureRecognizer的作用就是通過Listener來將原始指針事件轉換為語義手勢,GestureDetector直接可以接收一個子Widget。GestureRecognizer是一個抽象類,一種手勢的識別器對應一個GestureRecognizer的子類,Flutter實現了豐富的手勢識別器,我們可以直接使用。
#### 示例
假設我們要給一段富文本(RichText)的不同部分分別添加點擊事件處理器,但是TextSpan并不是一個Widget,這時我們不能用GestureDetector,但TextSpan有一個`recognizer`屬性,它可以接收一個GestureRecognizer,假設我們在點擊時給文本變色:
```
import 'package:flutter/gestures.dart';
class _GestureRecognizerTestRouteState
extends State<_GestureRecognizerTestRoute> {
TapGestureRecognizer _tapGestureRecognizer = new TapGestureRecognizer();
bool _toggle = false; //變色開關
@override
void dispose() {
//用到GestureRecognizer的話一定要調用其dispose方法釋放資源
_tapGestureRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Text.rich(
TextSpan(
children: [
TextSpan(text: "你好世界"),
TextSpan(
text: "點我變色",
style: TextStyle(
fontSize: 30.0,
color: _toggle ? Colors.blue : Colors.red
),
recognizer: _tapGestureRecognizer
..onTap = () {
setState(() {
_toggle = !_toggle;
});
},
),
TextSpan(text: "你好世界"),
]
)
),
);
}
}
```
運行效果:

注意:使用GestureRecognizer后一定要調用其`dispose()`方法來釋放資源(主要是取消內部的計時器)。
### 手勢競爭與沖突
#### 競爭
如果在上例中我們同時監聽水平和垂直方向的拖動事件,那么我們斜著拖動時哪個方向會生效?實際上取決于第一次移動時兩個軸上的位移分量,哪個軸的大,哪個軸在本次滑動事件競爭中就勝出。實際上Flutter中的手勢識別引入了一個Arena的概念,Arena直譯為“競技場”的意思,每一個手勢識別器(GestureRecognizer)都是一個“競爭者”(GestureArenaMember),當發生滑動事件時,他們都要在“競技場”去競爭本次事件的處理權,而最終只有一個“競爭者”會勝出(win)。例如,假設有一個ListView,它的第一個子Widget也是ListView,如果現在滑動這個子ListView,父ListView會動嗎?答案是否定的,這時只有子Widget會動,因為這時子Widget會勝出而獲得滑動事件的處理權。
**示例**
我們以拖動手勢為例,同時識別水平和垂直方向的拖動手勢,當用戶按下手指時就會觸發競爭(水平方向和垂直方向),一旦某個方向“獲勝”,則直到當次拖動手勢結束都會沿著該方向移動。代碼如下:
```
import 'package:flutter/material.dart';
class BothDirectionTestRoute extends StatefulWidget {
@override
BothDirectionTestRouteState createState() =>
new BothDirectionTestRouteState();
}
class BothDirectionTestRouteState extends State<BothDirectionTestRoute> {
double _top = 0.0;
double _left = 0.0;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
//垂直方向拖動事件
onVerticalDragUpdate: (DragUpdateDetails details) {
setState(() {
_top += details.delta.dy;
});
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
),
)
],
);
}
}
```
此示例運行后,每次拖動只會沿一個方向移動(水平或垂直),而競爭發生在手指按下后首次移動(move)時,此例中具體的“獲勝”條件是:首次移動時的位移在水平和垂直方向上的分量大的一個獲勝。
#### 手勢沖突
由于手勢競爭最終只有一個勝出者,所以,當有多個手勢識別器時,可能會產生沖突。假設有一個widget,它可以左右拖動,現在我們也想檢測在它上面手指按下和抬起的事件,代碼如下:
```
class GestureConflictTestRouteState extends State<GestureConflictTestRoute> {
double _left = 0.0;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")), //要拖動和點擊的widget
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
onHorizontalDragEnd: (details){
print("onHorizontalDragEnd");
},
onTapDown: (details){
print("down");
},
onTapUp: (details){
print("up");
},
),
)
],
);
}
}
```
現在我們按住圓形“A”拖動然后抬起手指,控制臺日志如下:
```
I/flutter (17539): down
I/flutter (17539): onHorizontalDragEnd
```
我們發現沒有打印"up",這是因為在拖動時,剛開始按下手指時在沒有移動時,拖動手勢還沒有完整的語義,此時TapDown手勢勝出(win),此時打印"down",而拖動時,拖動手勢會勝出,當手指抬起時,`onHorizontalDragEnd` 和 `onTapUp`發生了沖突,但是因為是在拖動的語義中,所以`onHorizontalDragEnd`勝出,所以就會打印 “onHorizontalDragEnd”。如果我們的代碼邏輯中,對于手指按下和抬起是強依賴的,比如在一個輪播圖組件中,我們希望手指按下時,暫停輪播,而抬起時恢復輪播,但是由于輪播圖組件中本身可能已經處理了拖動手勢(支持手動滑動切換),甚至可能也支持了縮放手勢,這時我們如果在外部再用`onTapDown`、`onTapUp`來監聽的話是不行的。這時我們應該怎么做?其實很簡單,通過Listener監聽原始指針事件就行:
```
Positioned(
top:80.0,
left: _leftB,
child: Listener(
onPointerDown: (details) {
print("down");
},
onPointerUp: (details) {
//會觸發
print("up");
},
child: GestureDetector(
child: CircleAvatar(child: Text("B")),
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_leftB += details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
print("onHorizontalDragEnd");
},
),
),
)
```
總結:
手勢沖突只是手勢級別的,而手勢是對原始指針的語義化的識別,所以在遇到復雜的沖突場景時,都可以通過Listener直接識別原始指針事件來解決沖突。
- 緣起
- 起步
- 移動開發技術簡介
- Flutter簡介
- 搭建Flutter開發環境
- 常見配置問題
- Dart語言簡介
- 第一個Flutter應用
- 計數器示例
- 路由管理
- 包管理
- 資源管理
- 調試Flutter APP
- Dart線程模型及異常捕獲
- 基礎Widgets
- Widget簡介
- 文本、字體樣式
- 按鈕
- 圖片和Icon
- 單選框和復選框
- 輸入框和表單
- 布局類Widgets
- 布局類Widgets簡介
- 線性布局Row、Column
- 彈性布局Flex
- 流式布局Wrap、Flow
- 層疊布局Stack、Positioned
- 容器類Widgets
- Padding
- 布局限制類容器ConstrainedBox、SizeBox
- 裝飾容器DecoratedBox
- 變換Transform
- Container容器
- Scaffold、TabBar、底部導航
- 可滾動Widgets
- 可滾動Widgets簡介
- SingleChildScrollView
- ListView
- GridView
- CustomScrollView
- 滾動監聽及控制ScrollController
- 功能型Widgets
- 導航返回攔截-WillPopScope
- 數據共享-InheritedWidget
- 主題-Theme
- 事件處理與通知
- 原始指針事件處理
- 手勢識別
- 全局事件總線
- 通知Notification
- 動畫
- Flutter動畫簡介
- 動畫結構
- 自定義路由過渡動畫
- Hero動畫
- 交錯動畫
- 自定義Widget
- 自定義Widget方法簡介
- 通過組合現有Widget實現
- 實例:TurnBox
- CustomPaint與Canvas
- 實例:圓形漸變進度條(自繪)
- 文件操作與網絡請求
- 文件操作
- Http請求-HttpClient
- Http請求-Dio package
- 實例:Http分塊下載
- WebSocket
- 使用Socket API
- Json轉Model
- 包與插件
- 開發package
- 插件開發:平臺通道簡介
- 插件開發:實現Android端API
- 插件開發:實現IOS端API
- 系統能力調用
- 國際化
- 讓App支持多語言
- 實現Localizations
- 使用Intl包
- Flutter核心原理
- Flutter UI系統
- Element和BuildContext
- RenderObject與RenderBox
- Flutter從啟動到顯示