注:本文代码基于Flutter SDK 3.13.5

一、什么是Listener

Listener可以用来监听原始指针事件(Raw Pointer 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/// 调用回调以响应常见指针事件的widget
/// A widget that calls callbacks in response to common pointer events.
///
/// 它侦听可以构造手势的事件,例如按下、移动、然后释放或取消指针时
/// It listens to events that can construct gestures, such as when the
/// pointer is pressed, moved, then released or canceled.
///
/// 它不侦听鼠标独有的事件,例如当鼠标进入、退出或悬停某个区域而不按下任何按钮时。对于这些事件,请使用MouseRegion
/// It does not listen to events that are exclusive to mouse, such as when the
/// mouse enters, exits or hovers a region without pressing any buttons. For
/// these events, use [MouseRegion].
///
/// 考虑使用GestureDetector监听更高级别的手势,而不是监听原始指针事件
/// Rather than listening for raw pointer events, consider listening for
/// higher-level gestures using [GestureDetector].
class Listener extends SingleChildRenderObjectWidget {

  const Listener({
    super.key,
    this.onPointerDown,
    this.onPointerMove,
    this.onPointerUp,
    this.onPointerHover,
    this.onPointerCancel,
    this.onPointerPanZoomStart,
    this.onPointerPanZoomUpdate,
    this.onPointerPanZoomEnd,
    this.onPointerSignal,
    this.behavior = HitTestBehavior.deferToChild,
    super.child,
  });

  final PointerDownEventListener? onPointerDown;

  final PointerMoveEventListener? onPointerMove;

  final PointerUpEventListener? onPointerUp;

  final PointerHoverEventListener? onPointerHover;

  final PointerCancelEventListener? onPointerCancel;

  final PointerPanZoomStartEventListener? onPointerPanZoomStart;

  final PointerPanZoomUpdateEventListener? onPointerPanZoomUpdate;

  final PointerPanZoomEndEventListener? onPointerPanZoomEnd;

  final PointerSignalEventListener? onPointerSignal;

  final HitTestBehavior behavior;

  @override
  RenderPointerListener createRenderObject(BuildContext context) {
    return RenderPointerListener(
      onPointerDown: onPointerDown,
      onPointerMove: onPointerMove,
      onPointerUp: onPointerUp,
      onPointerHover: onPointerHover,
      onPointerCancel: onPointerCancel,
      onPointerPanZoomStart: onPointerPanZoomStart,
      onPointerPanZoomUpdate: onPointerPanZoomUpdate,
      onPointerPanZoomEnd: onPointerPanZoomEnd,
      onPointerSignal: onPointerSignal,
      behavior: behavior,
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderPointerListener renderObject) {
    renderObject
      ..onPointerDown = onPointerDown
      ..onPointerMove = onPointerMove
      ..onPointerUp = onPointerUp
      ..onPointerHover = onPointerHover
      ..onPointerCancel = onPointerCancel
      ..onPointerPanZoomStart = onPointerPanZoomStart
      ..onPointerPanZoomUpdate = onPointerPanZoomUpdate
      ..onPointerPanZoomEnd = onPointerPanZoomEnd
      ..onPointerSignal = onPointerSignal
      ..behavior = behavior;
  }

}

可以看到,Listener的代码并不多,它继承自SingleChildRenderObjectWidget,所创建的RenderObjectRenderPointerListener

Listener中包含了许多原始指针事件的监听回调,这里列出几个较为常见的。

Listener回调 说明
PointerDownEventListener 当指针接触屏幕(对于触摸指针)或在此Widget的位置按下其按钮(对于鼠标指针)时调用
PointerMoveEventListener 当触发onPointerDown的指针改变位置时调用
PointerUpEventListener 当触发onPointerDown的指针不再与屏幕接触时调用
PointerCancelEventListener 当触发onPointerDown的指针的输入不再定向到该接收器时调用。

当触发指针事件时,监听回调的入参PointerDownEventPointerMoveEventPointerUpEventPointerCancelEvent均是PointerEvent的子类。

PointerEvent类是触摸、手写笔或鼠标事件的基类,它包含了很多属性,这里列出几个较为常见的。

属性 说明
kind 为其生成事件的输入设备的类型
position 指针位置的坐标,以全局坐标空间中的逻辑像素为单位
delta 自上次PointerMoveEventPointerHoverEvent以来指针移动的距离(以逻辑像素为单位)。对于downupcancel事件,该值始终为 0.0
pressure 表示触摸的压力。该值是一个范围从 0.0(表示没有可辨别压力的触摸)到 1.0(表示具有“正常”压力的触摸)的数字,并且可能超过 1.0(表示有更强的触摸)。对于不检测压力的设备(例如鼠标),返回 1.0
orientation 检测到的物体的方向角,以弧度为单位。
transform 用于将该事件从全局坐标空间变换到事件接收者的坐标空间的变换。该值影响localPositionlocalDelta返回的内容。如果该值为空,则将其视为恒等变换
localPosition 根据transformposition变换到事件接收者的本地坐标系中。如果此事件尚未转换,则按原样返回position

除此之外,Listener还有一个behavior参数需要注意一下,它的作用是决定子组件如何响应命中测试,如果不传入该参数,默认值为HitTestBehavior.deferToChild,关于behavior的分析本文后面会讲。

二、Listener示例

为了便于分析,在Listener示例中丢弃了runApp方法带来的一个View结构(在实际项目中不能这样做),具体分析看解读Flutter源码之runApp一文。

程序运行起来后,通过Flutter Inspector查看Widget层次结构如下所示。

对应的代码为:

 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
void main() {
  _runApp(const MyApp());
}

void _runApp(Widget app) {
  final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized();
  Timer.run(() {
    binding.attachRootWidget(app);
  });
  binding.scheduleWarmUpFrame();
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Listener(
      child: const ColoredBox(color: Colors.blueAccent),
      onPointerDown: (PointerDownEvent event) => debugPrint('$event'),
      onPointerMove: (PointerMoveEvent event) => debugPrint('$event'),
      onPointerUp: (PointerUpEvent event) => debugPrint('$event'),
      onPointerCancel: (PointerCancelEvent event) => debugPrint('$event'),
    );
  }
}

当在屏幕上触摸时,打印的日志如下:

1
2
3
4
I/flutter (26592): _TransformedPointerDownEvent#ec489(position: Offset(152.4, 615.2))
I/flutter (26592): _TransformedPointerMoveEvent#27677(position: Offset(155.0, 615.2))
I/flutter (26592): _TransformedPointerMoveEvent#7e2ef(position: Offset(157.7, 615.2))
I/flutter (26592): _TransformedPointerUpEvent#fd843(position: Offset(157.7, 615.2))

从日志可以知道,在移动端各个平台或UI系统的原始指针事件模型基本都是一致,即:一次完整的事件分为三个阶段:手指按下、手指移动、和手指抬起。

三、源码分析

3.1、_dispatchPointerDataPacket方法

当特定平台的原始指针事件模型(例如AndroidMotionEvent)转换&流转到FlutterEngine层时,Engine层就会通过 _dispatchPointerDataPacket 方法回调到Flutterframework层,此时的原始指针事件模型已被转换为ByteData

1
2
3
4
5
// /FlutterSDK/flutter/bin/cache/pkg/sky_engine/lib/ui/hooks.dart
@pragma('vm:entry-point')
void _dispatchPointerDataPacket(ByteData packet) {
  PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}

看下PlatformDispatcher的成员方法 _dispatchPointerDataPacket,可以发现如果onPointerDataPacket回调不为null,那么就会调用 _invoke1 方法,并且将onPointerDataPacket回调作为参数传入了 _invoke1 方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
PointerDataPacketCallback? get onPointerDataPacket => _onPointerDataPacket;
PointerDataPacketCallback? _onPointerDataPacket;
Zone _onPointerDataPacketZone = Zone.root;
set onPointerDataPacket(PointerDataPacketCallback? callback) {
  _onPointerDataPacket = callback;
  _onPointerDataPacketZone = Zone.current;
}

// Called from the engine, via hooks.dart
void _dispatchPointerDataPacket(ByteData packet) {
  if (onPointerDataPacket != null) {
    _invoke1<PointerDataPacket>(
      // 传入PointerDataPacketCallback回调
      onPointerDataPacket,
      _onPointerDataPacketZone,
      _unpackPointerDataPacket(packet),
    );
  }
}

来看下PointerDataPacketCallback回调的声明,可以发现此时接收的参数为PointerDataPacket,说明原始指针事件模型已由ByteData转换为PointerDataPacket,这是在 _invoke1 方法中完成的。

1
typedef PointerDataPacketCallback = void Function(PointerDataPacket packet);

跟踪下 _invoke1 方法的源码,可以发现调用了callback并且传入了arg参数,此时PointerDataPacketarg,而arg则是 _unpackPointerDataPacket(packet) 方法进行传入的,说明原始指针事件模型ByteData转换为PointerDataPacket是通过 _unpackPointerDataPacket 方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// /FlutterSDK/flutter/bin/cache/pkg/sky_engine/lib/ui/hooks.dart
void _invoke1<A>(void Function(A a)? callback, Zone zone, A arg) {
  if (callback == null) {
    return;
  }
  if (identical(zone, Zone.current)) {
    callback(arg);
  } else {
    zone.runUnaryGuarded<A>(callback, arg);
  }
}

OK,回到 _dispatchPointerDataPacket 方法,那么PointerDataPacketCallback回调的实例是在哪里创建的呢?

答案是在GestureBindinginitInstances方法中创建的,看下它的源码,可以发现将 _handlePointerDataPacket 方法赋值给了platformDispatcheronPointerDataPacket回调。

1
2
3
4
5
6
7
8
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
    platformDispatcher.onPointerDataPacket = _handlePointerDataPacket;
  }
}

