爆裂吧现实

不想死就早点睡

0%

Flutter 手势事件解析(一)

本文分为两篇,讲述 flutter 中的指针事件和手势处理逻辑

一、Listener 和 GestureDetector

ListenerGestureDetecotor 是 flutter 中和手势事件相关常用的两个 widget 了,不过这两个还是有很大的差别的。

Listener 是一个纯粹的 指针事件(pointer event) 消费者,它能消费的事件有:onPointerDown, onPointerMove, onPointerUp, onPointerCancel..(这里忽略鼠标事件和不连续事件)*。它不识别任何手势,所以不存在两个 *Listener 竞争同一个指针事件的情况。

GestureDetector 就是处理手势的一个小部件了,它内部其实也是先通过 Listener 来监听基础的指针事件,然后把指针事件转化为不同的手势。因为手势会存在冲突,所以 GestureDetector 有个处理冲突的逻辑,比 Listener 复杂一些。

接下来我将会详细解析下 Listener 的一些处理逻辑,GestureDetector 的讲解放到下一篇文章中。

二、Pointer Event(指针事件)分发逻辑快速预览

为了给想要偷懒的同学一个方便的入口,快速了解 pointer event 事件处理规则,这里先讲解下大致的流程。

首先 flutter framwork 会把 pointer event 分组,一次完整的 down ➡️ move … ➡️ up/cancel 表示一组 pointer event 事件。

在每次触发 down 事件的时候,都会从 root RenderView 开始遍历整棵 RenderObject 树,依照深度优先遍历的顺序开始依次执行每个 RendrObject命中测试

命中测试中,自身是否命中的判断条件一般都是判断指针 down 事件的坐标是否落在当前 RenderObject 的范围内。

如果 命中测试 命中了,就会把自己添加到 hitTestResult 这个对象中。当 RenderObject 树遍历完之后,hitTestResult 中就包含了当前这组指针事件的消费者们了。

1. Render tree 的命中测试流程:

process

RenderObject命中测试过程中,会先判断当前 down 指针事件的位置是否落在当前 RenderObject 的范围内。如果是,则先把 hitTest(..) 分发给 child/children;如果否,则直接判定不命中。

如果 child/chidren 命中了,那么自己也命中了;如果 child/children 没有命中,再判断自己是否需要命中(这个条件是自身控制的,比如是否透明之类的)。

如果 命中测试 命中了,就会把自己添加到 hitTestResult 这个对象中。当 RenderObject 树遍历完之后,hitTestResult 中就包含了当前这组指针事件的消费者们了。

2. 举个简单的例子:

example

如图中所示,当 down 事件来之后,黄色的路径是 hitTest 执行的路径,其中 down 指针事件没有落到 RenderObjectA 内,所以直接返回不命中;RenderObjectB 自身没有命中;Listener3Listener2 自身命中;RootRenderView 因为 child 有命中所以也命中。 

因为是深度优先遍历,所以最后指针事件的消费顺序是 Listener3、Listener2、RootRenderView

需要注意的是这里的 dispatchEvent 是通过 for 循环消费者发送事件,所以不存在拦截这一说,父组件要拦截事件只能在 hitTest 中拦截。这一点和 android 差别很大

到这儿为止,pointer event 的分发处理规则就讲完了,接下来就是枯燥的源码分析了,只想了解 pointer event 分发规则的同学可以逃了。


三、源码分析

1. 指针事件的分发入口

要分析一个流程,可以从入口开始自顶向下,或者直接从消费者自底向上。我这里分析采用第一种,先来看下指针事件分发的一个入口 GestureBinding,它的 _handlePointerEvent(PointerEvent event) 方法就是事件处理的入口:

1
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
void _handlePointerEvent(PointerEvent event) {
HitTestResult hitTestResult;
// PointerSignalEvent 是离散事件,比如鼠标滚轮的事件就是离散事件
if (event is PointerDownEvent || event is PointerSignalEvent) {
hitTestResult = HitTestResult();
// 这里执行 hitTest
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) {
// event.down 表示当前事件是个触摸事件
hitTestResult = _hitTests[event.pointer];
}

if (hitTestResult != null ||
// 以下几个事件都是悬浮指针事件,比如鼠标的悬浮
event is PointerHoverEvent ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
// 开始分发事件
dispatchEvent(event, hitTestResult);
}
}

