Listener
Listener
背景:
- 可能会遇到的问题:如下代码,点击文字以外的区域是无响应的
GestureDetector(
child: Container(
height: 50,
// color: Colors.green,
padding: EdgeInsets.only(left: 5, right: 5),
alignment: Alignment.center,
child: Text(
"click me",
style: TextStyle(fontSize: 20),
)
),
onTap: () {
print("click");
},
)
原因分析:
GestureDetector -> RawGestureDetector -> Listener
Listener是一个监听指针事件的控件,比如按下、移动、释放、取消等指针事件。
通常情况下,监听手势事件使用GestureDetector,GestureDetector是更高级的手势事件。
Listener的事件介绍如下:
onPointerDown:按下时回调
onPointerMove:移动时回调
onPointerUp:抬起时回调
- 用法如下:
Listener(
onPointerDown: (PointerDownEvent pointerDownEvent) {
print('$pointerDownEvent');
},
onPointerMove: (PointerMoveEvent pointerMoveEvent) {
print('$pointerMoveEvent');
},
onPointerUp: (PointerUpEvent upEvent) {
print('$upEvent');
},
child: Container(
height: 200,
width: 200,
color: Colors.blue,
alignment: Alignment.center,
),
)
- 当手指按下时,Flutter会对应用程序执行命中测试(Hit Test),以确定指针与屏幕接触的位置存在哪些组件(widget), 指针按下事件(以及该指针的后续事件)然后被分发到由命中测试发现的最内部的组件,然后从那里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件被分发到组件树根的路径上的所有组件,这和Web开发中浏览器的事件冒泡机制相似, 但是Flutter中没有机制取消或停止“冒泡”过程,而浏览器的冒泡是可以停止的。注意,只有通过命中测试的组件才能触发事件。
什么是命中测试?
当手指按下、移动或者抬起时,Flutter会给每一个事件新建一个对象,如按下是PointerDownEvent,移动是PointerMoveEvent,抬起是PointerUpEvent。对于每一个事件对象,Flutter都会执行命中测试,它经历了以下这几步:
1、从最底层的Widget开始执行命中测试,是否命中取决于hitTestChildren方法(它的children Widget是否命中测试)或hitTestSelf方法是否返回true, 如果返回true,表示命中测试通过,会把自己以HitTestEntry添加到HitTestResult对象中。
2、循环最底层Widget的children Widget,分别执行child Widget的命中测试。child Widget是否命中也取决于hitTestChidren方法(它的children Widget是否命中测试)或hitTestSelf方法是否返回true。
3、从下往上递归地执行命中测试,直到找到最上层的一个命中测试的Widget,将它加入命中测试列表。由于它已命中测试,那么它的父Widget也命中了测试,将父Widget也加入命中测试列表。以此类推,直到将所有命中测试的Widget加入命中测试列表。
原则:优先判断children,再判断自己,只要有一个为true,就把自己加入到result中
bool hitTest(BoxHitTestResult result, { @required Offset position }) {
// 事件的position必须在当前组件内
if (_size.contains(position)) {
// 优先判断children,再判断自己,只要有一个为true,就把自己加入到result中
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
测试列表链:
- 在Flutter中,每一个Widget实际上会对应一个RenderObject。对于上面代码来说,上图为Widget和RenderObject的对应关系。
例:
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(200, 200)),
child: Center(
child: Text('click me'),
)
),
onPointerDown: (event) => print("onPointerDown")
)
image.png
1、当点击了Text时,它的命中测试列表是这样的:
RenderParagraph->RenderPositionedBox->RenderConstrainedBox->RenderPointerListener,
所以RenderPointerListener的handleEvent方法会被执行,最终在控制台会打印onPointerDown。
2、当点击了Text以外的区域时,它的命中测试列表就没有RenderPointerListener了。为什么呢???
Text以外的区域是ConstrainedBox的(为什么不是Center,因为Center的功能是帮助Text定位,它的区域和Text是一致的)。那ConstrainedBox对应的RenderConstrainedBox命中测试了么?很显然是没有的。
因为ConstrainedBox只有一个child,就是Center。Center对应的RenderPositionedBox没有命中测试,导致RenderConstrainedBox的hitTestChildren返回false,而它的hitTestSelf也返回false,所以RenderConstrainedBox没有命中测试。
而Listener也只有一个child,那就是ConstrainedBox,既然RenderConstrainedBox没有命中测试,那么RenderPointerListener相应的就没有命中测试,所以命中测试列表中是没有RenderPointerListener的。
所以控制台并不会打印onPointerDown。
上面的例子使用的behavior属性是默认的HitTestBehavior.deferToChild,如果修改一下behavior属性会有什么奇妙的效果呢?
一、behavior:
behavior表示命中测试(Hit Test)过程中的表现策略。它是一个枚举,提供了三个值,
分别是HitTestBehavior.deferToChild、HitTestBehavior.opaque、HitTestBehavior.translucent
/// How to behave during hit tests.
enum HitTestBehavior {
///事件是否处理取决于自己的子类
deferToChild,
/// Opaque targets can be hit by hit tests, causing them to both receive
/// events within their bounds and prevent targets visually behind them from
/// also receiving events.
/// 自己可以命中hitTest,又在视觉上阻止位于其后方的目标也接收事件。
opaque,
/// Translucent targets both receive events within their bounds and permit
/// targets visually behind them to also receive events.
///半透明目标既可以接收其范围内的事件,也可以在视觉上允许目标后面的目标也接收事件。
translucent,
}
- 源码引用顺序 Listener->_PointerListener->RenderPointerListener->RenderProxyBoxWithHitTestBehavior
源码分析:
RenderProxyBoxWithHitTestBehavior源码,代码很少,但逻辑就是在这里了
/// A RenderProxyBox subclass that allows you to customize the
/// hit-testing behavior.
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {
@override
bool hitTest(BoxHitTestResult result, { Offset position }) {
bool hitTarget = false;
// position在自己范围内
if (size.contains(position)) {
// 判断子类和自己是否命中,这是普通逻辑,没什么特别的
hitTarget =
hitTestChildren(result, position: position) || hitTestSelf(position);
// 如果是HitTestBehavior.translucent,强行将自己命中hittest,参与事件消费的队列中,这里hitTestChildren和hitTestSelf的结果就不重要了
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
//如果Behavior是opaque,且没有被子类重写,那就是返回true,也即是参与到事件消费的队列中
@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
}
所以,当我们用GestureDetector监听事件时,最后都会走到RenderProxyBoxWithHitTestBehavior里,只要behavio是opaque或translucent都会将自己加入到事件消费的队列, 而behavior默认是HitTestBehavior.deferToChild,当点击空白处时,
hitTestChildren(result, position: position)返回false
hitTestSelf(hitTestSelf) 也返回false
二、背景色:
接下来看第二个问题,为什么Container设置任意背景色也可以响应点击事件?
Container其实是个StateLessWidget,它本身并没有RenderObject对应,可以理解为是个配置项,真实渲染的render是其他配置引进的,比如color对应的ColoredBox
ColoredBox->_RenderColoredBox
// 一眼就看到了,强制设置为opaque了,答案和第一个问题一样了
class _RenderColoredBox extends RenderProxyBoxWithHitTestBehavior {
_RenderColoredBox({@required Color color})
: _color = color,
//看这里!!!!!!!!!!!!!!!!!!!!!
super(behavior: HitTestBehavior.opaque);
}
所以,Container设置任意背景色,可以响应点击事件,因为设置了color后,返回的widget里包含了ColoredBox,ColoredBox对应的RenderObject是_RenderColoredBox,_RenderColoredBox继承自RenderProxyBoxWithHitTestBehavior并强制指定了behavior是HitTestBehavior.opaque。
下面例子进一步印证:
return Stack(
children: <Widget>[
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(300.0, 300.0)),
child: DecoratedBox(decoration: BoxDecoration(color: Colors.red)),
),
onPointerDown: (event) => print("first child"),
),
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(200.0, 200.0)),
child: Center(child: Text("左上角200*200范围内-空白区域点击")),
),
onPointerDown: (event) => print("second child"),
//放开此行注释后,单词点击 first ,second都会响应,HitTestBehavior.opaque是不行的
// behavior: HitTestBehavior.translucent,
)
],
);
当点击左上角,非文本区域时,只会响应first child,当把这句代码注释打开
// behavior: HitTestBehavior.translucent,
会先输出second child,再输出first child。原因还是在RenderProxyBoxWithHitTestBehavior的hitTest方法
@override
bool hitTest(BoxHitTestResult result, { Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
//如果是translucent,尽快自身加入了命中测试队列,但返回的结果还是false,
//但如果是opaque,子类不重写hitTestSelf,那hitTarget肯定就是true了
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
对于Stack来说,对应的Render是RenderStack
RenderStack hitTestChildren
@override
bool hitTestChildren(BoxHitTestResult result, { Offset position }) {
return defaultHitTestChildren(result, position: position);
}
最终走到了RenderBoxContainerDefaultsMixin的defaultHitTestChildren
bool defaultHitTestChildren(BoxHitTestResult result, { Offset position }) {
// The x, y parameters have the top left of the node's box as the origin.
// 从最上层的子类开始遍历
ChildType child = lastChild;
while (child != null) {
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);
},
);
// 第一个命中,就直接返回了,后续的子类不再执行命中测试,所以translucent能透传,因为被它修饰的Listener,返回的结果是false
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
结论:
所以想要解决开头抛出的问题,方法如下:
1、GestureDetector的behavior设置为opaque或者translucent才行,
2、Container设置任意背景色
总结:
截屏2021-08-25 下午1.56.36.pngopaque和translucent的区别:
RenderStack的hitTestChildren返回了true,它就不会再去检测第二个child。
opaque: 第一个Listener是否命中测试” ,即意味着如果第一个child的hitTest返回true(例如opaque)的话Stack就不会再把指针事件传给第二个child,即不能透传,
translucent: 如果第一个child的hitTest返回false(例如translucent)则点击事件会被传递到第二个child,即能透传
Stack
参考:
https://juejin.cn/post/6844904079106277383
https://juejin.cn/post/6908365134365491208