You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

223 lines
12 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 19 | 用户交互事件该如何响应?
你好,我是陈航。今天,我和你分享的主题是,如何响应用户交互事件。
在前面两篇文章中我和你一起学习了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除了可以响应我们的拖拽行为外还能够同时响应点击、双击、长按这些事件。
![](https://static001.geekbang.org/resource/image/b8/4e/b8c4fa6e898ef154afc217d5ebf0b54e.gif)
图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
```
![](https://static001.geekbang.org/resource/image/8e/da/8e99c7bbc5d0b52b3ba80263f989e7da.png)
图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事件在按钮上双击父容器的双击事件会被识别吗为什么
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。