Flutter入门-事件

141 阅读15分钟

原始指针事件处理

本节先来介绍一下原始指针事件(Pointer Event,在移动设备上通常为触摸事件),下一节再介绍手势处理。

命中测试简介

在移动端,各个平台或UI系统的原始指针事件模型基本都是一致,即:一次完整的事件分为三个阶段:手指按下、手指移动、和手指抬起,而更高级别的手势(如点击、双击、拖动等)都是基于这些原始事件的。

当指针按下时,Flutter会对应用程序执行命中测试(Hit Test) ,以确定指针与屏幕接触的位置存在哪些组件(widget), 指针按下事件(以及该指针的后续事件)然后被分发到由命中测试发现的最内部的组件,然后从那里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件被分发到组件树根的路径上的所有组件,这和Web开发中浏览器的事件冒泡机制相似, 但是Flutter中没有机制取消或停止“冒泡”过程,而浏览器的冒泡是可以停止的。注意,只有通过命中测试的组件才能触发事件,我们会在下一节中深入介绍命中测试过程。

注意:术语“Hit Test ”的中文翻译比较多,如 “命中测试”、“点击测试” ,对于名字我们不用较真,知道它们代表的是 “Hit Test ” 即可。

Listener 组件

Flutter中可以使用Listener来监听原始触摸事件

Listener({
  Key key,
  this.onPointerDown, //手指按下回调
  this.onPointerMove, //手指移动回调
  this.onPointerUp,//手指抬起回调
  this.onPointerCancel,//触摸事件取消回调
  this.behavior = HitTestBehavior.deferToChild, //先忽略此参数,后面小节会专门介绍
  Widget child
})

我们先看一个示例,下面代码功能是: 手指在一个容器上移动时查看手指相对于容器的位置。

class _PointerMoveIndicatorState extends State<PointerMoveIndicator> {
  PointerEvent? _event;

  @override
  Widget build(BuildContext context) {
    return Listener(
      child: Container(
        alignment: Alignment.center,
        color: Colors.blue,
        width: 300.0,
        height: 150.0,
        child: Text(
          '${_event?.localPosition ?? ''}',
          style: TextStyle(color: Colors.white),
        ),
      ),
      onPointerDown: (PointerDownEvent event) => setState(() => _event = event),
      onPointerMove: (PointerMoveEvent event) => setState(() => _event = event),
      onPointerUp: (PointerUpEvent event) => setState(() => _event = event),
    );
  }
}

运行后效果如图所示:

手指在蓝色矩形区域内移动即可看到当前指针偏移,当触发指针事件时,参数 PointerDownEvent、 PointerMoveEvent、 PointerUpEvent 都是PointerEvent的子类,PointerEvent类中包括当前指针的一些信息,注意 Pointer,即“指针”, 指事件的触发者,可以是鼠标、触摸板、手指。

如:

  • position:它是指针相对于当对于全局坐标的偏移。
  • localPosition: 它是指针相对于当对于本身布局坐标的偏移。
  • delta:两次指针移动事件(PointerMoveEvent)的距离。
  • pressure:按压力度,如果手机屏幕支持压力传感器(如iPhone的3D Touch),此属性会更有意义,如果手机不支持,则始终为1。
  • orientation:指针移动方向,是一个角度值。

上面只是PointerEvent一些常用属性,除了这些它还有很多属性,读者可以查看API文档。

还有一个behavior属性,它决定子组件如何响应命中测试,关于该属性我们将在8.3节中详细介绍。

忽略指针事件

假如我们不想让某个子树响应PointerEvent的话,我们可以使用IgnorePointerAbsorbPointer,这两个组件都能阻止子树接收指针事件,不同之处在于AbsorbPointer本身会参与命中测试,而IgnorePointer本身不会参与,这就意味着AbsorbPointer本身是可以接收指针事件的(但其子树不行),而IgnorePointer不可以。一个简单的例子如下:

Listener(
  child: AbsorbPointer(
    child: Listener(
      child: Container(
        color: Colors.red,
        width: 200.0,
        height: 100.0,
      ),
      onPointerDown: (event)=>print("in"),
    ),
  ),
  onPointerDown: (event)=>print("up"),
)

点击Container时,由于它在AbsorbPointer的子树上,所以不会响应指针事件,所以日志不会输出"in",但AbsorbPointer本身是可以接收指针事件的,所以会输出"up"。如果将AbsorbPointer换成IgnorePointer,那么两个都不会输出。

事件机制

事件处理