继续跟踪下GestureBinding_handlePointerDataPacket 方法的源码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();

void _handlePointerDataPacket(ui.PointerDataPacket packet) {
  // 我们将指针数据转换为逻辑像素,以便例如触摸斜率可以以与设备无关的方式定义。
  // We convert pointer data to logical pixels so that e.g. the touch slop can be
  // defined in a device-independent manner.
  try {
    _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, _devicePixelRatioForView));
    // lockEvents当前是否正在锁定事件。绑定触发事件的子类应该首先检查它,如果设置了,则对事件进行排队而不是触发它们。调用unlocked时应刷新事件。
    // 主要用于非用户交互时间,例如允许reassembleApplication在遍历树时阻止输入(部分异步执行),或者锁定事件,以便触摸事件等在预定帧完成之前不会自行插入。
    if (!locked) {
      _flushPointerEventQueue();
    }
  } catch (error, stack) {
    FlutterError.reportError(FlutterErrorDetails(
      exception: error,
      stack: stack,
      library: 'gestures library',
      context: ErrorDescription('while handling a pointer data packet'),
    ));
  }
}

可以看到,在GestureBinding_handlePointerDataPacket 方法中,将PointerDataPacket转换为了PointerEvent,添加进 _pendingPointerEvents 队列中,然后执行了 _flushPointerEventQueue 方法。

