Flutter完整开发实战详解(十3、全面深刻触摸和滑动原理)

本篇将带你深刻了解 Flutter 中的手势事件传递、事件分发、事件冲突竞争,滑动流畅等等的原理,帮你构建一个完整的 Flutter 闭环手势知识体系,这也许是目前最全面的手势事件和滑动源码的深刻文章了。git

文章汇总地址:

Flutter 完整实战实战系列文章专栏github

Flutter 番外的世界系列文章专栏bash

Flutter 中默认状况下,以 Android 为例,全部的事件都是起原生源于 io.flutter.view.FlutterView 这个 SurfaceView 的子类,整个触摸手势事件实质上经历了 JAVA => C++ => Dart 的一个流程,整个流程以下图所示,不管是 Android 仍是 IOS ,原生层都只是将全部事件打包下发,好比在 Android 中,手势信息被打包成 ByteBuffer 进行传递,最后在 Dart 层的 _dispatchPointerDataPacket 方法中,经过 _unpackPointerDataPacket 方法解析成可用的 PointerDataPacket 对象使用。markdown

那么具体在 Flutter 中是如何分发使用手势事件的呢?app

一、事件流程

在前面的流程图中咱们知道,在 Dart 层中手势事件都是从 _dispatchPointerDataPacket 开始的,以后会经过 Zone 判断环境回调,会执行 GestureBinding 这个胶水类中的 _handlePointerEvent 方法。(若是对 Zone 或者 GestureBinding 有疑问能够翻阅前面的篇章)ide

以下代码所示, GestureBinding_handlePointerEvent 方法中主要是 hitTestdispatchEvent经过 hitTest 碰撞,获得一个包含控件的待处理成员列表 HitTestResult,而后经过 dispatchEvent 分发事件并产生竞争,获得胜利者相应。oop

void _handlePointerEvent(PointerEvent event) {
    assert(!locked);
    HitTestResult hitTestResult;
    if (event is PointerDownEvent || event is PointerSignalEvent) {
      hitTestResult = HitTestResult();
      ///开始碰撞测试了,会添加各个控件,获得一个须要处理的控件成员列表
      hitTest(hitTestResult, event.position);
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;
      }
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      ///复用机制,抬起和取消,不用hitTest,移除
      hitTestResult = _hitTests.remove(event.pointer);
    } else if (event.down) {
      ///复用机制,手指处于滑动中,不用hitTest
      hitTestResult = _hitTests[event.pointer];
    }
    if (hitTestResult != null ||
        event is PointerHoverEvent ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      ///开始分发事件
      dispatchEvent(event, hitTestResult);
    }
  }
复制代码

了解告终果后,接下来深刻分析这两个关键方法:学习

1.1 、hitTest

hitTest 方法主要为了获得一个 HitTestResult ,这个 HitTestResult 内有一个 List<HitTestEntry> 是用于分发和竞争事件的,而每一个 HitTestEntry.target 都会存储每一个控件的 RenderObject测试

由于 RenderObject 默认都实现了 HitTestTarget 接口,因此能够理解为: HitTestTarget 大部分时候都是 RenderObject ,而 HitTestResult 就是一个带着碰撞测试后的控件列表。ui

事实上 hitTestHitTestable 抽象类的方法,而 Flutter 中全部实现 HitTestable 的类有 GestureBindingRendererBinding ,它们都是 mixinsWidgetsFlutterBinding 这个入口类上,而且由于它们的 mixins 顺序的关系,因此 RendererBindinghitTest 会先被调用,以后才调用 GestureBindinghitTest

那么这两个 hitTest 又分别干了什么事呢?

1.二、RendererBinding.hitTest

RendererBinding.hitTest 中会执行 renderView.hitTest(result, position: position); ,以下代码所示,renderView.hitTest 方法内会执行 child.hitTest ,它将尝试将符合条件的 child 控件添加到 HitTestResult 里,最后把本身添加进去。

///RendererBinding

bool hitTest(HitTestResult result, { Offset position }) {
    if (child != null)
      child.hitTest(result, position: position);
    result.add(HitTestEntry(this));
    return true;
  }
复制代码

而查看 child.hitTest 方法源码,以下所示,RenderObjcet 中的hitTest ,会经过 _size.contains 判断本身是否属于响应区域,确认响应后执行 hitTestChildrenhitTestSelf ,尝试添加下级的 child 和本身添加进去,这样的递归就让咱们自下而上的获得了一个 HitTestResult 的相应控件列表了,最底下的 Child 在最上面

