Flutter的触摸事件

一次触摸事件从Down开始Up结束, 而Flutter是一个跨平台的UI框架, 那么Flutter是如何收集不同平台传递的触摸事件?

我们以Android为例深入探索Flutter对触摸事件的处理.

点击事件响应流程

首先一个点击事件出发点肯定是从Native发出的, 然后经过多次转换传到Flutter层.

图片来自网络

总结来说

Native -> C++

Android onTouchEvent 接收到触摸事件, 通过 Flutter.JNI 传递到 C++ 层.

C++ -> Dart

C++ 通过 RuntimeControll 控制器 调用 Window.DispatchPointerDataPacker 将窗体坐标点位传递到Dart层.

Dart -> 逻辑像素

Dart层通过FlutterWindow与Native交互, 通过GestureBinding#window.onPointDataPacket初始化, 开始进行手势处理.

Flutter处理点击事件流程

  • 收集所有的触摸事件转换为逻辑像素.
  • 对收集的点击事件进行命中测试, 得到一个新的集合HitTestResult.
  • HitTestResult收集的Widget进行触摸事件分发, 最后处理事件的是GestureBinding, 宣告一次点击事件结束, 并将都能够响应触摸事件的Widget进行手势竞争, 胜出者接受手势, 其他接受拒绝手势.

触摸事件收集与分发

原始数据转换为逻辑像素

由Native传来的原始触摸事件会把由GestureBinding#_handlePointerDataPacket方法转换为对应的逻辑像素.

  // 需要处理的队列
  final Queue<PointerEvent> _pendingPointerEvents = Queue<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();
  }

触摸事件处理

GestureBinding#_handlePointerEvent方法进行触摸事件的命中测试和分发.

    // 记录所有的Down点击事件, 不会跟踪悬停状态的事件, 因为需要对每一帧进行命中测试
    // key是event.pointer, 它不会重复, 每个Down事件的时候pointer会+1
    final Map<int, HitTestResult> _hitTests = <int, HitTestResult>{};

    void _handlePointerEvent(PointerEvent event) {
      HitTestResult? hitTestResult;
      if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
        // Dwon事件触发
        assert(!_hitTests.containsKey(event.pointer));
        hitTestResult = HitTestResult();
        //进行命中测试
        hitTest(hitTestResult, event.position);
        if (event is PointerDownEvent) {
          // 添加到 _hitTests 集合
          _hitTests[event.pointer] = hitTestResult;
        }
      } else if (event is PointerUpEvent || event is PointerCancelEvent) {
        // up 事件之后, 手势结束, 所以移除
        hitTestResult = _hitTests.remove(event.pointer);
      } else if (event.down) {
        // 移动的过程, 根据event.pointer, 进行重新赋值
        hitTestResult = _hitTests[event.pointer];
      }
      if (hitTestResult != null ||
          event is PointerAddedEvent ||
          event is PointerRemovedEvent) {
        assert(event.position != null);
        // 事件分发
        dispatchEvent(event, hitTestResult);
      }
    }

一次点击事件由Down+Up组成, 经过Dart代码将屏幕实际位置转换为物理位置, 然后加入队列, 对Dwon事件进行hitTest(命中测试)

hitTest 对响应触摸事件的Widget进行汇总

hitTest 会从根Widget进行深度优先遍历, 把所有能够响应触摸事件的Widget添加到队列.

也就是说最后添加到Widget树的Widget会优先添加到队列进行命中测试.

命中测试就是判断触摸点位是否在Widget的Layout范围内(包顶部和左侧边缘, 不包底部和右侧边缘)

最终HitTestResult汇总了所有能够响应点击事件的Widget集合, 而需要注意的是, GestureBinding方法最终会把自己添加到队列末尾.

  • GestureBinding#hitTest
  // GestureBinding 会把自己添加到队列末尾
  @override // from HitTestable
    void hitTest(HitTestResult result, Offset position) {
    result.add(HitTestEntry(this));
  }
  • RendererBinding#hitTest
  /// 负责绘制渲染的 Root 节点
  RenderView get renderView => _pipelineOwner.rootNode! as RenderView;
  ///绘制树的owner,负责绘制,布局,合成
  PipelineOwner get pipelineOwner => _pipelineOwner;

  // RendererBinding 对 hitTest进行了重写
  @override
  void hitTest(HitTestResult result, Offset position) {
    assert(renderView != null);
    assert(result != null);
    assert(position != null);
    // 从根节点进行遍历
    // 调用 -> View#hitTest
    renderView.hitTest(result, position: position);
    super.hitTest(result, position);
  }
  • View#hitTest
  bool hitTest(HitTestResult result, { required Offset position }) {
    // 深度优先遍历
    // 调用 -> RenderBox#hitTest
    if (child != null)
      child!.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }
  • RenderBox#hitTest
  // 如果接受的触摸事件在Widget树中, 则返回 true
  // position 是被转换之后的相对坐标
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
    // 判断触摸点位是否在Widget的上下左右范围内(包顶部和左侧边缘, 不包底部和右侧边缘)
    if (_size!.contains(position)) {
      // true: 当这个Widget的孩子或者自己包含触摸点, 则把添加这个绘制对象到hitResult中, 这样就说明当前Widget已经响应了触摸事件.
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    // false: 交给下一个Widget处理
    return false;
  }

