Listener

作者: 邹邹_ZZ | 来源:发表于2021-08-24 17:42 被阅读0次

    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

    相关文章

      网友评论

          本文标题:Listener

          本文链接:https://www.haomeiwen.com/subject/yvhdiltx.html