引言
有过移动端开发经验的同学都知道,移动端的触摸事件是由手指按下、手指移动、手指抬起这些基本事件组成的。
在Flutter中,一切皆Widget。Widget本身并不具备识别触摸事件的功能。能识别触摸事件的Widget,必须经由Listener或GestureDetector组装起来。
而GestureDetector本质上还是由Listener组成的,所以我们先认识一下Listener。
Listener
Listener在功能划分上属于功能型Widget,主要提供原始触摸事件的监听。下面看一下它的构造函数:
const Listener({
Key key,
this.onPointerDown,
this.onPointerMove,
this.onPointerEnter,
this.onPointerExit,
this.onPointerHover,
this.onPointerUp,
this.onPointerCancel,
this.onPointerSignal,
this.behavior = HitTestBehavior.deferToChild,
Widget child,
})
从构造函数中可以知道,Listener提供了多种触摸事件的监听,但我们经常用到的是onPointerDown、onPointerMove、onPointerUp,分别对应手指按下、手指移动、手指抬起这三个触摸事件。
child属性表示被包装的Widget。
behavior属性,这是Listener很重要的一个属性,也是本节着重讨论的,但是现在还轮不到他出场,在理解behavior属性之前,我们必须要认识一个概念,叫做命中测试(Hit Test)。
一、命中测试
当手指按下、移动或者抬起时,Flutter会给每一个事件新建一个对象,如按下是PointerDownEvent,移动是PointerMoveEvent,抬起是PointerUpEvent。对于每一个事件对象,Flutter都会执行命中测试,它经历了以下这几步:
1、从最底层的Widget开始执行命中测试,是否命中取决于hitTestChildren方法(它的children Widget是否命中测试)或hitTestSelf方法是否返回true。
2、循环最底层Widget的children Widget,分别执行child Widget的命中测试。child Widget是否命中也取决于hitTestChidren方法(它的children Widget是否命中测试)或hitTestSelf方法是否返回true。
3、从下往上递归地执行命中测试,直到找到最上层的一个命中测试的Widget,将它加入命中测试列表。由于它已命中测试,那么它的父Widget也命中了测试,将父Widget也加入命中测试列表。以此类推,直到将所有命中测试的Widget加入命中测试列表。
举个例子
为了更加形象的理解命中测试这个概念,我们看一下下面的例子。
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(200, 200)),
child: Center(
child: Text('click me'),
)
),
onPointerDown: (event) => print("onPointerDown")
)
它的展示效果如上图所示。
在Flutter中,每一个Widget实际上会对应一个RenderObject。对于上面代码来说,上图为Widget和RenderObject的对应关系。
1、当点击了Text时,它的命中测试列表是这样的:
RenderParagraph->RenderPositionedBox->RenderConstrainedBox->RenderPointerListener,所以RenderPointerListener的handleEvent方法会被执行,最终在控制台会打印onPointerDown。
注意:触摸事件会循环命中测试列表,并分别执行它们的
handleEvent方法。Flutter中几乎所有Widget对应的RenderObject都是直接或者间接继承自RenderBox,而RenderBox继承了HitTestTarget,并重写了handleEvent方法。
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。
说明:命中测试方法是
RenderBox(RenderObject的子类)的hitTest方法。
上面的例子使用的behavior属性是默认的HitTestBehavior.deferToChild,如果修改一下behavior属性会有什么奇妙的效果呢?
二、behavior属性
behavior表示命中测试(Hit Test)过程中的表现策略。它是一个枚举,提供了三个值,分别是HitTestBehavior.deferToChild、HitTestBehavior.opaque、HitTestBehavior.translucent。
上面说到过,命中测试,就是看RenderBox的hitTest的返回值,如Listener的hitTest方法如下。
bool hitTest(BoxHitTestResult result, { 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;
}
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
HitTestBehavior.deferToChild:Listener是否命中测试,取决于子child是否命中测试,这是默认behavior的默认值。
HitTestBehavior.opaque:当Listener的子child没有命中测试时,该属性值保证hitTestSelf返回true,即保证Listener所在区域能响应触摸事件。
HitTestBehavior.translucent:当Listener的子child没有命中测试时,并且hitTestSelf返回false时,该属性值可以保证Listener所在的区域能响应触摸事件(加入到命中测试列表),但是hitTest方法返回值还是false,这不能改变。
举个例子
上面那个例子,我们将Listener的behavior属性修改为HitTestBehavior.opaque。
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(200, 200)),
child: Center(
child: Text('click me'),
)
),
behavior: HitTestBehavior.opaque, //显性的修改behavior属性
onPointerDown: (event) => print("onPointerDown")
)
当我们再次点击Text以外的区域时,可以发现命中列表中加入了RenderPointerListener。
因为当RenderPointerListener执行hitTestSelf时,判断behavior如果为HitTestBehavior.opaque,则返回true。也就是说RenderPointerListener符合命中测试。
所以,我们能看到控制台将会打印onPointerDown。
再举个例子
为了更深入的理解behavior属性,我们再来看另外一个例子。
Stack(
children: <Widget>[
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(400, 200)),
child: Container(
color: Colors.blue,
)
),
onPointerDown: (event) => print("onPointerDown1"),
),
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(400, 200)),
child: Center(child: Text("dont click me")),
),
onPointerDown: (event) => print("onPointerDown2"),
// behavior: HitTestBehavior.opaque, //注释1
// behavior: HitTestBehavior.translucent, //注释2
)
],
),
它的展示效果如上图所示。
上图为
Widget与RenderObject的对应关系。
1、behavior为默认HitTestBehavior.deferToChild属性时,当点击了Text以外的区域,它的命中测试列表是这样的:
RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack。
RenderStack的hitTestChildren会先找Stack中最上层的child,看它是否命中测试。很显然,第一个child,即第二个Listener没有命中测试。
然后它再去找第二个child,即第一个Listener是否命中测试。这里的第一个Listener包含的Container设置了color属性,所以Container这里对应的是RenderDecoratedBox,它通过了命中测试,相应的Listener也通过了命中测试。
所以控制台会只打印onPointerDown1。
2、将注释2关闭,注释1打开,behavior为HitTestBehavior.opaque属性时,当点击了Text以外的区域,它的命中测试列表是这样的:
RenderPointerListener->RenderStack。
RenderStack的hitTestChildren会先找Stack中最上层的child,看它是否命中测试。第一个child,即第二个Listener加上了HitTestBehavior.opaque属性后,通过了命中测试。
这个时候RenderStack的hitTestChildren直接返回了true,它并不会再去检测第二个child,即第一个Listener是否命中测试。
所以控制台只会打印onPointerDown2。
3、将注释1关闭,注释2打开,behavior为HitTestBehavior.translucent属性时,当点击了Text以外的区域,它的命中测试列表是这样的:
RenderPointerListener->RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack。
RenderStack的hitTestChildren会先找Stack中最上层的child,看它是否命中测试。第一个child,即第二个Listener加上了HitTestBehavior.translucent属性后,通过了命中测试,加入命中测试列表。但必须注意的是,虽然通过了命中测试,但是该RenderPointerListener的hitTest方法返回false。
然后RenderStack会再去找第二个child,即第一个Listener是否命中测试。由上面的分析可知,它是通过了命中测试的。因此整个命中测试列表就是:
RenderPointerListener->RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack。
所以控制台会先打印onPointerDown2,然后再打印onPointerDown1。
总结
Flutter的Listener组件是一切可触控Widget的包装组件,在触摸事件确定怎么样传递时,需要对Widget进行命中测试。Listener提供了behavior属性,可灵活的改变Listener在命中测试时的表现,提供多种不一样的触控表现。