Listener

Listener

背景:

  • 可能会遇到的问题:如下代码,点击文字以外的区域是无响应的
GestureDetector(
      child: Container(
        height: 50,
        // color: Colors.green,
        padding: EdgeInsets.only(left: 5, right: 5),
        alignment: Alignment.center,
        child: Text(
          "click me",
          style: TextStyle(fontSize: 20),
        )
      ),
      onTap: () {
        print("click");
      },
    )

原因分析:

GestureDetector -> RawGestureDetector -> Listener

Listener是一个监听指针事件的控件,比如按下、移动、释放、取消等指针事件。

通常情况下,监听手势事件使用GestureDetector,GestureDetector是更高级的手势事件。

Listener的事件介绍如下:
onPointerDown:按下时回调
onPointerMove:移动时回调
onPointerUp:抬起时回调

  • 用法如下:
Listener(
  onPointerDown: (PointerDownEvent pointerDownEvent) {
    print('$pointerDownEvent');
  },
  onPointerMove: (PointerMoveEvent pointerMoveEvent) {
    print('$pointerMoveEvent');
  },
  onPointerUp: (PointerUpEvent upEvent) {
    print('$upEvent');
  },
  child: Container(
    height: 200,
    width: 200,
    color: Colors.blue,
    alignment: Alignment.center,
  ),
)
  • 当手指按下时,Flutter会对应用程序执行命中测试(Hit Test),以确定指针与屏幕接触的位置存在哪些组件(widget), 指针按下事件(以及该指针的后续事件)然后被分发到由命中测试发现的最内部的组件,然后从那里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件被分发到组件树根的路径上的所有组件,这和Web开发中浏览器的事件冒泡机制相似, 但是Flutter中没有机制取消或停止“冒泡”过程,而浏览器的冒泡是可以停止的。注意,只有通过命中测试的组件才能触发事件。

什么是命中测试?

当手指按下、移动或者抬起时,Flutter会给每一个事件新建一个对象,如按下是PointerDownEvent,移动是PointerMoveEvent,抬起是PointerUpEvent。对于每一个事件对象,Flutter都会执行命中测试,它经历了以下这几步:

1、从最底层的Widget开始执行命中测试,是否命中取决于hitTestChildren方法(它的children Widget是否命中测试)或hitTestSelf方法是否返回true, 如果返回true,表示命中测试通过,会把自己以HitTestEntry添加到HitTestResult对象中。
2、循环最底层Widget的children Widget,分别执行child Widget的命中测试。child Widget是否命中也取决于hitTestChidren方法(它的children Widget是否命中测试)或hitTestSelf方法是否返回true。
3、从下往上递归地执行命中测试,直到找到最上层的一个命中测试的Widget,将它加入命中测试列表。由于它已命中测试,那么它的父Widget也命中了测试,将父Widget也加入命中测试列表。以此类推,直到将所有命中测试的Widget加入命中测试列表。

原则:优先判断children,再判断自己,只要有一个为true,就把自己加入到result中


bool hitTest(BoxHitTestResult result, { @required Offset position }) {
    // 事件的position必须在当前组件内
  if (_size.contains(position)) {
      // 优先判断children,再判断自己,只要有一个为true,就把自己加入到result中
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

测试列表链:

  • 在Flutter中,每一个Widget实际上会对应一个RenderObject。对于上面代码来说,上图为Widget和RenderObject的对应关系。

例:

Listener(
    child: ConstrainedBox(
      constraints: BoxConstraints.tight(Size(200, 200)),
      child: Center(
        child: Text('click me'),
      )
    ),
    onPointerDown: (event) => print("onPointerDown")
)
image.png

1、当点击了Text时,它的命中测试列表是这样的:
RenderParagraph->RenderPositionedBox->RenderConstrainedBox->RenderPointerListener
所以RenderPointerListener的handleEvent方法会被执行,最终在控制台会打印onPointerDown。

2、当点击了Text以外的区域时,它的命中测试列表就没有RenderPointerListener了。为什么呢???
Text以外的区域是ConstrainedBox的(为什么不是Center,因为Center的功能是帮助Text定位,它的区域和Text是一致的)。那ConstrainedBox对应的RenderConstrainedBox命中测试了么?很显然是没有的。
因为ConstrainedBox只有一个child,就是Center。Center对应的RenderPositionedBox没有命中测试,导致RenderConstrainedBox的hitTestChildren返回false,而它的hitTestSelf也返回false,所以RenderConstrainedBox没有命中测试。
而Listener也只有一个child,那就是ConstrainedBox,既然RenderConstrainedBox没有命中测试,那么RenderPointerListener相应的就没有命中测试,所以命中测试列表中是没有RenderPointerListener的。
所以控制台并不会打印onPointerDown。

上面的例子使用的behavior属性是默认的HitTestBehavior.deferToChild,如果修改一下behavior属性会有什么奇妙的效果呢?

一、behavior:

behavior表示命中测试(Hit Test)过程中的表现策略。它是一个枚举,提供了三个值,
分别是HitTestBehavior.deferToChild、HitTestBehavior.opaque、HitTestBehavior.translucent

/// How to behave during hit tests.
enum HitTestBehavior {
  ///事件是否处理取决于自己的子类
  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.
  /// 自己可以命中hitTest,又在视觉上阻止位于其后方的目标也接收事件。
  opaque,

  /// Translucent targets both receive events within their bounds and permit
  /// targets visually behind them to also receive events.
  ///半透明目标既可以接收其范围内的事件,也可以在视觉上允许目标后面的目标也接收事件。
  translucent,
}

  • 源码引用顺序 Listener->_PointerListener->RenderPointerListener->RenderProxyBoxWithHitTestBehavior

源码分析:

RenderProxyBoxWithHitTestBehavior源码,代码很少,但逻辑就是在这里了
/// A RenderProxyBox subclass that allows you to customize the
/// hit-testing behavior.
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {

  @override
  bool hitTest(BoxHitTestResult result, { Offset position }) {
    bool hitTarget = false;
    // position在自己范围内
    if (size.contains(position)) {
      // 判断子类和自己是否命中,这是普通逻辑,没什么特别的
      hitTarget =
      hitTestChildren(result, position: position) || hitTestSelf(position);
      // 如果是HitTestBehavior.translucent,强行将自己命中hittest,参与事件消费的队列中,这里hitTestChildren和hitTestSelf的结果就不重要了
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

 //如果Behavior是opaque,且没有被子类重写,那就是返回true,也即是参与到事件消费的队列中
  @override
  bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
}

所以,当我们用GestureDetector监听事件时,最后都会走到RenderProxyBoxWithHitTestBehavior里,只要behavio是opaque或translucent都会将自己加入到事件消费的队列, 而behavior默认是HitTestBehavior.deferToChild,当点击空白处时,
hitTestChildren(result, position: position)返回false
hitTestSelf(hitTestSelf) 也返回false

二、背景色:

接下来看第二个问题,为什么Container设置任意背景色也可以响应点击事件?

Container其实是个StateLessWidget,它本身并没有RenderObject对应,可以理解为是个配置项,真实渲染的render是其他配置引进的,比如color对应的ColoredBox
ColoredBox->_RenderColoredBox

// 一眼就看到了,强制设置为opaque了,答案和第一个问题一样了
class _RenderColoredBox extends RenderProxyBoxWithHitTestBehavior {
  _RenderColoredBox({@required Color color})
    : _color = color,
     //看这里!!!!!!!!!!!!!!!!!!!!!
      super(behavior: HitTestBehavior.opaque);
    
}

所以,Container设置任意背景色,可以响应点击事件,因为设置了color后,返回的widget里包含了ColoredBox,ColoredBox对应的RenderObject是_RenderColoredBox,_RenderColoredBox继承自RenderProxyBoxWithHitTestBehavior并强制指定了behavior是HitTestBehavior.opaque。

下面例子进一步印证:

return Stack(
      children: <Widget>[
        Listener(
          child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(300.0, 300.0)),
            child: DecoratedBox(decoration: BoxDecoration(color: Colors.red)),
          ),
          onPointerDown: (event) => print("first child"),
        ),
        Listener(
          child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(200.0, 200.0)),
            child: Center(child: Text("左上角200*200范围内-空白区域点击")),
          ),
          onPointerDown: (event) => print("second child"),
          //放开此行注释后,单词点击 first ,second都会响应,HitTestBehavior.opaque是不行的
          // behavior: HitTestBehavior.translucent,
        )
      ],
    );

当点击左上角,非文本区域时,只会响应first child,当把这句代码注释打开

// behavior: HitTestBehavior.translucent,

会先输出second child,再输出first child。原因还是在RenderProxyBoxWithHitTestBehavior的hitTest方法

@override
  bool hitTest(BoxHitTestResult result, { Offset position }) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
      //如果是translucent,尽快自身加入了命中测试队列,但返回的结果还是false,
      //但如果是opaque,子类不重写hitTestSelf,那hitTarget肯定就是true了
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

对于Stack来说,对应的Render是RenderStack

RenderStack hitTestChildren
 @override
  bool hitTestChildren(BoxHitTestResult result, { Offset position }) {
    return defaultHitTestChildren(result, position: position);
  }


最终走到了RenderBoxContainerDefaultsMixin的defaultHitTestChildren

  bool defaultHitTestChildren(BoxHitTestResult result, { Offset position }) {
    // The x, y parameters have the top left of the node's box as the origin.
    // 从最上层的子类开始遍历
    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);
        },
      );
      // 第一个命中,就直接返回了,后续的子类不再执行命中测试,所以translucent能透传,因为被它修饰的Listener,返回的结果是false
      if (isHit)
        return true;
      child = childParentData.previousSibling;
    }
    return false;
  }

结论:

所以想要解决开头抛出的问题,方法如下:
1、GestureDetector的behavior设置为opaque或者translucent才行,
2、Container设置任意背景色

总结:

opaque和translucent的区别:
RenderStack的hitTestChildren返回了true,它就不会再去检测第二个child。
opaque: 第一个Listener是否命中测试” ,即意味着如果第一个child的hitTest返回true(例如opaque)的话Stack就不会再把指针事件传给第二个child,即不能透传
translucent: 如果第一个child的hitTest返回false(例如translucent)则点击事件会被传递到第二个child,即能透传

截屏2021-08-25 下午1.56.36.png

Stack

参考:
https://juejin.cn/post/6844904079106277383
https://juejin.cn/post/6908365134365491208

推荐阅读更多精彩内容