引言
有过移动端开发经验的同学都知道,移动端的触摸事件是由手指按下、手指移动、手指抬起这些基本事件组成的。
在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
在命中测试时的表现,提供多种不一样的触控表现。