Flutter手势

 

研究下Flutter的手势和滑动

背景

由于想实现一个效果:2层布局结构,下面一层也可以响应,所以研究了下flutter手势问题。

原理

gestures

GestureDetector内部是使用一个或多个GestureRecognizer来识别各种手势的,而GestureRecognizer的作用就是通过Listener来将原始指针事件转换为语义手势,GestureDetector直接可以接收一个子widget。GestureRecognizer是一个抽象类,一种手势的识别器对应一个GestureRecognizer的子类,Flutter实现了丰富的手势识别器,我们可以直接使用。

比如textspan,不是一个widget,所以没法用GestureDetector,只能用GestureRecognizer。 大家去看源码可以发现GestureDetector内部会维护一个 Map<Type, GestureRecognizerFactory> gestures 然后再各个recongnizer赋值之后,转交RawGestureDetector去实现。而RawGestureDetector其实就是个StatefulWidget,内部用Listener实现诸如 void _handlePointerDown(PointerDownEvent event) 的方法。当然其中代码肯定不是这么简单两句就能说清的,不过好在不是很深,大家可以自行阅读。

class Listener extends SingleChildRenderObjectWidget {
  /// Creates a widget that forwards point events to callbacks.
  ///
  /// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
  const Listener({
    Key? key,
    this.onPointerDown,
    this.onPointerMove,
    this.onPointerUp,
    this.onPointerHover,
    this.onPointerCancel,
    this.onPointerSignal,
    this.behavior = HitTestBehavior.deferToChild,
    Widget? child,
  }) : assert(behavior != null),
       super(key: key, child: child);

    ///...
}

Listener就是我们上节见过的可以通过SingleChildRenderObjectWidget自定义Widget的一种具体方式。

事件机制

用户触摸事件为以下步骤:

  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 。

完整的代码位于GestureBinding 实现中,整个事件处理流程大致为:

flutter_gesture1

// 触发新事件时,flutter 会调用此方法
void _handlePointerEventImmediately(PointerEvent event) {
    HitTestResult? hitTestResult;

    if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
        // 发起命中测试
      assert(!_hitTests.containsKey(event.pointer));
      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) {
      // 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.
      //直接获取命中测试的结果 PointerMoveEvent
      hitTestResult = _hitTests[event.pointer];
    }
    //...
    if (hitTestResult != null ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position != null);
      dispatchEvent(event, hitTestResult);
    }
}

Flutter 中的事件是从 Window.onPointerDataPacket 的回调中获取的,将原始事件转化成 PointerEvent 加入到待处理的事件队列中,然后逐个处理队列中的 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.
    _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
    if (!locked)
        _flushPointerEventQueue();
}

//...

void _flushPointerEventQueue() {
    assert(!locked);

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

其中 _handlePointerEvent 将生成 HitTestResult 将所有的命中测试结果存在 _path (HitTestResult 中的一个命中测试对象的集合),最后遍历 HitTestResult 的 _path 进行事件分发。

hitTest

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

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

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

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

当发生用户事件时,Flutter 会从根节点(RenderView)开始调用它hitTest()

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

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

命中测试的逻辑都在 RenderObject 中,而并非在 Widget或 Element 中。

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

bool hitTest(HitTestResult result, { required Offset position }) {
    if (child != null)
      child!.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
}

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

bool hitTest(BoxHitTestResult result, { required Offset position }) {
    ///...
    if (_size!.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
}

其中调用者需要将全局的position做个坐标系转换以适应当前RenderBox的坐标。

上面代码中:

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 中。

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;
      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;
  }

上面代码的主要逻辑是遍历调用子组件的 hitTest() 方法,同时提供了一种中断机制:即遍历过程中只要有子节点的 hitTest() 返回了 true 时: 会终止子节点遍历,这意味着该子节点前面的兄弟节点将没有机会通过命中测试。注意,兄弟节点的遍历倒序的。 父节点也会通过命中测试。因为子节点 hitTest() 返回了 true 导父节点 hitTestChildren 也会返回 true,最终会导致 父节点的 hitTest 返回 true,父节点被添加到 HitTestResult 中。 当子节点的 hitTest() 返回了 false 时,继续遍历该子节点前面的兄弟节点,对它们进行命中测试,如果所有子节点都返回 false 时,则父节点会调用自身的 hitTestSelf 方法,如果该方法也返回 false,则父节点就会被认为没有通过命中测试。

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

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

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

按照正常流程,我们得实现一个Element和一个Widget,然后在Widget中创建Element,在Element中创建和更新RenderObject,另外还得管理一大堆状态,处理非常繁琐。所幸flutter为我们封装了这一套逻辑,即RenderObjectWidget。另外前面一篇文章也说过了,flutter还分别实现了几个子类,进一步封装了RenderObjectWidget,它们分别是LeafRenderObjectWidget、SingleChildRenderObjectWidget、MultiChildRenderObjectWidget。其中,LeafRenderObjectWidget是叶节点,不含子Widget;SingleChildRenderObjectWidget仅有一个child;而MultiChildRenderObjectWidget则是含有children列表。这几个子类根据child的情况分别创建了对应的Element,所以通过这几个子类,我们只需要关注RenderObject的创建和更新。 而RenderObject渲染时需要用的RenderBox。

事件分发

从上面的代码可以看见,组件只需要出来handleEvent

  @override // from HitTestDispatcher
  void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
       ///如果没有碰撞结果,那么通过 `pointerRouter.route` 将事件分发到全局处理。
    if (hitTestResult == null) {
      try {
        pointerRouter.route(event);
      } catch (exception, stack) {
      return;
    }
    ///上面我们知道 HitTestEntry 中的 target 是一系自下而上的控件
    ///还有 renderView 和 GestureBinding
    ///循环执行每一个的 handleEvent 方法
    for (HitTestEntry entry in hitTestResult.path) {
      try {
        entry.target.handleEvent(event, entry);
      } catch (exception, stack) {
      }
    }
  }

也不是所有的控件的 RenderObject 子类都会处理 handleEvent ,大部分时候,只有带有 RenderPointerListener (RenderObject) / Listener (Widget) 的才会处理 handleEvent 事件,并且从上述源码可以看出,handleEvent 的执行是不会被拦截打断的。

手势冲突

RawGestureDetector 中会通过 Listener 组件监听 PointerDownEvent 事件,相关源码如下:

@override
Widget build(BuildContext context) {
  ... // 省略无关代码
  Widget result = Listener(
    onPointerDown: _handlePointerDown,
    behavior: widget.behavior ?? _defaultBehavior,
    child: widget.child,
  );
}  
 
void _handlePointerDown(PointerDownEvent event) {
  for (final GestureRecognizer recognizer in _recognizers!.values)
    recognizer.addPointer(event);
}  

TapGestureRecognizer 为例

class CustomTapGestureRecognizer1 extends TapGestureRecognizer {

  void addPointer(PointerDownEvent event) {
    //会将 handleEvent 回调添加到 pointerRouter 中
    GestureBinding.instance!.pointerRouter.addRoute(event.pointer, handleEvent);
  }
  
  @override
  void handleEvent(PointerEvent event) {
    //会进行手势识别,并决定是是调用 acceptGesture 还是 rejectGesture,
  }
  
  @override
  void acceptGesture(int pointer) {
    // 竞争胜出会调用
  }

  @override
  void rejectGesture(int pointer) {
    // 竞争失败会调用
  }
}

当 PointerDownEvent 事件触发时,会调用 TapGestureRecognizer 的 addPointer,在 addPointer 中会将 handleEvent 方法添加到 pointerRouter 中保存起来。这样一来当手势发生变化时只需要在 pointerRouter中取出 GestureRecognizer 的 handleEvent 方法进行手势识别即可。

正常情况下应该是手势直接作用的对象应该来处理手势,所以一个简单的原则就是同一个手势应该只有一个手势识别器生效,为此,手势识别才映入了手势竞技场(Arena)的概念,简单来讲:

  1. 每一个手势识别器(GestureRecognizer)都是一个“竞争者”(GestureArenaMember),当发生指针事件时,他们都要在“竞技场”去竞争本次事件的处理权,默认情况最终只有一个“竞争者”会胜出(win)。
  2. GestureRecognizer 的 handleEvent 中会识别手势,如果手势发生了某个手势,竞争者可以宣布自己是否胜出,一旦有一个竞争者胜出,竞技场管理者(GestureArenaManager)就会通知其它竞争者失败。
  3. 胜出者的 acceptGesture 会被调用,其余的 rejectGesture 将会被调用。

大致流程:RendererBinding(hittest)->GestureBinding(hittest) GestureBinding 也通过命中测试了,这样的话在事件分发阶段,GestureBinding 的 handleEvent 也便会被调用,由于它是最后被添加到 HitTestResult 中的,所以在事件分发阶段 GestureBinding 的 handleEvent:

@override 
void handleEvent(PointerEvent event, HitTestEntry entry) {
  // 会调用在 pointerRouter 中添加的 GestureRecognizer 的 handleEvent
  pointerRouter.route(event);
  if (event is PointerDownEvent) {
    // 分发完毕后,关闭竞技场
    gestureArena.close(event.pointer);
  } else if (event is PointerUpEvent) {
    gestureArena.sweep(event.pointer);
  } else if (event is PointerSignalEvent) {
    pointerSignalResolver.resolve(event);
  }
}

最关键的代码就是第一行,功能是会调用之前在 pointerRouter 中添加的 GestureRecognizer 的 handleEvent,不同 GestureRecognizer 的 handleEvent 会识别不同的手势,然后它会和 gestureArena 交互(如果当前的 GestureRecognizer 胜出,需要 gestureArena 去通知其它竞争者它们失败了),最终,如果当前GestureRecognizer 胜出,则最终它的 acceptGesture 会被调用,如果失败则其 rejectGesture 将会被调用,因为这部分代码不同的 GestureRecognizer 会不同,知道做了什么就行,读者有兴趣可以自行查看源码。

比如:

GestureDetector( //GestureDetector2
  onTapUp: (x)=>print("2"), // 监听父组件 tapUp 手势
  child: Container(
    width:200,
    height: 200,
    color: Colors.red,
    alignment: Alignment.center,
    child: GestureDetector( //GestureDetector1
      onTapUp: (x)=>print("1"), // 监听子组件 tapUp 手势
      child: Container(
        width: 50,
        height: 50,
        color: Colors.grey,
      ),
    ),
  ),
);

当我们点击子组件时,控制台只会打印 “1”, 并不会打印 “2”,这是因为手指抬起后,GestureDetector1 和 GestureDetector 2 会发生竞争,判定获胜的规则是“子组件优先”,所以 GestureDetector1 获胜,因为只能有一个“竞争者”胜出,所以 GestureDetector 2 将被忽略。

手势是对原始指针的语义化的识别,手势冲突只是手势级别的,也就是说只会在组件树中的多个 GestureDetector 之间才有冲突的场景,如果压根就没有使用 GestureDetector 则不存在所谓的冲突,因为每一个节点都能收到事件,只是在 GestureDetector 中为了识别语义,它会去决定哪些子节点应该忽略事件,哪些节点应该生效。

解决手势冲突的方法有两种:

  • 使用 Listener。这相当于跳出了手势识别那套规则。
  • 自定义手势手势识别器( Recognizer)自定义手势识别器的方式比较麻烦,原理时当确定手势竞争胜出者时,会调用胜出者的acceptGesture 方法,表示“宣布成功”,其回调就会被执行了,表示“宣布失败”。