void dispatchEvent(PointerEvent event, HitTestResult result) {
// 略去不太相关的处理
...
for (final HitTestEntry entry in hitTestResult.path) {
try {
// 遍历 result 中的 target 来消费指针事件
entry.target.handleEvent(event.transformed(entry.transfrom), entry);
} catch (exception, stack) {
...
}
}
}

上述代码中有很多不是触摸事件相关的,比如离散事件,鼠标悬停事件。我这里简单起见只分析触摸事件(其余的事件处理逻辑雷同,省篇幅不多讲述了)

每当收到一个新 pointer event 的时候,会先判断它是否是 down,如果是 down 事件会触发 hitTest(hitTestResult, position) 函数,这个函数非常重要,它决定了哪些消费者来接受这些触摸事件,hitTest(..) 函数结束后,需要接受处理触摸事件的消费者就会保存到 hitTestResult 中,并且把 hitTestResult 保存到成员变量 _hitTests 中。

如果接受到的 pointer event 不是 down,就会根据 event.pointer 这个 id 从 _hitTests 中取对应的 hitTestResult

之前说过 flutter framework 会将 pointer event 分组,一次手指的按下 -> 移动 -> 抬起/取消 算一组,这个 event.pointer 就是这个组的 id

因为在开始的 down 流程中有根据 event.pointer 保存 hitTestResult ,所以这里可以直接取。

处理完 hitTestResult 之后,就开始分发事件了 dispatchEvent(event, hitTestResult),分发会遍历 hitTestResult 中所有的 target 来消费指针事件。

2. hitTest(..) 的处理逻辑

根据之前的分析,所有指针事件(pointer event)的消费者都在 hitTestResult 中,hitTestResult 的计算是在 hitTest(..) 中进行的,接下来我们来看下这部分的代码

1
2
3
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}

可以看到 GestureBindinghitTest(..) 就一句话,把自己当成指针事件的消费者加入到 hitTestResult 中,那么我们平常写的组件是在什么时候加入的?这就要看 RendererBinging 了,它是 GestureBinding 的一个子实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mixin RendererBinding {
void hitTest(HitTestResult result, Offset position) {
rendererView.hitTest(result, position);
super.hitTest(result, position);
}
}

class RendererView extends RenderObject {
bool hitTest(HitTestResult result, {Offset position}) {
// 先执行 child 的 hitTest
if (child != null)
child.hitTest(BoxHitTestResult.wrap(result), position: position);
// 把自己加入到指针事件消费者中
result,add(HitTestEntry(this));
return true;
}
}

代码中可以看到有个 rendererView.hitTest(result, position),这个 rendererView 就是我们接触到的 RenderObject 树的顶层节点了,也就是说到这里为止,hitTest(..) 就开始分发到 RenderObject 树中去了。

RenderObject hitTest 分发逻辑

RenderObject 树是一颗树,所以肯定存在着 hitTest 函数向 child 分发的逻辑,实际上 RenderObject 是没有 hitTest(..) 方法的,实际有这个方法的子类是 RenderBox

RendererView 中的 child 其实也是 RenderBox 类型