手势分发

GestureBinding#_handlePointerEvent方法最后进行事件分发.

    void _handlePointerEvent(PointerEvent event) {
      // 深度遍历收集所有命中测试的Widget.
      // ......
      if (hitTestResult != null || event is PointerAddedEvent || event is PointerRemovedEvent) {
        assert(event.position != null);
        // 对收集的Widget进行事件分发, Widget树最深的, 即UI最上层的Widget最先响应.
        dispatchEvent(event, hitTestResult);
      }
    }

    // 事件将发送到[HitTestResult]集合中的每个[HitTestTarget]handleEvent方法, 其中处理程序的所有异常都会被捕获
    @override // from HitTestDispatcher
    void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
      for (final HitTestEntry entry in hitTestResult.path) {
        entry.target.handleEvent(event.transformed(entry.transform), entry);
      }
    }

总结

触摸事件主要的处理类是GestureBinding, 它会对Widget树进行深度遍历, 并对每一个Widget进行测试命中, 测试命中的判断依据是该触摸事件的坐标是否在WidgetLayout范围内, HitTestResult负责收集所有测试命中的Widget, 并对其进行事件分发dispatchEvent.

手势冲突

上面介绍了, GestureBinding#_handlePointerEvent方法会对测试命中的Widget进行事件分发, 而很显然并不是测试命中的Widget就可以响应并处理触摸事件, 那么什么样的Widget会收到Down事件并且能够进行后续处理呢?

Down 事件进一步收集

以InkWell为例, 我们查看Down事件是如何被收集的.

// InkWell继承了InkResponse, 那么查看InkResponse的实现
class InkWell extends InkResponse {}

// InkResponse是一个StatelessWidget, 那么查看build方法
class InkResponse extends StatelessWidget {
    Widget build() {
      return
        // 其他 ...
        // 最后嵌套了GestureDetector
        GestureDetector();
    }
}
// 继续查看build方法
class GestureDetector extends StatelessWidget {
    Widget build() {
      return RawGestureDetector();
    }
}
// 继续查看build方法
class RawGestureDetector extends StatefulWidget {
    Widget build() {
      return Listener();
    }
}
// Listener最后嵌套的了RenderPointerListener
class Listener extends SingleChildRenderObjectWidget {
  RenderPointerListener createRenderObject(BuildContext context) {
    return RenderPointerListener();
  }
}
// 最终发现 RenderPointerListener 间接实现了RenderObject, 而RenderObject实现了 HitTestTarget
// 还记得 GestureBinding#_handlePointerEvent 会遍历调用 HitTestTarget#handleEvent 方法吗
class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {

  // event 是 经过RawGestureDetector实现了Down事件传递过来的
  @override
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    if (event is PointerDownEvent)
      return onPointerDown?.call(event);
    if (event is PointerMoveEvent)
      return onPointerMove?.call(event);
    if (event is PointerUpEvent)
      return onPointerUp?.call(event);
    if (event is PointerHoverEvent)
      return onPointerHover?.call(event);
    if (event is PointerCancelEvent)
      return onPointerCancel?.call(event);
    if (event is PointerSignalEvent)
      return onPointerSignal?.call(event);
  }
}

// RawGestureDetector最终嵌套了RenderPointerListener, 而RenderPointerListener被HitTestResult#_handlePointerDown方法回调.
// 而在回调RawGestureDetector#_handlePointerDown方法中会把该触摸事件添加到手势竞争场, 并进行手势竞争.
void _handlePointerDown(PointerDownEvent event) {
  // 添加event到手势竞技场
  for (final GestureRecognizer recognizer in _recognizers!.values)
    recognizer.addPointer(event);
}

// GestureRecognizer#addPointer
void addPointer(PointerDownEvent event) {
  // GestureRecognizer 负责将自身添加到全局指针路由器, 以接收此指针的后续事件,
  // 并将Pointer添加到GestureArenaManager, 以跟踪该指针。
  addAllowedPointer(event);
  //...
}

// 最终, 重写addAllowedPointer的控件会被添加到手势竞技场中.
GestureBinding.instance!.gestureArena.add(event.pointer, this),

手势冲突

我们通过Down事件进一步收集发现, 并不是所有的Widget都会响应触摸事件, 而是通过RenderPointerListener进行不同的触摸事件识别并回调到RawGestureDetector.

如果在页面中存在多个RawGestureDetector就会有触摸事件冲突问题, 具体由哪个Widget接受触摸事件? 每一个RawGestureDetector都响应了触摸事件怎么处理? 这就需要了解手势的竞争留存问题.

TODO 手势竞技场

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容