前言
之所以将手势操作称为事件传递,是因为对应的输入都以Event的形式分发,手势操作的Event为PointerEvent,即点击事件。本文将从事件入口出发,一步步分析事件的整个传递流程以及如何被响应,从而知晓点击无效的原因以及学会正确的设置手势回调。
事件传递
入口
通过引擎JNI调用,Flutter中的入口为**_dispatchPointerDataPacket**
dart
@pragma('vm:entry-point')
void _dispatchPointerDataPacket(ByteData packet) {
PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}
在Android中由onTouchEvent 及onGenericMotionEvent方法进行调用,这里不再介绍。
分发
在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为translucent 或opaque时均会成功命中 不过这两者有什么区别呢? 分析以上代码,当为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中介绍。
相关案例
- 为什么点击空白区域没有触发点击事件?
由上面的hitTest可以知道,点击空白并不会有child命中成功,并且没有设置HitTestBehavior为translucent或opaque的话,自身也不会命中成功,没有成功命中,也不会分发事件给recognizer引起手势回调。
- 双指滚动为什么列表的滚动距离会翻倍?
双指会产生两个PointerDownEvent,之后的双指滑动会分别产生不同pointer的PointerMoveEvent,都会被Scrollable接收并进行滚动。其实问题中的翻倍的描述并不准确,而是列表滚动的距离是两根手指滚动距离之和,更多根手指也同理。
总结
事件会由GestureBinding进行分发,在手机上的每次点击都会有一个pointer,我们以一次滚动为例,一般以PointerDownEvent或PointerHoverEvent作为整个手势的开始(个人测试发现第一个Event为PointerHoverEvent)。
- 在该pointer的第一个事件开始进行hitTest并保存HitTestResult,在收到PointerDownEvent事件时,开始调用startTrackingPointer,将会添加此pointer的router;
- 后续的PointerMoveEvent通过pointerRouter进行route;
- 收到PointerUpEvent后清除所有该pointer的hitTestResult,并调用stopTrackingPointer清除router,标记着此次手势的结束