大前端最重要的两个事情,界面渲染和事件分发。下面我们通过源码和一些实例聊一聊Flutter的手势事件从接收到响应的整个过程。
以下源代码基于
- Flutter 2.5.1
- Dart 2.14.2
原始事件获取
在大前端中,大部分平台或系统原始指针处理方式都是类似的,按下、移动、抬起,其它高级事件如双击、滚动都是基于此来实现的。
在Flutter中,接收事件的入口是从_dispatchPointerDataPacket
开始的,通过绑定回调后面会调到_handlePointerDataPacket
开始处理事件
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
_pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
if (!locked)
_flushPointerEventQueue();
}
2
3
4
5
packet
是从引擎层过来的手势数据,其中packet.data
是一个数组,如果在电脑PC上只有鼠标操作时它的长度是1,在移动端上比如我们按下屏幕会同时触发hover
和down
事件,它的长度是2,它的类型是PointerData
,它有一些重要属性
- change(PointerChange): 手势变化,如常见的
hover
、down
、move
、up
等 - kind(PointerDeviceKind): 触发手势的装置,如
touch
(移动设备屏幕)、mouse
(鼠标)等 - signalKind(PointerSignalKind): 除了手势外的响应方式,如鼠标滚轮滚动
PointerEventConverter.expand
会将原始获取的手势数据转化为PointerEvent
类,不同手势类型会继承它实现不同的类
- PointerCancelEvent: 手势取消,一般用于重置正在运行中的手势,可Native触发,也可以调用
cancelPointer
触发,经常用于当跳转新页面后,将原来页面正在运行的手势重置(混合开发时,经常会碰到Flutter页跳转到Native后返回,Flutter页面卡顿,就是手势没有正确取消,可以看我这篇文章解决混合栈跳转导致Flutter页面事件卡死问题 - PointerDownEvent: 当手指或鼠标左键按下触发
- PointerMoveEvent: 当手指在屏幕上滑动或鼠标左键按下滑动触发
- PointerUpEvent: 当手指移出屏幕或鼠标左键抬起时触发
- PointerRemovedEvent: 当手势按下过程中移出到屏幕外触发
- PointerScrollEvent: 一般由鼠标滚轮触发
- PointerHoverEvent: 指针在屏幕上方时触发,常用于鼠标事件,触摸屏按下也会触发
- PointerEnterEvent: 指针进入某个区域时触发,常用于鼠标事件,由
PointerHoverEvent
事件触发计算获得 - PointerExitEvent: 鼠标移出某个区域时触发,常用于鼠标事件,由
PointerHoverEvent
事件触发计算获得
PointerEvent
也有一些重要的属性
- embedderId(int): 平台唯一标识符
- pointer(int): 标识一次完整的手势,如手指按下、手指移动、手指抬起,当一次手势完成此标识会自增
- kind(PointerDeviceKind): 标识手势监听的设备,如手指、鼠标
- position(Offset): 指针相对于屏幕位置
- delta(Offset): 指针偏移量,目前只有
PointerHoverEvent
、PointerMoveEvent
有偏移值 - buttons(int): 标识鼠标、手写笔等按键事件,比如按下鼠标右键时,触发
PointerDownEvent
同时buttons为kSecondaryButton
,它是一个十六进制,Flutter定义了一些常量来标识,我以鼠标按键为例介绍一下- kPrimaryButton(0x01): 鼠标左键
- kSecondaryButton(0x02): 鼠标右键
- kTertiaryButton(0x04): 鼠标中键
- kBackMouseButton(0x08): 鼠标返回键
- kForwardMouseButton(0x08): 鼠标前进键
- down(bool): 是否按下屏幕,如
PointerDownEvent
、PointerMoveEvent
的down都为true
void _flushPointerEventQueue() {
while (_pendingPointerEvents.isNotEmpty)
handlePointerEvent(_pendingPointerEvents.removeFirst());
}
void handlePointerEvent(PointerEvent event) {
if (resamplingEnabled) {
_resampler.addOrDispatch(event);
_resampler.sample(samplingOffset, _samplingClock);
return;
}
_resampler.stop();
_handlePointerEventImmediately(event);
}
2
3
4
5
6
7
8
9
10
11
12
13
上面_flushPointerEventQueue
很简单,就是循环遍历已经存入的指针列表,然后执行handlePointerEvent
,resamplingEnabled
为true的话表示在一些特殊设备上可以给_handlePointerEventImmediately
添加一些延时来让手势更加顺畅。
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult();
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
//...
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
hitTestResult = _hitTests[event.pointer];
}
//...
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_handlePointerEventImmediately
过程有几个部分,当event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent
时,会进行有hitTest
过程,同时如果event
是PointerDownEvent
时,会将获得的hitTest
结果缓存到_hitTests
中。hitTest
通常叫作命中测试
,它是对事件发生范围内的组件进行收集,PointerDownEvent
、PointerSignalEvent
、PointerHoverEvent
这三种类型的手势都是需要接触到屏幕且涉及到指针的变化.
当event is PointerUpEvent || event is PointerCancelEvent
时,代表一次手势的结束,所以它会将result
从_hitTests
中移除并赋值给hitTestResult
,值得注意的是这里就是通过pointer
属性来确定其对应的PointerDownEvent
拿到的命中测试结果的。
当event.down
为true
时,目前只有一种手势会进入到这里,就是PointerMoveEvent
,为什么同样都是在屏幕上操作,它不应该也需要进行hitTest
吗。在手势中,一般地『手指按下』、『手指移动』、『手指抬起』被认为是一次完整的手势过程,手指移动虽然位置会改变,但是它所作用的区域被认为还是在按下的区域,所以只要把按下区域的result
拿来用就行了。
hitTest(命中测试)
在Flutter中,有个类似前端的事件冒泡机制叫命中测试
(Hit Test),它的作用是根据事件的位置收集所有在该位置上的Widget,它先从顶部节点开始对所有RenderObject(如果对RenderObject树不了解可以先看看我的另一篇文章)进行深度遍历,然后从最底层的节点开始向上将每个节点收集到一个为HitTestResult类型的result变量中,最后根据该result进行事件分发。
例子
我以一个例子带大家看看hitTest
过程Flutter底层到底做了些什么。
import 'package:flutter/material.dart';
void main() {
runApp(MyHomePage(
key: UniqueKey(),
));
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Listener(
onPointerDown: (p) {
print('down');
},
child: const ColoredBox(
color: Colors.green,
child: SizedBox(
width: 100,
height: 100,
),
),
),
Listener(
onPointerUp: (p) {
print('up');
},
child: const ColoredBox(
color: Colors.yellow,
child: SizedBox(
width: 100,
height: 100,
),
),
),
],
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
当我点击上面绿色的方块时,命中测试会有以下过程
上面组件会生成如图中的RenderObject
树,在RendBinding
中会去调用RenderView
的hitTest
,RenderView
会先对子组件进行收集,子组件是_RenderColorBox
,在看_RenderColorBox
的hitTest
之前,我们先看看RenderBox
的hitTest
。
bool hitTest(BoxHitTestResult result, { required Offset position }) {
if (_size!.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
2
3
4
5
6
7
8
9
它先简单判断点击位置(position)是否在当前节点范围中(需要注意的是传入的position已经是经过转化相对于当前节点的位置了),这是hitTest的关键,如果发生的事件不在组件范围,hitTest
直接就失败了,就不用再遍历子组件了。
hitTestChildren
然后先调用hitTestChildren
,它在这里很简单,作用是对子组件进行命中测试,如果子组件返回了true,说明子组件命中测试成功。
hitTestChildren
默认返回false,因为可能当前组件没有子组件了。Flutter中比较典型的场景是当前组件存在一个子组件或多个子组件,当只有一个子组件时,大部分组件会使用RenderProxyBoxMixin
这个mixin
,比如SizedBox
、Opacity
、ColoredBox
等,它的hitTestChildren
是
bool hitTestChildren(BoxHitTestResult result, { required Offset position })
return child?.hitTest(result, position: position) ?? false;
}
2
3
很简单,它直接调用子组件的hitTest
。当有多个子组件时,比如Column
、Row
、Stack
、Wrap
、ListBody
等,它们都是自己重写了hitTestChildren
,然后直接调用了RenderBoxContainerDefaultsMixin
中的defaultHitTestChildren
这个方法
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
ChildType? child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData! as ParentDataType;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset? transformed) {
assert(transformed == position - childParentData.offset);
return child!.hitTest(result, position: transformed!);
},
);
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
简单解释一下,它是从最后一个子组件开始,向前遍历,result.addWithPaintOffset
是计算当前子节点的偏移位置,因为传入的position
是相对于父节点的偏移位置。如果一个子组件命中测试返回true
,那么该hitTestChildren
就直接返回true
,不再遍历前面的组件了,这也是如果两个重叠的Stack
子组件,上面的组件能接收到事件,而下面的组件接收不到事件的原因。
如果我想让下面的组件也能接收到事件怎么办呢,这就是behavior
属性该发挥作用了。
hitTestSelf
我们假设所有组件判断自己是否可通过命中测试都是依赖子组件,而看上面我们知道如果没有子组件,hitTestChildren
会返回false
,那就没有组件能通过命中测试,那么整个组件树hitTest
都是返回false
,hitTestSelf
就是解决这个问题。
在hitTest
过程中,如果hitTestSelf
返回true
就是告诉它,我是能接收事件命中的,让我来响应手势事件吧。在Flutter组件中有很多组件是直接将hitTestSelf
返回true
的,如MouseRegion
、Texture
、Image
、EditableText
等组件的RenderObject
对应的hitTestSelf
。有些组件是需要简单的判断,如Listener
、ColoredBox
组件使用的RenderProxyBoxWithHitTestBehavior
这个mixin
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
它是通过behavior
是否为HitTestBehavior.opaque
来确定自己是否返回true
。behavior
这个东西又出现了,下面我们就来看看
behavior
hitTest
过程如果通过其实就做了两个事情,我们将这两个事情取名为A
和B
,A
是将自己添加到HitTestResult result
中,这样我就能响应事件了,二是告诉父组件的hitTestChildren
返回true
。从上面RenderBox
中的hitTest
我们可以看到,这两个事情都是同时发生的,那么有没有可能A
发生B
不发生。答案就在RenderProxyBoxWithHitTestBehavior
这个mixin中。
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
2
3
4
5
6
7
8
9
10
其中behavior
这个属性发挥了重要的作用,它的类型HitTestBehavior
是一个枚举,有三种情况
- HitTestBehavior.deferToChild
deferToChild
是默认行为,事件在组件范围内发生时,当前组件是否添加进命中测试由其子组件来决定。也就是子组件通过测试它才通过,子组件会影响当前组件的命中结果。
- HitTestBehavior.opaque
opaque
表示当事件在组件范围内发生时,不管子组件能否通过命中测试,当前组件都能通过。看上面hitTestSelf
部分,只要behavior
为HitTestBehavior.opaque
,hitTestSelf
就返回true
。
- HitTestBehavior.translucent
translucent
就是比较神奇的存在了,当子组件没有通过命中测试返回了false
,当前组件也能将自己添加到result
中,但是却告诉了父组件当前组件没有通过命中测试,也就是它做了A
,却没有做B
。
它们分别有什么作用和区别呢,下面我们通过几个示例来看看HitTestBehavior
几个属性的作用。
void main() {
runApp(MyHomePage());
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Center(
child: Listener(
onPointerDown: (_) {
print("down");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.red,
child: SizedBox(
width: 100,
height: 100,
),
),
),
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
运行后会得到下面界面
简单解释一下这里IgnorePointer
的作用,为了有点击区域的概念,我使用了ColoredBox
和SizedBox
,让界面中间出现一个红色区域,但ColoredBox
它本身就能接收hitTest
(hitTestSelf
为true),为了不受它的影响,我套了一个IgnorePointer
让Listener
子组件不能通过命中测试。
上面Listener
组件的behavior
默认为HitTestBehavior.deferToChild
,所以无论怎么点击红色区域,上面onPointerDown
都不会有日志输出。
我们稍微改一下
Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down");
},
//...
)
2
3
4
5
6
7
现在再来点击红色区域,发现输出了down
的日志。在我们日常开发中,经常会遇到一个情况,我们使用Container
组件没有设置颜色时,在它外层添加的GestureDetector
点击范围可能只有在文字上,当我们给它设置了个透明色后就正常了,还是因为添加了颜色后,Container
组件会嵌套一个ColoredBox
,ColoredBox
自己有通过命中测试的能力,所以就正常了,其实更好的做法是在GestureDetector
上添加HitTestBehavior.opaque
。
我们再将上面例子改一下
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Center(
child: Stack(
textDirection: TextDirection.ltr,
children: [
Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down1");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.red,
child: SizedBox(
width: 100,
height: 100,
),
),
),
),
Positioned(
top: 50,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) {
print("down2");
},
child: IgnorePointer(
child: ColoredBox(
color: Colors.blue,
child: SizedBox(
width: 100,
height: 50,
),
),
),
),
),
],
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
下面蓝色方块是覆盖在红色方块上的,我们点击蓝色方块,会输出down2
,但怎么都不会输出down1
,如果我们将上面蓝色方块上的behavior
设置成HitTestBehavior.translucent
,将会输出
down2
down1
2
这样,不仅蓝色方块接收到了down事件,而且事件还传递到了下面的红色方块上,让红色方块也接收到了事件。至于为什么是先down2
再down1
这个顺序,我们看看事件分发过程。
事件分发
dispatchEvent
// rendering/binding
// from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
// 处理鼠标如`PointerExitEvent`、`PointerEnterEvent`等事件
_mouseTracker!.updateWithEvent(
event,
() => (hitTestResult == null || event is PointerMoveEvent) ? renderView.hitTestMouseTrackers(event.position) : hitTestResult,
);
super.dispatchEvent(event, hitTestResult);
}
// gesturing/binding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
//...
}
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
//...
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
当hitTestResult
为null,会进行pointerRouter.route
,pointerRouter
可以接收framework其它任何位置提供的回调,为引擎其它地方提供一个可以接收事件通知的能力。
如果hitTestResult
不为空,将会循环遍历命中测试拿到的result
,执行其handleEvent
方法。由于这里循环是数组从左往右执行,所以命中测试和事件分发是先进先出原则,这也就解释了为什么上面示例会先输出down2
再输出down1
。
handleEvent
handleEvent
是HitTestTarget
上的方法,RenderObject
实现了HitTestTarget
,所以所有的RenderObject
类及子类上都可以通过实现handleEvent
来接收到事件响应。
Flutter有很多组件自定义了handleEvent
来处理比较复杂的事件,我们先先来看看Listener
的handleEvent
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent)
return onPointerDown?.call(event);
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
if (event is PointerUpEvent)
return onPointerUp?.call(event);
if (event is PointerHoverEvent)
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
它的实现很简单,就是根据事件类型直接返回给Listener
传入的回调,所以Listener
也可以叫做原始事件监听器,像我们常用的GestureDetector
、Scrollable
(ListView
、PageView
等都是基于它实现的)都是基于Listener
实现的。
再看看GestureBinding
上handleEvent
的实现
// from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
pointerRouter.route(event);
if (event is PointerDownEvent) {
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
}
}
2
3
4
5
6
7
8
9
10
11
GestureBinding
是每次命中测试最后都会被添加到HitTestResult result
中的,所以上面的handleEvent
方法每次事件分发过程的最后都会执行一次,pointerRouter.route
跟hitTestResult
为null时一样。gestureArena
提供一个手势竞争场,这个后面将GestureDetector
组件时再细讲。pointerSignalResolver
目前只是给鼠标滚轮事件使用的,鼠标滚轮事件没有竞争场概念,所以它提供一个能让鼠标事件能够竞争的机会,比如当多个如ListView嵌套时,当发生PointerSignalEvent
事件时,所有ListView都会都滚动,这显然是不对的,pointerSignalResolver
就能解决这个问题。
总结
在Flutter中,当手指或鼠标接触屏幕到对应组件收到事件响应有两个过程,命中测试和事件分发,命中测试用于收集那些可以收到事件的组件,事件分发用于执行对应组件的接收事件函数,这样组件就可以在接收到事件后处理对应的逻辑。Listener
是一个非常原始的事件监听组件,我们很多复杂的手势都可以基于它来实现。我突然想到一个问题,当我触摸移动一段距离再抬起时,PointerDownEvent
、PointerMoveEvent
、PointerUpEvent
会依次触发,按照Listener
组件,这时会同时触发点击事件和拖动事件,这显然不太合理,按照常理这时只会触发拖动事件,为了解决这个问题,Flutter引入了手势竞争的概念,我们后面再继续分析。