在移动端所谓的用户交互事件既是用户的手势操作处理。
手势操作在flutter中可分为两类:
- 第一类是原始的指针事件(Pointer Event),即原生开发中常见的触摸事件,表示屏幕上触摸(或鼠标、手写笔)行为触发所的位移行为。
- 第二类则是手势识别(Gesture Detector),表示多个原生指针事件的组合操作,如点击、双击、长按、等,是指针事件的语义化封装。
指针事件
在移动端,各个平台或UI系统的原始指针事件模型基本都是一致,即:一次完整的事件分为三个阶段:手指按下(PointerDownEvent)、手指移动(PointerMoveEvent)、和手指抬起(PointerUpEvent),还有触摸取消事件(PointerCancelEvent)。
从官方文档中我们可以了解到:指针按下事件(以及该指针的后续事件)然后被分发到由命中测试发现的最内部的组件,然后从那里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件被分发到组件树根的路径上的所有组件。
也就是说flutter的指针事件机制也是基于冒泡事件进行处理的,但是这种冒泡却无法自主停止。只能通过命中测试(Hit Test)去调整组件在生命中测试期内该如何表现,比如把触摸事件交给子组件,或者交给其他视图层级之下的组件去响应。
Flutter中可以使用Listener来监听原始触摸事件,按照本书对组件的分类,则Listener也是一个功能性组件。下面是Listener的构造函数定义:
Listener(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: 300.0,
height: 150.0,
child: Text(_event?.toString()??"",style: TextStyle(color: Colors.white)),
),
onPointerDown: (PointerDownEvent event) => setState(()=> print("down $event"),//手势按下回调
onPointerMove: (PointerMoveEvent event) => setState(()=>print("down $event"),//手势移动回调
onPointerUp: (PointerUpEvent event) => setState(()=>print("down $event"),//手势抬起回调
),
通过拖拽绿色区块可以看到控制台打印如下:
I/flutter (13829): up PointerUpEvent(Offset(97.7, 287.7))
I/flutter (13829): down PointerDownEvent(Offset(110.7, 317.7))
I/flutter (13829): move PointerMoveEvent(Offset(112.9, 317.7))
I/flutter (13829): move PointerMoveEvent(Offset(114.2, 317.7))
I/flutter (13829): up PointerUpEvent(Offset(117.1, 315.7))
上面说到的命中测试是通过PointerEvent中的 behavior 属性控制的。
改属性的值类型为HitTestBehavior,这是一个枚举类,有三个枚举值:
- deferToChild:子组件会一个接一个的进行命中测试,如果子组件中有测试通过的,则当前组件通过,这就意味着,如果指针事件作用于子组件上时,其父级组件也肯定可以收到该事件。
- opaque:在命中测试时,将当前组件当成不透明处理(即使本身是透明的),最终的效果相当于当前Widget的整个区域都是点击区域。举个例子:
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(300.0, 150.0)),
child: Center(child: Text("Box A")),
),
//behavior: HitTestBehavior.opaque,
onPointerDown: (event) => print("down A")
),
上例中,只有点击文本内容区域才会触发点击事件,因为 deferToChild 会去子组件判断是否命中测试,而该例中子组件就是 Text("Box A") 。 如果我们想让整个300×150的矩形区域都能点击我们可以将behavior设为HitTestBehavior.opaque。注意,该属性并不能用于在组件树中拦截(忽略)事件,它只是决定命中测试时的组件大小。
- translucent:当点击组件透明区域时,可以对自身边界内及底部可视区域都进行命中测试,这意味着点击顶部组件透明区域时,顶部组件和底部组件都可以接收到事件,例如:
Stack(
children: <Widget>[
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(300.0, 200.0)),
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.blue)),
),
onPointerDown: (event) => print("down0"),
),
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(200.0, 100.0)),
child: Center(child: Text("左上角200*100范围内非文本区域点击")),
),
onPointerDown: (event) => print("down1"),
//behavior: HitTestBehavior.translucent, //放开此行注释后可以"点透"
)
],
)
上例中,当注释掉最后一行代码后,在左上角200*100范围内非文本区域点击时(顶部组件透明区域),控制台只会打印“down0”,也就是说顶部组件没有接收到事件,而只有底部接收到了。当放开注释后,再点击时顶部和底部都会接收到事件,此时会打印:
I/flutter ( 3039): down1
I/flutter ( 3039): down0
如果behavior值改为HitTestBehavior.opaque,则只会打印"down1"。
手势识别
flutter 是通过GestureDetector和GestureRecognizer处理手势。
GestureDetector 常用的方法:
- onTap:点击
- onDoubleTap:双击
- onLongPress:长按
- onPanUpdate:拖拽
- onScaleUpdate:缩放
- 等等
flutter支持手势监听多个事件,也就是说可以同时监听多个事件。但是由于有手势竞技场(下面解释)出现,因此每次只有一个事件能竞技成功也就是能得到相应。
如下例子所示:假设有一个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("B")), //要拖动和点击的widget
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
onHorizontalDragEnd: (details){
print("onHorizontalDragEnd");
},
onTapDown: (details){
print("down");
},
onTapUp: (details){
print("up");
},
),
)
],
);
}
}
现在我们按住圆形“B”拖动然后抬起手指,控制台日志如下:
I/flutter (17539): down
I/flutter (17539): onHorizontalDragEnd
具体原因:刚开始按下手指时在没有移动时,拖动手势还没有完整的语义,此时TapDown手势胜出(win),此时打印"down",而拖动时,拖动手势会胜出,当手指抬起时,onHorizontalDragEnd 和 onTapUp发生了冲突,但是因为是在拖动的语义中,所以onHorizontalDragEnd胜出,所以就会打印 “onHorizontalDragEnd”。
当然如果只是单纯想要得到按下,抬起,以及拖拽触发,按下和抬起这种单一事件是可以通过结合Listener指针事件进行处理。
Positioned(
top:80.0,
left: _leftB,
child: Listener(
onPointerDown: (details) {
print("down");
},
onPointerUp: (details) {
//会触发
print("up");
},
child: GestureDetector(
child: CircleAvatar(child: Text("C")),
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_leftB += details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
print("onHorizontalDragEnd");
},
),
),
)
如上写法:可以监听到用户点击,拖拽,以及抬起事件。
手势竞技场
其实现方式是通过GestureDetector内部对每一个手势都建立了一个工厂类(Gesture Factory)。而工厂类内部都会使用手势识别类(GestureRecognizer),来确定当前处理的手势。
而所有手势的工厂类都会交给RawGestureDetector类,以完成检测手势大量工作:使用Listener监听原始指针事件,并在状态改变时把信息同步给所有的手势识别器。然后这些手势会在竞技场决定最后又谁来响应用户事件。
但现实场景中也存在当点击时需要父类事件和子类事件同时执行响应的操作。如下代码:
GestureDetector(
onTap: ()=> print('parent tap');
child:Container(
color:Colors.pinkAccent,
child: GestureDetector(
onTap: ()=> print('child tap');
child:Container(
color:Colors.red,
width:200.0,
height:200.0,
),
)
),
);
运行后点击蓝色区域,可以发现只打印了 Child tap;
因此为了能让父类也能接收到手势,我们需要同时使用RawGestureGetector和GestureFactory,来改变竞技场决定谁来响应事件的结果。
① 我们需要自定义一个手势识别器,让改手势识别器在竞技失败时也能够把自己重新添加回来,以便接下来能继续去响应用户事件。
如下定义了一个继承点击手势识别器TapGestureRecognizer的类并重写了器rejectGesture方法,手动的把自己复活:
class MultipleTapGesture extends TapGestureRecognizer{
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
② 需要将手势识别器和其工厂类传递给RawGestureDetector,以便用户产生手势交互事件时能够立刻找到对应的识别方法。RawGestureDetector的初始化函数所做的配置工作,就是定义手势识别器和其工厂类的映射关系。
如下所示:点击蓝色区域时,其父类容器也收到了Tap事件
RawGestureDetector(// 自己构造父 Widget 的手势识别映射关系
gestures: {
// 建立多手势识别器与手势识别工厂类的映射关系,从而返回可以响应该手势的 recognizer
MultipleTapGesture: GestureRecognizerFactoryWithHandlers<
MultipleTapGesture>(
() => MultipleTapGesture(),
(MultipleTapGesture instance) {
instance.onTap = () => print('parent tapped ');// 点击回调
},
)
},
child: Container(
color: Colors.pinkAccent,
child: Center(
child: GestureDetector(// 子视图可以继续使用 GestureDetector
onTap: () => print('Child tapped'),
child: Container(...),
),
),
),
);