Flutter源码系列:HitTestBehavior实现分析

问题

在阅读Flutter in action第8.1节时,有个疑问,选择translucent的属性为什么不能让文本区域点击也穿透到下一层元素呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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, //为什么要点击非空白区域才能穿透到下一层元素呢?
    )
  ],
)

前置概念:事件的分发

首先Flutter以树的形式组织渲染元素


框架的渲染树

点击事件从根节点RenderView开始命中测试。紧接着调用子节点的hitTest方法进行测试

1
2
3
4
5
6
7
8
//view.dart中的hitTest方法
  bool hitTest(HitTestResult result, { Offset position }) {
    if (child != null)
    // 检测起点
      child.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }

上面的检测起点的child的hitTest实现如下,可以看到调用了hitTestChildren和hitTestSelf方法,分别检测自身是否响应点击事件,和检测子节点是否通过碰撞测试。child的child元素又是同样的实现方法,碰撞测试就这么递归地进行下去,直到进行到渲染树的叶子节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//box.dart文件中的RenderBox类中的hitTest
  bool hitTest(BoxHitTestResult result, { @required Offset position }) {
    assert(() {
     ···
    if (_size.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
    //如果自身通过测试或者child有通过测试的元素,就把自身加入碰撞成功队列
  //child元素的hitTest方法同样也是这样检测自身和检测自己的child是否有通过测试的元素来向父元素反馈结果
        result.add(BoxHitTestEntry(this, position));  
        return true;
      }
    }
    return false;
  }

最后所有的碰撞测试完成后,我们得到一个包含所有通过碰撞测试的元素的列表。并且我们对列表每个元素都调用handleEvent来传递PointerDownEvent事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//gesture/binding.dart中的GestureBind类中的dispatchEvent方法
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
    assert(!locked);
    ···
    for (HitTestEntry entry in hitTestResult.path) {
      try {
        //对于通过碰撞测试的每一个元素都分发事件
entry.target.handleEvent(event.transformed(entry.transform), entry);
      } catch (exception, stack) {
        FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
         ... //错误处理过程
        ));
      }
    }
  }

HitTestBehavior行为分析

所以一个元素是否获得点击事件的关键在于hitTestSelf和hitTestChildren这两个方法的实现。我们来看看hitTestChildren的默认实现源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//box.dart文件中的RenderBoxContainerDefaultsMixin类的defaultHitTestChildren方法
 bool defaultHitTestChildren(BoxHitTestResult result, { Offset position }) {
    ChildType child = lastChild;  //从最顶层的child开始搜索
    while (child != null) {
      final ParentDataType childParentData = child.parentData;
      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; //所有的child都不通过,所以HitTestChildren会返回false
  }

所以理论上一个元素即使有多个child元素重叠在一起,也只能有一个元素通过测试

这时候我们突然间又想起HitTestBehavior.translucent这个属性,这个属性是如何让多个重叠在一起的子元素都能接收到点击事件的呢?我们来看Listener的hitTest实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//proxy_box.dart文件中的RenderProxyBoxWithHitTestBehavior类中的hitTest方法
  @override
  bool hitTest(BoxHitTestResult result, { 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.opaque 和HitTestBehavior.translucent相同点在于都可以扩大点击范围,让自身整个区域都响应点击事件
  • HitTestBehavior.opaque 和HitTestBehavior.translucent不同点在于opaque会阻挡下一层元素获得事件,而translucent不会
    因为opaque会修改hitTestSelf的返回值,让自己通过测试进而让父类结束对其它子类的碰撞测试
  • HitTestBehavior.translucent的穿透是有条件的,只能在"空白区域"穿透。这里的空白区域是指点击的区域没有child可以通过碰撞测试

参考文章:

1.Flutter事件分发