///RenderObjcet
  
  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;
  }
复制代码

1.三、GestureBinding.hitTest

最后 GestureBinding.hitTest 方法不过最后把 GestureBinding 本身也添加到 HitTestResult 里,最后由于后面咱们的流程还会须要回到 GestureBinding 中去处理。

1.四、dispatchEvent

dispatchEvent 中主要是对事件进行分发,而且经过上述添加进去的 target.handleEvent 处理事件,以下代码所示,在存在碰撞结果的时候,是会经过循环对每一个控件内部的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 的执行是不会被拦截打断的。

那么问题来了,若是同一个区域内有多个控件都实现了 handleEvent 时,那最后事件应该交给谁消耗呢?

更具体为一个场景问题就是:好比一个列表页面内,存在上下滑动和 Item 点击时,Flutter 要怎么分配手势事件? 这就涉及到事件的竞争了。

核心要来了,高能预警!!!

二、事件竞争

Flutter 在设计事件竞争的时候,定义了一个颇有趣的概念:经过一个竞技场,各个控件参与竞争,直接胜利的或者活到最后的第一位,你就获胜获得了胜利。 那么为了分析接下来的“战争”,咱们须要先看几个概念:

  • GestureRecognizer :手势识别器基类,基本上 RenderPointerListener 中须要处理的手势事件,都会分发到它对应的 GestureRecognizer,并通过它处理和竞技后再分发出去,常见有 :OneSequenceGestureRecognizerMultiTapGestureRecognizerVerticalDragGestureRecognizerTapGestureRecognizer 等等。

  • GestureArenaManagerr :手势竞技管理,它管理了整个“战争”的过程,原则上竞技胜出的条件是 :第一个竞技获胜的成员或最后一个不被拒绝的成员。

  • GestureArenaEntry :提供手势事件竞技信息的实体,内封装参与事件竞技的成员。

  • GestureArenaMember:参与竞技的成员抽象对象,内部有 acceptGesturerejectGesture 方法,它表明手势竞技的成员,默认 GestureRecognizer 都实现了它,全部竞技的成员能够理解为就是 GestureRecognizer 之间的竞争。

  • _GestureArenaGestureArenaManager 内的竞技场,内部持参与竞技的 members 列表,官方对这个竞技场的解释是: 若是一个手势试图在竞技场开放时(isOpen=true)获胜,它将成为一个带有“渴望获胜”的属性的对象。当竞技场关闭(isOpen=false)时,竞技场将寻找一个“渴望获胜”的对象成为新的参与者,若是这时候恰好只有一个,那这一个参与者将成为此次竞技场胜利的青睐存在。

好了,知道这些概念以后咱们开始分析流程,咱们知道 GestureBindingdispatchEvent 时会先判断是否有 HitTestResult 是否有结果,通常状况下是存在的,因此直接执行循环 entry.target.handleEvent

2.一、PointerDownEvent

循环执行过程当中,咱们知道 entry.target.handleEvent 会触发RenderPointerListenerhandleEvent ,而事件流程中第一个事件通常都会是 PointerDownEvent

PointerDownEvent 的流程在事件竞技流程中至关关键,由于它会触发 GestureRecognizer.addPointer

GestureRecognizer 只有经过 addPointer 方法将 PointerDownEvent 事件和本身绑定,并添加到 GestureBindingPointerRouter 事件路由和 GestureArenaManager 事件竞技中,后续的事件这个控件的 GestureRecognizer 才能响应和参与竞争。

事实上 Down 事件在 Flutter 中通常都是用来作添加判断的,若是存在竞争时,大部分时候是不会直接出结果的,而 Move 事件在不一样 GestureRecognizer 中会表现不一样,而 UP 事件以后,通常会强制获得一个结果。

因此咱们知道了事件在 GestureBinding 开始分发的时候,在 PointerDownEvent 时须要响应事件的 GestureRecognizer 们,会调用 addPointer 将本身添加到竞争中。以后流程中若是没有特殊状况,通常会执行到参与竞争成员列表的 last,也就是 GestureBinding 本身这个 handleEvent 。

以下代码所示,走到 GestureBindinghandleEvent ,在 Down 事件的流程中,通常 pointerRouter.route 不会怎么处理逻辑,而后就是 gestureArena.close 关闭竞技场了,尝试获得胜利者。

@override // from HitTestTarget
  void handleEvent(PointerEvent event, HitTestEntry entry) {
  	 /// 导航事件去触发  `GestureRecognizer` 的 handleEvent
  	 /// 通常 PointerDownEvent 在 route 执行中不怎么处理。
    pointerRouter.route(event);
    
    ///gestureArena 就是 GestureArenaManager
    if (event is PointerDownEvent) {
    
    	///关闭这个 Down 事件的竞技,尝试获得胜利
      /// 若是没有的话就留到 MOVE 或者 UP。
      gestureArena.close(event.pointer);
      
    } else if (event is PointerUpEvent) {
    	///已经到 UP 了,强行获得结果。
      gestureArena.sweep(event.pointer);
      
    } else if (event is PointerSignalEvent) {
      pointerSignalResolver.resolve(event);
    }
  }
复制代码

让咱们看 GestureArenaManagerclose 方法,下面代码咱们能够看到,若是前面 Down 事件中没有经过 addPointer 添加成员到 _arenas 中,那会连参加的机会都没有,而进入 _tryToResolveArena 以后,若是 state.members.length == 1 ,说明只有一个成员了,那就不竞争了,直接它就是胜利者,直接响应后续全部事件。 那么若是是多个的话,就须要后续的竞争了。

void close(int pointer) {
  	/// 拿到咱们上面 addPointer 时添加的成员封装
    final _GestureArena state = _arenas[pointer];
    if (state == null)
      return; // This arena either never existed or has been resolved.
    state.isOpen = false;
    ///开始打起来吧
    _tryToResolveArena(pointer, state);
  }
  
  void _tryToResolveArena(int pointer, _GestureArena state) {
    if (state.members.length == 1) {
      scheduleMicrotask(() => _resolveByDefault(pointer, state));
    } else if (state.members.isEmpty) {
      _arenas.remove(pointer);
    } else if (state.eagerWinner != null) {
      _resolveInFavorOf(pointer, state, state.eagerWinner);
    }
  }
复制代码

2.2 开始竞争

那竞争呢?接下来咱们以 TapGestureRecognizer 为例子,若是控件区域内存在两个 TapGestureRecognizer ,那么在 PointerDownEvent 流程是不会产生胜利者的,这时候若是没有 MOVE 打断的话,到了 UP 事件时,就会执行 gestureArena.sweep(event.pointer); 强行选取一个。

而选择的方式也是很简单,就是 state.members.first ,从咱们以前 hitTest 的结果上理解的话,就是控件树的最里面 Child 了。 这样胜利的 member 会经过 members.first.acceptGesture(pointer) 回调到 TapGestureRecognizer.acceptGesture 中,设置 _wonArenaForPrimaryPointer 为 ture 标志为胜利区域,而后执行 _checkDown_checkUp 发出事件响应触发给这个控件。

而这里有个有意思的就是 ,Down 流程的 acceptGesture 中的 _checkUp 由于没有 _finalPosition 此时是不会被执行的,_finalPosition 会在 handlePrimaryPointer 方法中,得到_finalPosition 并判断 _wonArenaForPrimaryPointer 标志为,再次执行 _checkUp 才会成功。

handlePrimaryPointer 是在 UP 流程中 pointerRouter.route 触发 TapGestureRecognizerhandleEvent 触发的。

那么问题来了,_checkDown_checkUp 时在 UP 事件一次性被执行,那么若是我长按住的话,_checkDown 不是没办法正确回调了?

固然不会,在 TapGestureRecognizer 中有一个 didExceedDeadline 的机制,在前面 Down 流程中,addPointerTapGestureRecognizer 会建立一个定时器,这个定时器的时间时 kPressTimeout = 100毫秒若是咱们长按住的话,就会等待到触发 didExceedDeadline 去执行 _checkDown 发出 onTabDown 事件了。

_checkDown 执行发送过程当中,会有一个标志为 _sentTapDown 判断是否已经发送过,若是发送过了也不会在重发,以后回到本来流程去竞争,手指抬起后获得胜利者相应,同时在 _checkUp 以后 _sentTapDown 标识为会被重置。

这也能够分析点击下的几种场景:

普通按下:
  • 一、区域内只有一个 TapGestureRecognizer :Down 事件时直接在竞技场 close 时就获得竞出胜利者,调用 acceptGesture 执行 _checkUp,到 Up 事件的时候经过 handlePrimaryPointer 执行 _checkUp,结束。

  • 二、区域内有多个 TapGestureRecognizer :Down 事件时在竞技场 close 不会竞出胜利者,在 Up 事件的时候,会在 route 过程经过handlePrimaryPointer 设置好 _finalPosition,以后通过竞技场 sweep 选取排在第一个位置的为胜利者,调用 acceptGesture,执行 _checkDown_checkUp

长按以后抬起:

一、区域内只有一个 TapGestureRecognizer :除了 Down 事件是在 didExceedDeadline 时发出 _checkDown 外其余和上面基本没区别。

  • 二、区域内有多个 TapGestureRecognizer :Down 事件时在竞技场 close 时不会竞出胜利者,可是会触发定时器 didExceedDeadline,先发出 _checkDown,以后再通过 sweep 选取第一个座位胜利者,调用 acceptGesture,触发 _checkUp

那么问题又来了,你有没有疑问,若是有区域两个 TapGestureRecognizer ,长按的时候由于都触发了 didExceedDeadline 执行 _checkDown 吗?

答案是:会的!由于定时器都触发了 didExceedDeadline,因此 _checkDown 都会被执行,从而都发出了 onTapDown 事件。可是后续竞争后,只会执行一个 _checkUp ,全部只会有一个控件响应 onTap

竞技失败:

在竞技场竞争失败的成员会被移出竞技场,移除后就没办法参加后面事件的竞技了 ,好比 TapGestureRecognizer 在接受到 PointerMoveEvent 事件时就会直接 rejected , 并触发 rejectGesture ,以后定时器会被关闭,而且触发 onTapCancel ,而后重置标志位.

总结下:

Down 事件时经过 addPointer 加入了 GestureRecognizer 竞技场的区域,在没移除的状况下,事件能够参加后续事件的竞技,在某个事件阶段移除的话,以后的事件序列也会没法接受。事件的竞争若是没有胜利者,在 UP 流程中会强制指定第一个为胜利者。

2.3 滑动事件

滑动事件也是须要在 Down 流程中 addPointer ,而后 MOVE 流程中,经过在 PointerRouter.route 以后执行 DragGestureRecognizer.handleEvent

image.png

PointerMoveEvent 事件的 DragGestureRecognizer.handleEvent 里,会经过在 _hasSufficientPendingDragDeltaToAccept判断是否符合条件,如:

bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;
复制代码

若是符合条件就直接执行 resolve(GestureDisposition.accepted); ,将流程回到竞技场里,而后执行 acceptGesture ,而后触发onStartonUpdate

回到咱们前面的上下滑动可点击列表,是否是很明确了:若是是点击的话,没有产生 MOVE 事件,因此 DragGestureRecognizer 没有被接受,而Item 做为 Child 第一位,因此响应点击。若是有 MOVE 事件, DragGestureRecognizer 会被 acceptGesture,而点击 GestureRecognizer 会被移除事件竞争,也就没有后续 UP 事件了。

那这个 onUpdate 是怎么让节目动起来的?

咱们以 ListView 为例子,经过源码能够知道, onUpdate 最后会调用到 Scrollable_handleDragUpdate ,这时候会执行 Drag.update

image.png

经过源码咱们知道 ListViewDrag 实现实际上是 ScrollDragController, 它在 Scrollable 中是和 ScrollPositionWithSingleContext 关联的在一块儿的。那么 ScrollPositionWithSingleContext 又是什么?

ScrollPositionWithSingleContext 其实就是这个滑动的关键,它其实就是 ScrollPosition 的子类,而 ScrollPosition 又是 ViewportOffset 的子类,而 ViewportOffset 又是一个 ChangeNotifier,出现以下关系:

继承关系:ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier

因此 ViewportOffset 就是滑动的关键点。上面咱们知道响应区域 DragGestureRecognizer 胜利以后执行 Drag.update ,最终会调用到 ScrollPositionWithSingleContextapplyUserOffset,致使内部肯定位置的 pixels 发生改变,并执行父类 ChangeNotifier 的方法notifyListeners 通知更新。

而在 ListView 内部 RenderViewportBase 中,这个 ViewportOffset 是经过 _offset.addListener(markNeedsLayout); 绑定的,so ,触摸滑动致使 Drag.update ,最终会执行到 RenderViewportBase 中的 markNeedsLayout 触发页面更新。

至于 markNeedsLayout 如何更新界面和滚动列表,这里暂不详细描述了,给个图感觉下:

image.png

自此,第十三篇终于结束了!(///▽///)

资源推荐

完整开源项目推荐:

相关文章
相关标签/搜索