1
2
3
4
5
6
7
void _flushPointerEventQueue() {
  assert(!locked);

  while (_pendingPointerEvents.isNotEmpty) {
    handlePointerEvent(_pendingPointerEvents.removeFirst());
  }
}

GestureBinding_flushPointerEventQueue 方法中,while循环遍历 _pendingPointerEvents 队列,执行handlePointerEvent方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 将事件分派到通过对其位置进行命中测试找到的目标。
void handlePointerEvent(PointerEvent event) {
  assert(!locked);

  if (resamplingEnabled) {
    _resampler.addOrDispatch(event);
    _resampler.sample(samplingOffset, _samplingClock);
    return;
  }

  // Stop resampler if resampling is not enabled. This is a no-op if
  // resampling was never enabled.
  _resampler.stop();
  _handlePointerEventImmediately(event);
}

GestureBindinghandlePointerEvent方法中,执行了 _handlePointerEventImmediately 方法。

 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
41
42
void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent || event is PointerPanZoomStartEvent) {
    assert(!_hitTests.containsKey(event.pointer), 'Pointer of ${event.toString(minLevel: DiagnosticLevel.debug)} unexpectedly has a HitTestResult associated with it.');
    // 分析一
    hitTestResult = HitTestResult();
    hitTestInView(hitTestResult, event.position, event.viewId);
    if (event is PointerDownEvent || event is PointerPanZoomStartEvent) {
      _hitTests[event.pointer] = hitTestResult;
    }
    assert(() {
      if (debugPrintHitTestResults) {
        debugPrint('${event.toString(minLevel: DiagnosticLevel.debug)}: $hitTestResult');
      }
      return true;
    }());
  } else if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent) {
    // 分析二
    hitTestResult = _hitTests.remove(event.pointer);
  } else if (event.down || event is PointerPanZoomUpdateEvent) {
    // 因为指针向下时发生的事件(例如 [PointerMoveEvent])应该分派到其初始 PointerDownEvent 所在的相同位置,所以我们希望重用指针向下时找到的路径,而不是每次都进行命中检测是时候我们得到这样的事件了。
    // Because events that occur with the pointer down (like
    // [PointerMoveEvent]s) should be dispatched to the same place that their
    // initial PointerDownEvent was, we want to re-use the path we found when
    // the pointer went down, rather than do hit detection each time we get
    // such an event.
    // 分析三
    hitTestResult = _hitTests[event.pointer];
  }
  assert(() {
    if (debugPrintMouseHoverEvents && event is PointerHoverEvent) {
      debugPrint('$event');
    }
    return true;
  }());
  // 分析四
  if (hitTestResult != null ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    dispatchEvent(event, hitTestResult);
  }
}
  • 分析一:

如果event的类型为PointerDownEvent,就会创建一个HitTestResult,用来记录执行命中测试的结果。

然后执行hitTestInView方法进行命中测试,最后确保event的类型为PointerDownEvent时,将HitTestResult保存在 _hitTests这个Map中。

  • 分析二:

如果event的类型为PointerUpEvent,或者event的类型为PointerCancelEvent时,从 _hitTests 这个Map中移除此次命中测试的结果。

  • 分析三:

此处event.down表示设置指针当前是否向下。对于触摸和手写笔指针,这意味着物体(手指、笔)与输 入表面接触。对于鼠标来说,这意味着按下了按钮。

如果是相同的指针向下位置,那么其实可以复用之前的命中测试结果,所以从直接从 _hitTests 这个Map中取出hitTestResult即可。

  • 分析四:

如果hitTestResult不为null时,执行dispatchEvent方法将事件发送到给定HitTestResult条目中的每个HitTestTarget,也就是进行事件分发。

3.2、命中测试(Hit Test

3.2.1、什么是命中测试?

由分析一可以知道,如果event的类型为PointerDownEvent,也就是当指针按下时,Flutter会对应用程序执行命中测试(执行hitTestInView方法),作用是为了确定指针与屏幕接触的位置存在哪些组件(Widget)。

然后指针按下事件以及该指针的后续事件将被分发到由命中测试发现的最内部的组件,也就是从那里开始,事件会在组件树中向上冒泡。

这些事件会从最内部的组件开始,沿着组件树中根组件路径的方向,分发给沿途所有命中测试的组件,注意:Flutter中没有机制取消或停止“冒泡”过程。

我们需要知道,命中测试的逻辑都在RenderObject中,而并非在WidgetElement中。

3.2.2、RenderObject单个子节点的命中测试过程(Listener示例)

接着上面分析一,可以知道执行了GestureBindinghitTestInView方法,又因为RendererBindingGestureBinding的子类,并且重写了hitTestInView方法,所以这里会执行RendererBindinghitTestInView方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@override
void hitTestInView(HitTestResult result, Offset position, int viewId) {
  // Currently Flutter only supports one view, the implicit view `renderView`.
  // TODO(dkwingsmt): After Flutter supports multi-view, look up the correct
  // render view for the ID.
  // https://github.com/flutter/flutter/issues/121573
  assert(viewId == _implicitViewId,
      'Unexpected view ID $viewId (expecting implicit view ID $_implicitViewId)');
  assert(viewId == renderView.flutterView.viewId);
  renderView.hitTest(result, position: position);
  super.hitTestInView(result, position, viewId);
}

可以看到,当发生指针事件时,Flutter会从根节点RenderView开始调用它hitTest方法,然后调用super.hitTestInView执行父类GestureBindinghitTestInView方法。

1
2
3
4
5
6
7
8
// 确定哪些HitTestTarget对象位于指定视图中的给定位置
@override // from HitTestable
void hitTestInView(HitTestResult result, Offset position, int viewId) {
  // 这里创建了一个HitTestEntry实例,传入this,此处this指的是WidgetsFlutterBinding,这个看runApp方法就可以知道
  // 然后把HitTestEntry实例添加进HitTestResult保存,该HitTestEntry实例在List中是最后一个位置
  // 这里添加进来和手势识别GestureDetector相关,看它的handleEvent方法,这里本文先不讲
  result.add(HitTestEntry(this));
}

先回到根节点RenderViewhitTest方法,看下它的源码,可以发现执行了childhitTest方法,最后把根节点RenderView添加进了HitTestResult中。那么,这里child是谁呢?很显然,就是示例中的RenderPointerListener

1
2
3
4
5
6
7
bool hitTest(HitTestResult result, { required Offset position }) {
  if (child != null) {
    child!.hitTest(BoxHitTestResult.wrap(result), position: position);
  }
  result.add(HitTestEntry(this));
  return true;
}

继续看下RenderPointerListenerhitTest方法,又因为RenderPointerListener继承自RenderProxyBoxWithHitTestBehavior,而RenderPointerListener没有重写hitTest方法,所以看下RenderProxyBoxWithHitTestBehaviorhitTest方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 确定位于给定位置的渲染对象集
@override
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;
}

RenderProxyBoxWithHitTestBehaviorhitTest方法中,先执行了hitTestChildren方法,这个方法定义在RenderProxyBoxWithHitTestBehavior的父类RenderProxyBox,它所混入的RenderProxyBoxMixin中。

看下RenderProxyBoxMixinhitTestChildren方法。

1
2
3
4
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
  return child?.hitTest(result, position: position) ?? false;
}

RenderProxyBoxMixinhitTestChildren方法中,又执行了childhitTest方法。那么,这里child是谁呢?很显然,就是示例中的 _RenderColoredBox

_RenderColoredBox 继承自RenderProxyBoxWithHitTestBehavior,自身并没有重写hitTest方法,所以执行了RenderProxyBoxWithHitTestBehaviorhitTest方法。

看到这里,已经知道hitTest方法是一个不断调用子节点hitTest方法的递归过程了。很明显,对于 _RenderColoredBox来说是命中测试的,所以返回true_RenderColoredBox会被添加进HitTestResult列表。

之后对于RenderPointerListener来说也是命中测试的,因为它的hitTestChildren方法返回trueRenderPointerListener会被添加进HitTestResult列表。

注意:一个对象是否可以响应事件,取决于在其对命中测试过程中是否被添加到了HitTestResult列表,如果没有被添加进去,则后续的事件分发将不会分发给自己。

此时,HitTestResult列表中保存的命中测试的RenderObject如下所示。

3.2.3、RenderObject多个子节点的命中测试过程

如果一个渲染对象有多个子节点,则命中测试逻辑为:如果任意一个子节点通过了命中测试或者当前节点“强行声明”自己通过了命中测试,则当前节点会通过命中测试。

Row为例,它的RenderObjectRenderFlexRenderFlex重写了父类RenderBoxhitTestChildren方法。

1
2
3
4
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
  return defaultHitTestChildren(result, position: position);
}

RenderFlexhitTestChildren方法中,执行了RenderBoxContainerDefaultsMixindefaultHitTestChildren方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
  // 遍历所有子组件(子节点从后向前遍历)
  ChildType? child = lastChild;
  while (child != null) {
    // The x, y parameters have the top left of the node's box as the origin.
    final ParentDataType childParentData = child.parentData! as ParentDataType;
    // 子组件的hitTest方法的返回值
    final bool isHit = result.addWithPaintOffset(
      offset: childParentData.offset,
      position: position,
      hitTest: (BoxHitTestResult result, Offset transformed) {
        assert(transformed == position - childParentData.offset);
        // 调用子组件的hitTest方法
        return child!.hitTest(result, position: transformed);
      },
    );
    // 一旦有一个子节点的 hitTest() 方法返回 true,则终止遍历,直接返回true
    if (isHit) {
      return true;
    }
    child = childParentData.previousSibling;
  }
  return false;
}

再看下BoxHitTestResultaddWithPaintOffset方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
bool addWithPaintOffset({
  required Offset? offset,
  required Offset position,
  required BoxHitTest hitTest,
}) {
  final Offset transformedPosition = offset == null ? position : position - offset;
  if (offset != null) {
    pushOffset(-offset);
  }
  // 子组件的hitTest方法的返回值
  final bool isHit = hitTest(this, transformedPosition);
  if (offset != null) {
    popTransform();
  }
  return isHit;
}

通过上面源码可以发现,这里的while循环遍历中提供了一种中断机制,遍历过程中只要有子节点的hitTest()返回了true时,就会终止子节点遍历,这意味着该子节点前面的兄弟节点将没有机会通过命中测试。注意,兄弟节点的遍历倒序的。

此时,父节点也会通过命中测试。因为子节点hitTest()返回了true导父节点hitTestChildren也会返回true,最终会导致父节点的hitTest返回true,父节点被添加到HitTestResult中。

但是,当子节点的hitTest()返回了false时,继续遍历该子节点前面的兄弟节点,对它们进行命中测试,如果所有子节点都返回false时,则父节点会调用自身的hitTestSelf方法,如果该方法也返回 false,则父节点就会被认为没有通过命中测试。

到这里会有两个疑问:

1、为什么兄弟节点的遍历要倒序?

兄弟节点一般不会重叠,而一旦发生重叠的话(比如在Stack布局中,兄弟组件的布局会重叠),往往是后面的组件会在前面组件之上,点击时应该是后面的组件会响应事件,而前面被遮住的组件不能响应,所以命中测试应该优先对后面的节点进行测试,因为一旦通过测试,就不会再继续遍历了。如果我们按照正向遍历,则会出现被遮住的组件能响应事件,而位于上面的组件反而不能,这明显不符合预期。

2、为什么要制定这个while循环遍历中断呢?

为了兼容一些重叠布局,如上面说的Stack布局。如果我们想让位于底部的组件也能响应事件,就得有一种机制,能让我们确保即使找到了一个节点,也不应该终止遍历,也就是说所有的子组件的hitTest方法都必须返回falseFlutter中可以通过HitTestBehavior来实现这个过程,这个本文后面会讲。

如果hitTestSelf返回true,则无论子节点中是否有通过命中测试的节点,当前节点自身都会被添加到HitTestResult中。

3.3、事件分发

回到GestureBinding_handlePointerEventImmediately ,看下分析四,执行了dispatchEvent方法,事件分发就相对简单了,遍历HitTestResult,调用每一个节点的 handleEvent方法。

 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
41
42
43
44
@override // from HitTestDispatcher
@pragma('vm:notify-debugger-on-exception')
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
  assert(!locked);
  // No hit test information implies that this is a [PointerAddedEvent] or
  // [PointerRemovedEvent]. These events are specially routed here; other
  // events will be routed through the `handleEvent` below.
  if (hitTestResult == null) {
    assert(event is PointerAddedEvent || event is PointerRemovedEvent);
    try {
      pointerRouter.route(event);
    } catch (exception, stack) {
      FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
        exception: exception,
        stack: stack,
        library: 'gesture library',
        context: ErrorDescription('while dispatching a non-hit-tested pointer event'),
        event: event,
        informationCollector: () => <DiagnosticsNode>[
          DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty),
        ],
      ));
    }
    return;
  }
  for (final HitTestEntry entry in hitTestResult.path) {
    try {
      entry.target.handleEvent(event.transformed(entry.transform), entry);
    } catch (exception, stack) {
      FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
        exception: exception,
        stack: stack,
        library: 'gesture library',
        context: ErrorDescription('while dispatching a pointer event'),
        event: event,
        hitTestEntry: entry,
        informationCollector: () => <DiagnosticsNode>[
          DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty),
          DiagnosticsProperty<HitTestTarget>('Target', entry.target, style: DiagnosticsTreeStyle.errorProperty),
        ],
      ));
    }
  }
}

这里以Listener的渲染对象RenderPointerListener为例,看下它的handleEvent方法,可以看到,最终是根据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
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
  assert(debugHandleEvent(event, 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 PointerPanZoomStartEvent) {
    return onPointerPanZoomStart?.call(event);
  }
  if (event is PointerPanZoomUpdateEvent) {
    return onPointerPanZoomUpdate?.call(event);
  }
  if (event is PointerPanZoomEndEvent) {
    return onPointerPanZoomEnd?.call(event);
  }
  if (event is PointerSignalEvent) {
    return onPointerSignal?.call(event);
  }
}

3.4、事件清理

回到GestureBinding_handlePointerEventImmediately ,看下分析二,可以发现,如果是PointerUpEvent或者PointerCancelEvent,那么从 _hitTests 里移除该hitTestResult,同时会返回当前的hitTestResult,继续分发这个事件,但是也代表这次事件流结束了。

1
2
3
4
else if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent) {
  // 分析二
  hitTestResult = _hitTests.remove(event.pointer);
}

3.5、HitTestBehavior

之前提到Listener组件有一个behavior参数,默认值为HitTestBehavior.deferToChild。那这个参数在哪里用到呢?

我们知道,Listener组件的渲染对象RenderPointerListener继承了RenderProxyBoxWithHitTestBehavior类。

RenderProxyBoxWithHitTestBehaviorhitTest方法以及hitTestSelf方法中就有用到behavior参数,它的取值会影响Listener的命中测试结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@override
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;
}

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

来看下HitTestBehavior的取值。

HitTestBehavior取值 说明
deferToChild 仅当其子级之一被命中测试击中时,遵从其子级的目标才会在其范围内接收事件
opaque 不透明目标可以通过命中测试命中,导致它们既接收其边界内的事件,又阻止视觉上位于其后面的目标也接收事件
translucent 半透明目标既接收其边界内的事件,又允许其后方的目标也接收事件

它有三个取值,我们结合hitTest实现来分析一下不同取值的作用:

1、behaviordeferToChild时,hitTestSelf返回false,当前组件是否能通过命中测试完全取决于hitTestChildren的返回值。也就是说只要有一个子节点通过命中测试,则当前组件便会通过命中测试。

2、behavioropaque时,hitTestSelf返回truehitTarget值始终为true,当前组件通过命中测试。

3、behaviortranslucent时,hitTestSelf返回falsehitTarget值此时取决于hitTestChildren的返回值,但是无论hitTarget值是什么,当前节点都会被添加到 HitTestResult中。

注意,behavioropaquetranslucent时当前组件都会通过命中测试,它们的区别是 hitTest() 的返回值(hitTarget)可能不同,所以它们的区别就看hitTest() 的返回值会影响什么,这个前面已经讲过。

一般情况下,只有Listener的子节点hitTest返回false时两者才有区别,这种场景不好找,这里只能强行制造一个场景,看下opaquetranslucent体现的差异情况。

 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
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Stack(
        children: [
          _createWidget(1),
          _createWidget(2),
        ],
      ),
    );
  }

  Widget _createWidget(int index) {
    return Listener(
      behavior: HitTestBehavior.translucent,  // 放开此行,点击会同时输出 2 和 1
      // behavior: HitTestBehavior.opaque,    // 放开此行,点击只会输出 2
      onPointerDown: (PointerDownEvent event) => debugPrint('$index'),
      child: const SizedBox.expand(),
    );
  }
}

因为SizedBox的渲染对象为RenderConstrainedBox,而RenderConstrainedBox的间接父类是RenderBox,所以hitTesthitTestChildren以及hitTestSelf方法实现均在RenderBox中。

可以看到hitTestChildrenhitTestSelf均返回false,所以它的hitTest方法也就返回false,说明SizedBox没有命中测试。

 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
