本節(jié)先介紹一些 Flutter 中用于處理手勢的GestureDetector
和GestureRecognizer
,然后再仔細(xì)討論一下手勢競爭與沖突問題。
GestureDetector
是一個用于手勢識別的功能性組件,我們通過它可以來識別各種手勢。GestureDetector
實際上是指針事件的語義化封裝,接下來我們詳細(xì)介紹一下各種手勢識別。
我們通過GestureDetector
對Container
進(jìn)行手勢識別,觸發(fā)相應(yīng)事件后,在Container
上顯示事件名,為了增大點(diǎn)擊區(qū)域,將Container
設(shè)置為 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"),//點(diǎn)擊
onDoubleTap: () => updateText("DoubleTap"), //雙擊
onLongPress: () => updateText("LongPress"), //長按
),
);
}
void updateText(String text) {
//更新顯示的事件名
setState(() {
_operation = text;
});
}
}
運(yùn)行效果如圖8-2所示:
注意: 當(dāng)同時監(jiān)聽
onTap
和onDoubleTap
事件時,當(dāng)用戶觸 發(fā)tap 事件時,會有200毫秒左右的延時,這是因為當(dāng)用戶點(diǎn)擊完之后很可能會再次點(diǎn)擊以觸發(fā)雙擊事件,所以GestureDetector
會等一段時間來確定是否為雙擊事件。如果用戶只監(jiān)聽了onTap
(沒有監(jiān)聽onDoubleTap
)事件時,則沒有延時。
一次完整的手勢過程是指用戶手指按下到抬起的整個過程,期間,用戶按下手指后可能會移動,也可能不會移動。GestureDetector
對于拖動和滑動事件是沒有區(qū)分的,他們本質(zhì)上是一樣的。GestureDetector
會將要監(jiān)聽的組件的原點(diǎn)(左上角)作為本次手勢的原點(diǎn),當(dāng)用戶在監(jiān)聽的組件上按下手指時,手勢識別就會開始。下面我們看一個拖動圓形字母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")),
//手指按下時會觸發(fā)此回調(diào)
onPanDown: (DragDownDetails e) {
//打印手指按下的位置(相對于屏幕)
print("用戶手指按下:${e.globalPosition}");
},
//手指滑動時會觸發(fā)此回調(diào)
onPanUpdate: (DragUpdateDetails e) {
//用戶手指滑動時,更新偏移,重新構(gòu)建
setState(() {
_left += e.delta.dx;
_top += e.delta.dy;
});
},
onPanEnd: (DragEndDetails e){
//打印滑動結(jié)束時在x、y軸上的速度
print(e.velocity);
},
),
)
],
);
}
}
運(yùn)行后,就可以在任意方向拖動了,運(yùn)行效果如圖8-3所示:
日志:
I/flutter ( 8513): 用戶手指按下:Offset(26.3, 101.8)
I/flutter ( 8513): Velocity(235.5, 125.8)
代碼解釋:
DragDownDetails.globalPosition
:當(dāng)用戶按下時,此屬性為用戶按下的位置相對于屏幕(而非父組件)原點(diǎn)(左上角)的偏移。DragUpdateDetails.delta
:當(dāng)用戶在屏幕上滑動時,會觸發(fā)多次 Update 事件,delta
指一次 Update 事件的滑動的偏移量。DragEndDetails.velocity
:該屬性代表用戶抬起手指時的滑動速度(包含 x、y 兩個軸的),示例中并沒有處理手指抬起時的速度,常見的效果是根據(jù)用戶抬起手指時的速度做一個減速動畫。
在本示例中,是可以朝任意方向拖動的,但是在很多場景,我們只需要沿一個方向來拖動,如一個垂直方向的列表,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
可以監(jiān)聽縮放事件,下面示例演示了一個簡單的圖片縮放效果:
class _ScaleTestRouteState extends State<_ScaleTestRoute> {
double _width = 200.0; //通過修改圖片寬度來達(dá)到縮放效果
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
//指定寬度,高度自適應(yīng)
child: Image.asset("./images/sea.png", width: _width),
onScaleUpdate: (ScaleUpdateDetails details) {
setState(() {
//縮放倍數(shù)在0.8到10倍之間
_width=200*details.scale.clamp(.8, 10.0);
});
},
),
);
}
}
運(yùn)行效果如圖8-4所示:
現(xiàn)在在圖片上雙指張開、收縮就可以放大、縮小圖片。本示例比較簡單,實際中我們通常還需要一些其它功能,如雙擊放大或縮小一定倍數(shù)、雙指張開離開屏幕時執(zhí)行一個減速放大動畫等,讀者可以在學(xué)習(xí)完后面“動畫”一章中的內(nèi)容后自己來嘗試實現(xiàn)一下。
GestureDetector
內(nèi)部是使用一個或多個GestureRecognizer
來識別各種手勢的,而GestureRecognizer
的作用就是通過Listener
來將原始指針事件轉(zhuǎn)換為語義手勢,GestureDetector
直接可以接收一個子 widget。GestureRecognizer
是一個抽象類,一種手勢的識別器對應(yīng)一個GestureRecognizer
的子類,F(xiàn)lutter 實現(xiàn)了豐富的手勢識別器,我們可以直接使用。
假設(shè)我們要給一段富文本(RichText
)的不同部分分別添加點(diǎn)擊事件處理器,但是TextSpan
并不是一個 widget,這時我們不能用GestureDetector
,但TextSpan
有一個recognizer
屬性,它可以接收一個GestureRecognizer
。
假設(shè)我們需要在點(diǎn)擊時給文本變色:
import 'package:flutter/gestures.dart';
class _GestureRecognizerTestRouteState
extends State<_GestureRecognizerTestRoute> {
TapGestureRecognizer _tapGestureRecognizer = new TapGestureRecognizer();
bool _toggle = false; //變色開關(guān)
@override
void dispose() {
//用到GestureRecognizer的話一定要調(diào)用其dispose方法釋放資源
_tapGestureRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Text.rich(
TextSpan(
children: [
TextSpan(text: "你好世界"),
TextSpan(
text: "點(diǎn)我變色",
style: TextStyle(
fontSize: 30.0,
color: _toggle ? Colors.blue : Colors.red
),
recognizer: _tapGestureRecognizer
..onTap = () {
setState(() {
_toggle = !_toggle;
});
},
),
TextSpan(text: "你好世界"),
]
)
),
);
}
}
運(yùn)行效果:
注意:使用
GestureRecognizer
后一定要調(diào)用其dispose()
方法來釋放資源(主要是取消內(nèi)部的計時器)。
如果在上例中我們同時監(jiān)聽水平和垂直方向的拖動事件,那么我們斜著拖動時哪個方向會生效?實際上取決于第一次移動時兩個軸上的位移分量,哪個軸的大,哪個軸在本次滑動事件競爭中就勝出。實際上 Flutter 中的手勢識別引入了一個 Arena 的概念,Arena 直譯為“競技場”的意思,每一個手勢識別器(GestureRecognizer
)都是一個“競爭者”(GestureArenaMember
),當(dāng)發(fā)生滑動事件時,他們都要在“競技場”去競爭本次事件的處理權(quán),而最終只有一個“競爭者”會勝出(win)。例如,假設(shè)有一個ListView
,它的第一個子組件也是ListView
,如果現(xiàn)在滑動這個子ListView
,父ListView
會動嗎?答案是否定的,這時只有子ListView
會動,因為這時子ListView
會勝出而獲得滑動事件的處理權(quán)。
我們以拖動手勢為例,同時識別水平和垂直方向的拖動手勢,當(dāng)用戶按下手指時就會觸發(fā)競爭(水平方向和垂直方向),一旦某個方向“獲勝”,則直到當(dāng)次拖動手勢結(jié)束都會沿著該方向移動。代碼如下:
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;
});
},
),
)
],
);
}
}
此示例運(yùn)行后,每次拖動只會沿一個方向移動(水平或垂直),而競爭發(fā)生在手指按下后首次移動(move)時,此例中具體的“獲勝”條件是:首次移動時的位移在水平和垂直方向上的分量大的一個獲勝。
由于手勢競爭最終只有一個勝出者,所以,當(dāng)有多個手勢識別器時,可能會產(chǎn)生沖突。假設(shè)有一個 widget,它可以左右拖動,現(xiàn)在我們也想檢測在它上面手指按下和抬起的事件,代碼如下:
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")), //要拖動和點(diǎn)擊的widget
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
onHorizontalDragEnd: (details){
print("onHorizontalDragEnd");
},
onTapDown: (details){
print("down");
},
onTapUp: (details){
print("up");
},
),
)
],
);
}
}
現(xiàn)在我們按住圓形“A”拖動然后抬起手指,控制臺日志如下:
I/flutter (17539): down
I/flutter (17539): onHorizontalDragEnd
我們發(fā)現(xiàn)沒有打印"up",這是因為在拖動時,剛開始按下手指時在沒有移動時,拖動手勢還沒有完整的語義,此時 TapDown 手勢勝出(win),此時打印"down",而拖動時,拖動手勢會勝出,當(dāng)手指抬起時,onHorizontalDragEnd
和 onTapUp
發(fā)生了沖突,但是因為是在拖動的語義中,所以onHorizontalDragEnd
勝出,所以就會打印 “onHorizontalDragEnd”。如果我們的代碼邏輯中,對于手指按下和抬起是強(qiáng)依賴的,比如在一個輪播圖組件中,我們希望手指按下時,暫停輪播,而抬起時恢復(fù)輪播,但是由于輪播圖組件中本身可能已經(jīng)處理了拖動手勢(支持手動滑動切換),甚至可能也支持了縮放手勢,這時我們?nèi)绻谕獠吭儆?code>onTapDown、onTapUp
來監(jiān)聽的話是不行的。這時我們應(yīng)該怎么做?其實很簡單,通過 Listener 監(jiān)聽原始指針事件就行:
Positioned(
top:80.0,
left: _leftB,
child: Listener(
onPointerDown: (details) {
print("down");
},
onPointerUp: (details) {
//會觸發(fā)
print("up");
},
child: GestureDetector(
child: CircleAvatar(child: Text("B")),
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_leftB += details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
print("onHorizontalDragEnd");
},
),
),
)
手勢沖突只是手勢級別的,而手勢是對原始指針的語義化的識別,所以在遇到復(fù)雜的沖突場景時,都可以通過Listener
直接識別原始指針事件來解決沖突。
更多建議: