Flutter刨根问底——点击事件(上)

前言

之所以将手势操作称为事件传递,是因为对应的输入都以Event的形式分发,手势操作的Event为PointerEvent,即点击事件。本文将从事件入口出发,一步步分析事件的整个传递流程以及如何被响应,从而知晓点击无效的原因以及学会正确的设置手势回调。

事件传递

入口

通过引擎JNI调用,Flutter中的入口为**_dispatchPointerDataPacket**

dart 复制代码
@pragma('vm:entry-point')
void _dispatchPointerDataPacket(ByteData packet) {
  PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}

在Android中由onTouchEventonGenericMotionEvent方法进行调用,这里不再介绍。

分发

在GestureBinding初始化时设置的_handlePointerDataPacket的回调中对事件进行分发,我们主要看下**_handlePointerEventImmediately** 以及dispatchEvent这两个方法

dart 复制代码
void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  // 每次手势的开始事件
  if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent || event is PointerPanZoomStartEvent) {
    assert(!_hitTests.containsKey(event.pointer));
    hitTestResult = HitTestResult();
    hitTest(hitTestResult, event.position);
    if (event is PointerDownEvent || event is PointerPanZoomStartEvent) {
      _hitTests[event.pointer] = hitTestResult;
    }
  } else if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent) {
  // 结束事件
    hitTestResult = _hitTests.remove(event.pointer);
  } else if (event.down || event is PointerPanZoomUpdateEvent) {
    hitTestResult = _hitTests[event.pointer];
  }
  if (hitTestResult != null ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    assert(event.position != null);
    dispatchEvent(event, hitTestResult);
  }
}
dart 复制代码
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
  if (hitTestResult == null) {
    assert(event is PointerAddedEvent || event is PointerRemovedEvent);
    try {
    // 执行pointer tracking
      pointerRouter.route(event);
    } catch (exception, stack) {
    }
    return;
  }
  for (final HitTestEntry entry in hitTestResult.path) {
    try {
      entry.target.handleEvent(event.transformed(entry.transform), entry);
    } catch (exception, stack) {
    }
  }
}

分发主要就两件事,确定点到了谁😉,之后把整个事件给到对应的处理者,即HitTestTarget,一般为RenderObject,对应的处理在handleEvent方法中。其中命运石的选择交给了hitTest,选择的结果保存在HitTestResult中的path中😊。

HitTest

这个过程影响到了我们设置的点击事件有没有效。Flutter存在一个初学者容易发现的问题:点击空白处怎么没有效果,热区太小了等等。

因为我们所用的WidgetsBinding继承了RendererBinding,并且RendererBinding重写了GestureBinding的hitTest方法,所以我们直接看RendererBinding中的hitTest

dart 复制代码
void hitTest(HitTestResult result, Offset position) {
  renderView.hitTest(result, position: position);
  super.hitTest(result, position);
}

GestureBinding的hitTest方法只做了result.add(HitTestEntry(this))这一件事,也就是自己直接被命中了,当然它内部并没有处理handleEvent。那这个renderView是什么呢?是RenderObject树的rootNode。

那么作为一个"普通人",RenderBox中的hitTest是怎么做的呢?

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

其中的hitTestChildren 是来判断是否自己的child是否能够被命中,如果是那么自己也跟命中沾亲带故,故把自己也add到result中去了;hitTestSelf则是决定自己可不可以被命中。不过这些的前提都是点击区域在自己的size中,即神选之民才可以(没有任何影射😎)

当然hitTest也有一些特殊的处理,比如我们用到HitTestBehavior时 RenderProxyBoxWithHitTestBehavior:

dart 复制代码
bool hitTest(BoxHitTestResult result, { required Offset position }) {
  bool hitTarget = false;
  if (size.contains(position)) {
    hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
    if (hitTarget || behavior == HitTestBehavior.translucent) {
      result.add(BoxHitTestEntry(this, position));
    }
  }
  return hitTarget;
}

@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;

以上可以看出当HitTestBehavior为translucentopaque时均会成功命中 不过这两者有什么区别呢? 分析以上代码,当为translucent时,如果点击空白处,hitTestChildren返回false,此时hitTarget为false,hitTest将会返回false,为opaque时会返回true,即返回值不同。

这个hitTest的返回值有什么用呢?接下来看下hitTestChildren的默认实现defaultHitTestChildren

dart 复制代码
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) {
        return child!.hitTest(result, position: transformed);
      },
    );
    if (isHit) {
      return true;
    }
    child = childParentData.previousSibling;
  }
  return false;
}

以上代码,遍历child,如果isHit为true,终止循环,这将影响到后续child上车

我还没上车呢,怎么活动结束了?

所以,如果在一个Stack中设置了HitTestBehavior为opaque,那么即使下面的child在空白处可以被命中,也无法触发任何事件。

handleEvent

除了一些特殊的处理,如TextField中的点击以及长按等,一般情况下使用的是GestureDetector以及ListView,而它们的事件由Listener中的handleEvent来分发

dart 复制代码
void handleEvent(PointerEvent event, HitTestEntry entry) {
  assert(debugHandleEvent(event, 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 PointerSignalEvent) {
    return onPointerSignal?.call(event);
  }
}

这里将事件交给对应的回调去处理,比如GestureDetector主要接收PointerDownEvent,交给GestureRecognizer去处理,Scrollable接收PointerSignalEvent来滚动

下面是这些Event的说明及作用

Event Introduce Usage
PointerDownEvent 按下事件,分发给新的hitTest 标志着手势的开始
PointerUpEvent 抬起事件,分发给down的hitTest 点击手势的结束
PointerEnterEvent 进入target区域,无需hitTest 事件开始
PointerExitEvent 离开target区域,无需hitTest 事件结束
PointerMoveEvent 移动,分发给down的hitTest 滑动
PointerHoverEvent 移动,无需hitTest 隔空滑动
PointerAddedEvent 事件开始后的tracking
PointerRemovedEvent 终止tracking
PointerCancelEvent 事件取消 取消点击
PointerSignalEvent 离散的指针信号 鼠标滑轮滚动

route

回看开始的dispatchEvent,其中的 pointerRouter.route(event) 是干什么的?

这其实是一个AOP(面向切片编程)实践,与由hitTestResult的target处理事件不同,route是通过startTrackingPointer注册的router来处理,即由recognizer来处理事件,具体细节将在下半部分GestureArena中介绍。

相关案例

  1. 为什么点击空白区域没有触发点击事件?

由上面的hitTest可以知道,点击空白并不会有child命中成功,并且没有设置HitTestBehavior为translucent或opaque的话,自身也不会命中成功,没有成功命中,也不会分发事件给recognizer引起手势回调。

  1. 双指滚动为什么列表的滚动距离会翻倍?

双指会产生两个PointerDownEvent,之后的双指滑动会分别产生不同pointer的PointerMoveEvent,都会被Scrollable接收并进行滚动。其实问题中的翻倍的描述并不准确,而是列表滚动的距离是两根手指滚动距离之和,更多根手指也同理。

总结

事件会由GestureBinding进行分发,在手机上的每次点击都会有一个pointer,我们以一次滚动为例,一般以PointerDownEvent或PointerHoverEvent作为整个手势的开始(个人测试发现第一个Event为PointerHoverEvent)。

  1. 在该pointer的第一个事件开始进行hitTest并保存HitTestResult,在收到PointerDownEvent事件时,开始调用startTrackingPointer,将会添加此pointer的router;
  2. 后续的PointerMoveEvent通过pointerRouter进行route;
  3. 收到PointerUpEvent后清除所有该pointer的hitTestResult,并调用stopTrackingPointer清除router,标记着此次手势的结束
相关推荐
AiFlutter15 小时前
Flutter之Package教程
flutter
Mingyueyixi19 小时前
Flutter Spacer引发的The ParentDataWidget Expanded(flex: 1) 惨案
前端·flutter
crasowas1 天前
Flutter问题记录 - 适配Xcode 16和iOS 18
flutter·ios·xcode
老田低代码2 天前
Dart自从引入null check后写Flutter App总有一种难受的感觉
前端·flutter
AiFlutter3 天前
Flutter Web首次加载时添加动画
前端·flutter
ZemanZhang4 天前
Flutter启动无法运行热重载
flutter
AiFlutter4 天前
Flutter-底部选择弹窗(showModalBottomSheet)
flutter
帅次5 天前
Android Studio:驱动高效开发的全方位智能平台
android·ide·flutter·kotlin·gradle·android studio·android jetpack
程序者王大川5 天前
【前端】Flutter vs uni-app:性能对比分析
前端·flutter·uni-app·安卓·全栈·性能分析·原生
yang2952423615 天前
使用 Vue.js 将数据对象的值放入另一个数据对象中
前端·vue.js·flutter