41
42
43
44
45
46
47
48
49
50
bool hitTest(BoxHitTestResult result, { required Offset position }) {
  assert(() {
    if (!hasSize) {
      if (debugNeedsLayout) {
        throw FlutterError.fromParts(<DiagnosticsNode>[
          ErrorSummary('Cannot hit test a render box that has never been laid out.'),
          describeForError('The hitTest() method was called on this RenderBox'),
          ErrorDescription(
            "Unfortunately, this object's geometry is not known at this time, "
            'probably because it has never been laid out. '
            'This means it cannot be accurately hit-tested.',
          ),
          ErrorHint(
            'If you are trying '
            'to perform a hit test during the layout phase itself, make sure '
            "you only hit test nodes that have completed layout (e.g. the node's "
            'children, after their layout() method has been called).',
          ),
        ]);
      }
      throw FlutterError.fromParts(<DiagnosticsNode>[
        ErrorSummary('Cannot hit test a render box with no size.'),
        describeForError('The hitTest() method was called on this RenderBox'),
        ErrorDescription(
          'Although this node is not marked as needing layout, '
          'its size is not set.',
        ),
        ErrorHint(
          'A RenderBox object must have an '
          'explicit size before it can be hit-tested. Make sure '
          'that the RenderBox in question sets its size during layout.',
        ),
      ]);
    }
    return true;
  }());
  if (_size!.contains(position)) {
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

@protected
bool hitTestSelf(Offset position) => false;

@protected
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) => false;

在来看下Listener的渲染对象RenderPointerListenerRenderPointerListener的父类为RenderProxyBoxWithHitTestBehavior,它重写了hitTest以及hitTestSelf方法。

因为SizedBox没有命中测试,所以hitTestChildren方法返回false,现在看hitTestSelf方法,可以发现hitTestSelf方法返回behavior == HitTestBehavior.opaque

如果behavioropaque,那么hitTestSelf返回返回truehitTest方法也就返回true,所以while循环倒叙遍历时,第二个Listener命中测试,然后中断while循环,此时点击只会输出2。

如果behaviortranslucent,那么hitTestSelf返回返回falsehitTest方法也就返回false,所以没有中断while循环,并且两个Listener均命中测试,此时点击只会输出2,1。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@override
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;
}

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

四、扩展

4.1、AbsorbPointer

假如我们不想让某个子树响应PointerEvent的话,我们可以使用AbsorbPointer,它有一个absorbing参数,默认值为true(下面示例不传入),表示是否在命中测试期间吸收指针。例如下面这个例子就只会打印“outer down”。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Listener(
      child: AbsorbPointer(
        child: Listener(
          child: const ColoredBox(color: Colors.blueAccent),
          onPointerDown: (PointerDownEvent event) => debugPrint('inner down'),
        ),
      ),
      onPointerDown: (PointerDownEvent event) => debugPrint('outer down'),
    );
  }
}

来看下AbsorbPointer的渲染对象RenderAbsorbPointer,它重写hitTest方法。可以看到,absorbing默认值为true时,走的是size.contains(position)逻辑,也就是不再递归执行子节点的命中测试,所以AbsorbPointer内部的Listener无法响应事件。

1
2
3
4
5
6
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
  return absorbing
      ? size.contains(position)
      : super.hitTest(result, position: position);
}

4.2、IgnorePointer

除了AbsorbPointer可以不让某个子树响应事件,IgnorePointer也具备该功能。它有一个ignoring参数,默认值为true(下面示例不传入),表示在命中测试期间是否忽略此Widget。例如下面这个例子就什么也不打印了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Listener(
      child: IgnorePointer(
        child: Listener(
          child: const ColoredBox(color: Colors.blueAccent),
          onPointerDown: (PointerDownEvent event) => debugPrint('inner down'),
        ),
      ),
      onPointerDown: (PointerDownEvent event) => debugPrint('outer down'),
    );
  }
}

来看下IgnorePointer的渲染对象RenderIgnorePointer,它重写hitTest方法。可以看到,ignoring默认值为true时,hitTest方法返回false,所以IgnorePointer内部的Listener无法响应事件,而且也会导致外部ListenerhitTestChildren方法返回false,导致外部Listener也不能命中测试。

1
2
3
4
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
  return !ignoring && super.hitTest(result, position: position);
}

五、总结

Flutter事件处理流程主要分3步:

1、命中测试:当手指按下时,触发PointerDownEvent事件,按照深度优先遍历当前渲染(render object)树,对每一个渲染对象进行“命中测试”(hit test),如果命中测试通过,则该渲染对象会被添加到一个HitTestResult列表当中。

2、事件分发:命中测试完毕后,会遍历HitTestResult列表,调用每一个渲染对象的事件处理方法(handleEvent)来处理PointerDownEvent事件,该过程称为“事件分发”(event dispatch)。随后当手指移动时,便会分发PointerMoveEvent事件。

3、事件清理:当手指抬(PointerUpEvent)起或事件取消时(PointerCancelEvent),会先对相应的事件进行分发,分发完毕后会清空HitTestResult列表。