1
2
3
4
5
6
7
8
9
10
11
12
13
class RenderBox extends RenderObject {
bool hitTest(BoxHitTestResult result, {@required Offset position}) {
// 只有 position 在当前组件内才会分发
if (_size.contains(position)) {
// 先判断 children,后判断自己,有一个条件符合就会把当前组件加入到事件消费者中
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
}

从代码中可以看到 RenderBox 会先判断 position 是否在自己的范围内,如果是才会分发 hitTest,否则直接返回 false。如果 children 或者自己的 hitTest(..) 成功了,那么会把自己加入到指针事件消费者队列中。

从这里可以看出,hitTest 会先分发给 children,然后才会判断自己。也就是如果父组件和子组件都是消费者的话,最后是子组件先消费,父组件后消费(这个结论很重要,指针事件的消费顺序决定了手势冲突谁优先的问题)

到这里为止,关于 point event 的分发核心逻辑就讲完了,可以总结为以下流程:

  • 设备触发指针事件 point event
    • GestureBinding 判断 point event 是否为 down
      • 是 down:开始 hitTest(..) 测试,获取到 hit 结果 hitTestResult 并保存
        • hitTest(..) 会遍历 renderObject 树,筛选出需要消费的 renderObject,加入到 hitTestResult
      • 否:从 _hitTests 表中根据 event.pointer 这个 id 取到 down 事件时候保存的 hitTestResult
    • 遍历 hitTestResult 中所有的消费者 target,调用 target.handleEvent(..) 消费 point event

是不是感觉很简单?比 android 里面事件处理机制简单太多!

原理讲完后下面我补充几个小 case,对你理解 flutter 内的事件传递设计有很大作用

3. 如何在父组件拦截指针事件?

在父组件拦截指针事件是一种比较常规的操作,在 android 上,直接在父 view 的 dispatchEvent 中不把 event 发给 child 就可以了,但是 flutter 不能这么干,为啥呢?看前面的 dispatchEvent(..) 函数就知道:

1
2
3
4
5
6
7
8
9
10
11
12
void dispatchEvent(PointerEvent event, HitTestResult result) {
// 略去不太相关的处理
...
for (final HitTestEntry entry in hitTestResult.path) {
try {
// 遍历 result 中的 target 来消费指针事件
entry.target.handleEvent(event.transformed(entry.transfrom), entry);
} catch (exception, stack) {
...
}
}
}

Flutter 的 point event 事件分发都是平级的,不存在父子关系!也就是这里其实没有办法拦截事件分发的!

能够拦截子组件无法接受到指针事件是在 hitTest 的时候,只要这个时候有树的概念,framework 中有个可以拦截 hitTest 的组件,叫 IgnorePointer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class IgnorePointer extends SingleChildRenderObjectWidget {
...
@override
RenderIgnorePointer createRenderObject(BuildContext context) {
return RenderIgnorePointer(
ignoring: ignoring,
ignoringSemantics: ignoringSemantics,
);
}
...
}

class RenderIgnorePointer extends RenderProxyBox {
@override
bool hitTest(BoxHitTestResult result, { Offset position }) {
// 如果 ignoring 是 true ,直接返回 false,不走 child 分发
return !ignoring && super.hitTest(result, position: position);
}
}

可以看到代码里面就是重写了 hitTest(..) 方法,然后在需要拦截指针事件的情况下直接返回 false,不做 children 分发,只要不让 children 加入 hitTestResult 中,那么就相当于拦截了 children 的指针事件。

目前来看 flutter 的这种事件传递机制有一个问题,就是无法在发送过程中拦截事件,一旦 down 的时候把 children 加入到 hitTestResult 中,那么之后当前 pointer event 组的所有事件,children 都可以收到。

4. Listener 和 GestureDetector 中的 HitTestBehavior 到底是什么意思?

不知道你有没有被这个字段所困惑过,我反正被困惑过。这个 HitTestBehavior 是个枚举类,有以下几种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// How to behave during hit tests.
enum HitTestBehavior {
/// Targets that defer to their children receive events within their bounds
/// only if one of their children is hit by the hit test.
deferToChild,

/// Opaque targets can be hit by hit tests, causing them to both receive
/// events within their bounds and prevent targets visually behind them from
/// also receiving events.
opaque,

/// Translucent targets both receive events within their bounds and permit
/// targets visually behind them to also receive events.
translucent,
}

我表示当时看这里的注释看不懂,可能我英语太渣

这里可以直接去分析它的源码,其实源码很简单:(代码在 RenderProxyBoxWithHitTestBehavior

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {
@override
bool hitTest(BoxHitTestResult result, { Offset position }) {
bool hitTarget = false;
// 首先和 RenderBox 一样,判断 position 是否落在自己的范围内
if (size.contains(position)) {
// 先 hitTestChildren,后 hitTestSelf 自己,这部分也和 RenderBox 一致
// hitTestSelf 只有在 behavior 是 opaque 的时候才是 true
hitTarget = hitTestChildren(result, position: position)
|| hitTestSelf(position);
// 如果上述结果是 true 或者当前的 behavior 是 translucent
// 把自己加入到消费者队列中
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}

@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
}

从上述代码分析可以看出,HitTestBehavior 只对当前组件的 pointer event 接受有影响,children 的分发了逻辑走的就是 RenderBox 的逻辑,总结下来这三个 flag 有以下效果:

  • deferToChild: 当前组件是否消费指针事件完全取决于儿子,如果儿子不消费,自己也不消费(hitTestSelf(..) 返回是 false,所以 hitTarget 也是 false)
  • opaque: 这个标记的话 hitTestSelf(..) 就会返回 true,也就是只要 hitTestSelf(..) 不被子类重写的话,position 在自己的范围内就会消费指针事件
  • translucent: 只要 position 在自己的范围内,一定会消耗

对于上面的 case,我写了一份 demo:代码链接