Flutter 事件处理流程主要分两步,为了聚焦核心流程,我们以用户触摸事件为例来说明:

  1. 命中测试:当手指按下时,触发 PointerDownEvent 事件,从根节点开始,按照深度优先遍历当前渲染(render object)树,对每一个渲染对象进行“命中测试”(hit test),如果命中测试通过,则该渲染对象会被添加到一个 HitTestResult 列表当中。
  2. 事件分发:命中测试完毕后,会遍历 HitTestResult 列表,调用每一个渲染对象的事件处理方法(handleEvent)来处理 PointerDownEvent 事件,该过程称为“事件分发”(event dispatch)。随后当手指移动时,便会分发 PointerMoveEvent 事件。
  3. 事件清理:当手指抬( PointerUpEvent )起或事件取消时(PointerCancelEvent),会先对相应的事件进行分发,分发完毕后会清空 HitTestResult 列表。

需要注意:

  • 命中测试是在 PointerDownEvent 事件触发时进行的,一个完成的事件流是 down > move > up (cancle)。
  • 如果父子组件都监听了同一个事件,则子组件会比父组件先响应事件。这是因为命中测试过程是按照深度优先规则遍历的,所以子渲染对象会比父渲染对象先加入 HitTestResult 列表,又因为在事件分发时是从前到后遍历 HitTestResult 列表的,所以子组件比父组件会更先被调用 handleEvent 。

下面我们从代码层面看一些整个事件处理流程:

// 触发新事件时,flutter 会调用此方法
void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  if (event is PointerDownEvent ) {
    hitTestResult = HitTestResult();
    // 发起命中测试
    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) { // PointerMoveEvent
    //直接获取命中测试的结果
    hitTestResult = _hitTests[event.pointer];
  }
  // 事件分发
  if (hitTestResult != null) {
    dispatchEvent(event, hitTestResult);
  }
}

上面代码只是核心代码,完整的代码位于GestureBinding 实现中。下面我们分别来介绍一些命中测试和事件分发过程。

命中测试

1. 命中测试的起点

一个对象是否可以响应事件,取决于在其对命中测试过程中是否被添加到了 HitTestResult 列表 ,如果没有被添加进去,则后续的事件分发将不会分发给自己。下面我们看一下命中测试的过程:当发生用户事件时,Flutter 会从根节点(RenderView)开始调用它hitTest() 。

@override
void hitTest(HitTestResult result, Offset position) {
  //从根节点开始进行命中测试
  renderView.hitTest(result, position: position); 
  // 会调用 GestureBinding 中的 hitTest()方法,我们将在下一节中介绍。
  super.hitTest(result, position); 
}

上面代码位于 RenderBinding 中,核心代码只有两行,整体是命中测试分两步,我们来解释一下:

第一步: renderView 是 RenderView 对应的 RenderObject 对象, RenderObject 对象的 hitTest 方法主要功能是:从该节点出发,按照深度优先的顺序递归遍历子树(渲染树)上的每一个节点并对它们进行命中测试。这个过程称为“渲染树命中测试”。

注意,为了表述方便,“渲染树命中测试”,也可以表述为组件树或节点树命中测试,只是我们需要知道,命中测试的逻辑都在 RenderObject 中,而并非在 Widget或 Element 中。

第二步:渲染树命中测试完毕后,会调用 GestureBinding 的 hitTest 方法,该方法主要用于处理手势,我们会在后面介绍。

#2. 渲染树命中测试过程

渲染树的命中测试流程就是父节点 hitTest 方法中不断调用子节点 hitTest 方法的递归过程。下面是RenderViewhitTest()源码:

// 发起命中测试,position 为事件触发的坐标(如果有的话)。
bool hitTest(HitTestResult result, { Offset position }) {
  if (child != null)
    child.hitTest(result, position: position); //递归对子树进行命中测试
  //根节点会始终被添加到HitTestResult列表中
  result.add(HitTestEntry(this)); 
  return true;
}

因为 RenderView 只有一个孩子,所以直接调用child.hitTest 即可。如果一个渲染对象有多个子节点,则命中测试逻辑为:如果任意一个子节点通过了命中测试或者当前节点“强行声明”自己通过了命中测试,则当前节点会通过命中测试。我们以RenderBox为例,看看它的hitTest()实现:

bool hitTest(HitTestResult result, { @required Offset position }) {
  ...  
  if (_size.contains(position)) { // 判断事件的触发位置是否位于组件范围内
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

上面代码中:

  • hitTestChildren() 功能是判断是否有子节点通过了命中测试,如果有,则会将子组件添加到 HitTestResult 中同时返回 true;如果没有则直接返回false。该方法中会递归调用子组件的 hitTest 方法。
  • hitTestSelf() 决定自身是否通过命中测试,如果节点需要确保自身一定能响应事件可以重写此函数并返回true ,相当于“强行声明”自己通过了命中测试。

需要注意,节点通过命中测试的标志是它被添加到 HitTestResult 列表中,而不是它 hitTest 的返回值,虽然大所数情况下节点通过命中测试就会返回 true,但是由于开发者在自定义组件时是可以重写 hitTest 的,所以有可能会在在通过命中测试时返回 false,或者未通过命中测试时返回 true,当然这样做并不好,我们在自定义组件时应该尽可能避免,但是在有些需要自定义命中测试流程的场景下可能就需要打破这种默契,比如我们将在本节后面实现的 HitTestBlocker 组件。

所以整体逻辑就是:

  1. 先判断事件的触发位置是否位于组件范围内,如果不是则不会通过命中测试,此时 hitTest 返回 false,如果是则到第二步。
  2. 会先调用 hitTestChildren() 判断是否有子节点通过命中测试,如果是,则将当前节点添加到 HitTestResult 列表,此时 hitTest 返回 true。即只要有子节点通过了命中测试,那么它的父节点(当前节点)也会通过命中测试。
  3. 如果没有子节点通过命中测试,则会取 hitTestSelf 方法的返回值,如果返回值为 true,则当前节点通过命中测试,反之则否。

如果当前节点有子节点通过了命中测试或者当前节点自己通过了命中测试,则将当前节点添加到 HitTestResult 中。又因为 hitTestChildren()中会递归调用子组件的 hitTest 方法,所以组件树的命中测试顺序深度优先的,即如果通过命中测试,子组件会比父组件会先被加入HitTestResult 中

我们看看这两个方法默认实现如下:

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

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

如果组件包含多个子组件,就必须重写 hitTestChildren() 方法,该方法中应该调用每一个子组件的 hitTest 方法,比如我们看看 RenderBoxContainerDefaultsMixin 中的实现:

// 子类的 hitTestChildren() 中会直接调用此方法
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
   // 遍历所有子组件(子节点从后向前遍历)
  ChildType? child = lastChild;
  while (child != null) {
    final ParentDataType childParentData = child.parentData! as ParentDataType;
    // isHit 为当前子节点调用hitTest() 的返回值
    final bool isHit = result.addWithPaintOffset(
      offset: childParentData.offset,
      position: position,
      //调用子组件的 hitTest方法,
      hitTest: (BoxHitTestResult result, Offset? transformed) {
        return child!.hitTest(result, position: transformed!);
      },
    );
    // 一旦有一个子节点的 hitTest() 方法返回 true,则终止遍历,直接返回true
    if (isHit) return true;
    child = childParentData.previousSibling;
  }
  return false;
}

  bool addWithPaintOffset({
    required Offset? offset,
    required Offset position,
    required BoxHitTest hitTest,
  }) {
    ...// 省略无关代码
    final bool isHit = hitTest(this, transformedPosition);
    return isHit; // 返回 hitTest 的执行结果
  }

我们可以看到上面代码的主要逻辑是遍历调用子组件的 hitTest() 方法,同时提供了一种中断机制:即遍历过程中只要有子节点的 hitTest() 返回了 true 时:

  1. 会终止子节点遍历,这意味着该子节点前面的兄弟节点将没有机会通过命中测试。注意,兄弟节点的遍历倒序的。
  2. 父节点也会通过命中测试。因为子节点 hitTest() 返回了 true 导父节点 hitTestChildren 也会返回 true,最终会导致 父节点的 hitTest 返回 true,父节点被添加到 HitTestResult 中。

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

下面思考两个问题:

  1. 为什么要制定这个中断呢?因为一般情况下兄弟节点占用的布局空间是不重合的,因此当用户点击的坐标位置只会有一个节点,所以一旦找到它后(通过了命中测试,hitTest 返回true),就没有必要再判断其他兄弟节点了。但是也有例外情况,比如在 Stack 布局中,兄弟组件的布局空间会重叠,如果我们想让位于底部的组件也能响应事件,就得有一种机制,能让我们确保:即使找到了一个节点,也不应该终止遍历,也就是说所有的子组件的 hitTest 方法都必须返回 false!为此,Flutter 中通过 HitTestBehavior 来定制这个过程,这个我们会在本节后面介绍。
  2. 为什么兄弟节点的遍历要倒序?同 1 中所述,兄弟节点一般不会重叠,而一旦发生重叠的话,往往是后面的组件会在前面组件之上,点击时应该是后面的组件会响应事件,而前面被遮住的组件不能响应,所以命中测试应该优先对后面的节点进行测试,因为一旦通过测试,就不会再继续遍历了。如果我们按照正向遍历,则会出现被遮住的组件能响应事件,而位于上面的组件反而不能,这明显不符合预期。

我们回到 hitTestChildren 上,如果不重写 hitTestChildren,则默认直接返回 false,这也就意味着后代节点将无法参与命中测试,相当于事件被拦截了,这也正是 IgnorePointer 和 AbsorbPointer 可以拦截事件下发的原理。

如果 hitTestSelf 返回 true,则无论子节点中是否有通过命中测试的节点,当前节点自身都会被添加到 HitTestResult 中。而 IgnorePointer 和 AbsorbPointer 的区别就是,前者的 hitTestSelf 返回了 false,而后者返回了 true。

命中测试完成后,所有通过命中测试的节点都被添加到了 HitTestResult 中

HitTestBehavior

behavior表示命中测试(Hit Test)过程中的表现策略。它是一个枚举,提供了三个值,分别是HitTestBehavior.deferToChildHitTestBehavior.opaqueHitTestBehavior.translucent HitTestBehavior.deferToChildListener是否命中测试,取决于子child是否命中测试,这是默认behavior的默认值。

HitTestBehavior.opaque:当Listener的子child没有命中测试时,该属性值保证hitTestSelf返回true,即保证Listener所在区域能响应触摸事件。

HitTestBehavior.translucent:当Listener的子child没有命中测试时,并且hitTestSelf返回false时,该属性值可以保证Listener所在的区域能响应触摸事件(加入到命中测试列表),但是hitTest方法返回值还是false,这不能改变。这样,其自身能够响应事件的同时,可以保证该事件继续冒泡。

为了更深入的理解behavior属性,我们再来看另外一个例子。

Stack(
  children: <Widget>[
    Listener(
      child: ConstrainedBox(
        constraints: BoxConstraints.tight(Size(400, 200)),
        child: Container(
          color: Colors.blue,
        )
      ),
      onPointerDown: (event) => print("onPointerDown1"),
    ),
    Listener(
      child: ConstrainedBox(
        constraints: BoxConstraints.tight(Size(400, 200)),
        child: Center(child: Text("dont click me")),
      ),
      onPointerDown: (event) => print("onPointerDown2"),
//    behavior: HitTestBehavior.opaque, //注释1
//    behavior: HitTestBehavior.translucent,  //注释2
    )
  ],
),

它的展示效果如上图所示。

上图为WidgetRenderObject的对应关系。

1、behavior为默认HitTestBehavior.deferToChild属性时,当点击了Text以外的区域,它的命中测试列表是这样的: RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack

RenderStackhitTestChildren会先找Stack中最上层的child,看它是否命中测试。很显然,第一个child,即第二个Listener没有命中测试。

然后它再去找第二个child,即第一个Listener是否命中测试。这里的第一个Listener包含的Container设置了color属性,所以Container这里对应的是RenderDecoratedBox,它通过了命中测试,相应的Listener也通过了命中测试。

所以控制台会只打印onPointerDown1

2、将注释2关闭,注释1打开,behaviorHitTestBehavior.opaque属性时,当点击了Text以外的区域,它的命中测试列表是这样的: RenderPointerListener->RenderStack

RenderStackhitTestChildren会先找Stack中最上层的child,看它是否命中测试。第一个child,即第二个Listener加上了HitTestBehavior.opaque属性后,通过了命中测试。

这个时候RenderStackhitTestChildren直接返回了true,它并不会再去检测第二个child,即第一个Listener是否命中测试。

所以控制台只会打印onPointerDown2

3、将注释1关闭,注释2打开,behaviorHitTestBehavior.translucent属性时,当点击了Text以外的区域,它的命中测试列表是这样的: RenderPointerListener->RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack

RenderStackhitTestChildren会先找Stack中最上层的child,看它是否命中测试。第一个child,即第二个Listener加上了HitTestBehavior.translucent属性后,通过了命中测试,加入命中测试列表。但必须注意的是,虽然通过了命中测试,但是该RenderPointerListener的hitTest方法返回false

然后RenderStack会再去找第二个child,即第一个Listener是否命中测试。由上面的分析可知,它是通过了命中测试的。因此整个命中测试列表就是: RenderPointerListener->RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack

所以控制台会先打印onPointerDown2,然后再打印onPointerDown1