原始指针事件处理
命中测试
在移动端,各个平台或UI系统的原始指针事件模型基本都是一致,即:一次完整的事件分为三个阶段:手指按下、手指移动、和手指抬起,而更高级别的手势(如点击、双击、拖动等)都是基于这些原始事件的。
当指针按下时,Flutter会对应用程序执行命中测试(Hit Test),以确定指针与屏幕接触的位置存在哪些组件(widget), 指针按下事件(以及该指针的后续事件)然后被分发到由命中测试发现的最内部的组件,然后从那里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件被分发到组件树根的路径上的所有组件,这和Web开发中浏览器的事件冒泡机制相似, 但是Flutter中没有机制取消或停止“冒泡”过程,而浏览器的冒泡是可以停止的。注意,只有通过命中测试的组件才能触发事件。
Listener 组件
Flutter中可以使用Listener
来监听原始触摸事件,Listener
也是一个功能性组件。下面是Listener
的构造函数定义:
Listener({
Key key,
this.onPointerDown, //手指按下回调
this.onPointerMove, //手指移动回调
this.onPointerUp,//手指抬起回调
this.onPointerCancel,//触摸事件取消回调
this.behavior = HitTestBehavior.deferToChild, //先忽略此参数,后面会专门介绍
Widget child
})
我们先看一个示例,下面代码功能是: 手指在一个容器上移动时查看手指相对于容器的位置。
class _PointerMoveIndicatorState extends State<PointerMoveIndicator> {
PointerEvent? _event;
Widget build(BuildContext context) {
return Listener(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: 300.0,
height: 150.0,
child: Text(
'${_event?.localPosition ?? ''}',
style: TextStyle(color: Colors.white),
),
),
onPointerDown: (PointerDownEvent event) => setState(() => _event = event),
onPointerMove: (PointerMoveEvent event) => setState(() => _event = event),
onPointerUp: (PointerUpEvent event) => setState(() => _event = event),
);
}
}
运行后效果:
手指在蓝色矩形区域内移动即可看到当前指针偏移,当触发指针事件时,参数 PointerDownEvent
、 PointerMoveEvent
、 PointerUpEvent
都是PointerEvent
的子类,PointerEvent
类中包括当前指针的一些信息,注意 Pointer
,即“指针”, 指事件的触发者,可以是鼠标、触摸板、手指。
如:
position
:它是指针相对于当对于全局坐标的偏移。localPosition
: 它是指针相对于当对于本身布局坐标的偏移。delta
:两次指针移动事件(PointerMoveEvent
)的距离。pressure
:按压力度,如果手机屏幕支持压力传感器(如iPhone的3D Touch),此属性会更有意义,如果手机不支持,则始终为1。orientation
:指针移动方向,是一个角度值。
上面只是PointerEvent
一些常用属性,除了这些它还有很多属性,可以查看API文档。
还有一个behavior
属性,它决定子组件如何响应命中测试,关于该属性将在后面详细介绍。
忽略指针事件
假如我们不想让某个子树响应PointerEvent
的话,我们可以使用IgnorePointer
和AbsorbPointer
,这两个组件都能阻止子树接收指针事件,不同之处在于AbsorbPointer
本身会参与命中测试,而IgnorePointer
本身不会参与,这就意味着AbsorbPointer
本身是可以接收指针事件的(但其子树不行),而IgnorePointer
不可以。
一个简单的例子如下:
Listener(
child: AbsorbPointer(
child: Listener(
child: Container(
color: Colors.red,
width: 200.0,
height: 100.0,
),
onPointerDown: (event)=>print("in"),
),
),
onPointerDown: (event)=>print("up"),
)
点击Container
时,由于它在AbsorbPointer
的子树上,所以不会响应指针事件,所以日志不会输出"in
",但AbsorbPointer
本身是可以接收指针事件的,所以会输出"up
"。如果将AbsorbPointer
换成IgnorePointer
,那么两个都不会输出。
手势识别
GestureDetector
GestureDetector
是一个用于手势识别的功能性组件,我们通过它可以来识别各种手势。GestureDetector
内部封装了 Listener
,用以识别语义化的手势,接下来我们详细介绍一下各种手势的识别。
1. 点击、双击、长按
我们通过GestureDetector
对Container
进行手势识别,触发相应事件后,在Container
上显示事件名,为了增大点击区域,将Container
设置为200×100
,代码如下:
class _GestureTestState extends State<GestureTest> {
String _operation = "No Gesture detected!"; //保存事件名
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: 200.0,
height: 100.0,
child: Text(
_operation,
style: TextStyle(color: Colors.white),
),
),
onTap: () => updateText("Tap"), //点击
onDoubleTap: () => updateText("DoubleTap"), //双击
onLongPress: () => updateText("LongPress"), //长按
),
);
}
void updateText(String text) {
//更新显示的事件名
setState(() {
_operation = text;
});
}
}
运行效果:
注意: 当同时监听onTap
和onDoubleTap
事件时,当用户触发tap
事件时,会有200
毫秒左右的延时,这是因为当用户点击完之后很可能会再次点击以触发双击事件,所以GestureDetector
会等一段时间来确定是否为双击事件。如果用户只监听了onTap
(没有监听onDoubleTap
)事件时,则没有延时。
2. 拖动、滑动
一次完整的手势过程是指用户手指按下到抬起的整个过程,期间,用户按下手指后可能会移动,也可能不会移动。GestureDetector
对于拖动和滑动事件是没有区分的,他们本质上是一样的。GestureDetector
会将要监听的组件的原点(左上角)作为本次手势的原点,当用户在监听的组件上按下手指时,手势识别就会开始。
下面我们看一个拖动圆形字母A的示例:
class _Drag extends StatefulWidget {
_DragState createState() => _DragState();
}
class _DragState extends State<_Drag> with SingleTickerProviderStateMixin {
double _top = 0.0; //距顶部的偏移
double _left = 0.0;//距左边的偏移
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
onPanDown: (DragDownDetails e) { // 手指按下时会触发此回调
// 打印手指按下的位置(相对于屏幕)
print("用户手指按下:${e.globalPosition}");
},
onPanUpdate: (DragUpdateDetails e) { // 手指滑动时会触发此回调
// 用户手指滑动时,更新偏移,重新构建
setState(() {
_left += e.delta.dx;
_top += e.delta.dy;
});
},
onPanEnd: (DragEndDetails e){
// 打印滑动结束时在x、y轴上的速度
print(e.velocity);
},
),
)
],
);
}
}
运行后,就可以在任意方向拖动了,运行效果:
日志:
I/flutter ( 8513): 用户手指按下:Offset(26.3, 101.8)
I/flutter ( 8513): Velocity(235.5, 125.8)
代码解释:
DragDownDetails.globalPosition
:当用户按下时,此属性为用户按下的位置相对于屏幕(而非父组件)原点(左上角)的偏移。DragUpdateDetails.delta
:当用户在屏幕上滑动时,会触发多次Update
事件,delta
指一次Update
事件的滑动的偏移量。DragEndDetails.velocity
:该属性代表用户抬起手指时的滑动速度(包含x、y
两个轴的),示例中并没有处理手指抬起时的速度,常见的效果是根据用户抬起手指时的速度做一个减速动画。
单一方向拖动
在上例中,是可以朝任意方向拖动的,但是在很多场景,我们只需要沿一个方向来拖动,如一个垂直方向的列表,GestureDetector
可以只识别特定方向的手势事件,我们将上面的例子改为只能沿垂直方向拖动:
class _DragVertical extends StatefulWidget {
_DragVerticalState createState() => _DragVerticalState();
}
class _DragVerticalState extends State<_DragVertical> {
double _top = 0.0;
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
onVerticalDragUpdate: (DragUpdateDetails details) { // 垂直方向拖动事件
setState(() {
_top += details.delta.dy;
});
},
),
)
],
);
}
}
这样就只能在垂直方向拖动了,如果只想在水平方向滑动同理。
3. 缩放
GestureDetector
可以监听缩放事件,下面示例演示了一个简单的图片缩放效果:
class _Scale extends StatefulWidget {
const _Scale({Key? key}) : super(key: key);
_ScaleState createState() => _ScaleState();
}
class _ScaleState extends State<_Scale> {
double _width = 200.0; // 通过修改图片宽度来达到缩放效果
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
child: Image.asset("./images/sea.png", width: _width), // 指定宽度,高度自适应
onScaleUpdate: (ScaleUpdateDetails details) {
setState(() {
// 缩放倍数在0.8到10倍之间
_width = 200 * details.scale.clamp(.8, 10.0);
});
},
),
);
}
}
运行效果:
现在在图片上双指张开、收缩就可以放大、缩小图片。
GestureRecognizer
GestureDetector
内部是使用一个或多个GestureRecognizer
来识别各种手势的,而GestureRecognizer
的作用就是通过Listener
来将原始指针事件转换为语义手势,GestureDetector
直接可以接收一个子widget
。GestureRecognizer
是一个抽象类,一种手势的识别器对应一个GestureRecognizer
的子类,Flutter实现了丰富的手势识别器,我们可以直接使用。
示例:假设我们要给一段富文本(RichText
)的不同部分分别添加点击事件处理器,但是TextSpan
并不是一个widget
,这时我们不能用GestureDetector
,但TextSpan
有一个recognizer
属性,它可以接收一个GestureRecognizer
。
假设我们需要在点击时给文本变色:
import 'package:flutter/gestures.dart';
class _GestureRecognizer extends StatefulWidget {
const _GestureRecognizer({Key? key}) : super(key: key);
_GestureRecognizerState createState() => _GestureRecognizerState();
}
class _GestureRecognizerState extends State<_GestureRecognizer> {
TapGestureRecognizer _tapGestureRecognizer = TapGestureRecognizer();
bool _toggle = false; //变色开关
void dispose() {
//用到GestureRecognizer的话一定要调用其dispose方法释放资源
_tapGestureRecognizer.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Center(
child: Text.rich(
TextSpan(
children: [
TextSpan(text: "你好世界"),
TextSpan(
text: "点我变色",
style: TextStyle(
fontSize: 30.0,
color: _toggle ? Colors.blue : Colors.red,
),
recognizer: _tapGestureRecognizer
..onTap = () {
setState(() {
_toggle = !_toggle;
});
},
),
TextSpan(text: "你好世界"),
],
),
),
);
}
}
运行效果:
注意:使用GestureRecognizer
后一定要调用其dispose()
方法来释放资源(主要是取消内部的计时器)。
Flutter事件机制
Flutter 事件处理流程
Flutter 事件处理流程主要分两步,为了聚焦核心流程,我们以用户触摸事件为例来说明:
- 命中测试:当手指按下时,触发
PointerDownEvent
事件,按照深度优先遍历当前渲染(render object
)树,对每一个渲染对象进行“命中测试”(hit test
),如果命中测试通过,则该渲染对象会被添加到一个HitTestResult
列表当中。 - 事件分发:命中测试完毕后,会遍历
HitTestResult
列表,调用每一个渲染对象的事件处理方法(handleEvent
)来处理PointerDownEvent
事件,该过程称为“事件分发”(event dispatch
)。随后当手指移动时,便会分发PointerMoveEvent
事件。 - 事件清理:当手指抬(
PointerUpEvent
)起或事件取消时(PointerCancelEvent
),会先对相应的事件进行分发,分发完毕后会清空HitTestResult
列表。
需要注意:
- 命中测试是在
PointerDownEvent
事件触发时进行的,一个完成的事件流是down > move > up (cancle)
。 - 如果父子组件都监听了同一个事件,则子组件会比父组件先响应事件。这是因为命中测试过程是按照深度优先规则遍历的,所以子渲染对象会比父渲染对象先加入
HitTestResult
列表,又因为在事件分发时是从前到后遍历HitTestResult
列表的,所以子组件比父组件会更先被调用handleEvent
。
下面我们从代码层面看一些整个事件处理流程:
// 触发新事件时,flutter 会调用此方法
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent ) {
hitTestResult = HitTestResult();
// 发起命中测试
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
//获取命中测试的结果,然后移除它
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) { // PointerMoveEvent
//直接获取命中测试的结果
hitTestResult = _hitTests[event.pointer];
}
// 事件分发
if (hitTestResult != null) {
dispatchEvent(event, hitTestResult);
}
}
上面代码只是核心代码,完整的代码位于GestureBinding
实现中。下面我们分别来介绍一些命中测试和事件分发过程。
命中测试详解
1. 命中测试的起点
一个对象是否可以响应事件,取决于在其对命中测试过程中是否被添加到了 HitTestResult
列表 ,如果没有被添加进去,则后续的事件分发将不会分发给自己。下面我们看一下命中测试的过程:当发生用户事件时,Flutter 会从根节点(RenderView
)开始调用它的hitTest()
。
void hitTest(HitTestResult result, Offset position) {
//从根节点开始进行命中测试
renderView.hitTest(result, position: position);
// 会调用 GestureBinding 中的 hitTest()方法,我们将在下一节中介绍。
super.hitTest(result, position);
}
上面代码位于 RenderBinding
中,核心代码只有两行,整体命中测试分两步,我们来解释一下:
-
第一步:
renderView
是RenderView
对应的RenderObject
对象,RenderObject
对象的hitTest
方法主要功能是:从该节点出发,按照深度优先的顺序递归遍历子树(渲染树)上的每一个节点并对它们进行命中测试。这个过程称为“渲染树命中测试”。注意:为了表述方便,“渲染树命中测试”,也可以表述为组件树或节点树命中测试,只是我们需要知道,命中测试的逻辑都在
RenderObject
中,而并非在Widget
或Element
中。 -
第二步:渲染树命中测试完毕后,会调用
GestureBinding
的hitTest
方法,该方法主要用于处理手势,我们会在后面介绍。
2. 渲染树命中测试过程
渲染树的命中测试流程就是父节点 hitTest
方法中不断调用子节点 hitTest
方法的递归过程。
下面是RenderView
的hitTest()
源码:
// 发起命中测试,position 为事件触发的坐标(如果有的话)。
bool hitTest(HitTestResult result, { Offset position }) {
if (child != null)
child.hitTest(result, position: position); //递归对子树进行命中测试
//根节点会始终被添加到HitTestResult列表中
result.add(HitTestEntry(this));
return true;
}
因为 RenderView
只有一个孩子,所以直接调用child.hitTest
即可。如果一个渲染对象有多个子节点,则命中测试逻辑为:如果任意一个子节点通过了命中测试,或者当前节点“强行声明”自己通过了命中测试,则当前节点会通过命中测试。
我们以RenderBox
为例,看看它的hitTest()
实现:
bool hitTest(HitTestResult result, { Offset position }) {
...
if (_size.contains(position)) { // 判断事件的触发位置是否位于组件范围内
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
上面代码中:
hitTestChildren()
功能是判断是否有子节点通过了命中测试,如果有,则会将子组件添加到HitTestResult
中同时返回true
;如果没有则直接返回false
。该方法中会递归调用子组件的hitTest
方法。hitTestSelf()
决定自身是否通过命中测试,如果节点需要确保自身一定能响应事件可以重写此函数并返回true ,相当于“强行声明”自己通过了命中测试。
需要注意,节点通过命中测试的标志是它被添加到 HitTestResult
列表中,而不是它 hitTest
的返回值,虽然大多数情况下节点通过命中测试就会返回 true
,但是由于开发者在自定义组件时是可以重写 hitTest
的,所以有可能会在在通过命中测试时返回 false
,或者未通过命中测试时返回 true
,当然这样做并不好,我们在自定义组件时应该尽可能避免,但是在有些需要自定义命中测试流程的场景下可能就需要打破这种默契,比如我们将在后面实现的 HitTestBlocker
组件。
所以整体逻辑就是:
- 先判断事件的触发位置是否位于组件范围内,如果不是则不会通过命中测试,此时
hitTest
返回false
,如果是则到第二步。 - 会先调用
hitTestChildren()
判断是否有子节点通过命中测试,如果是,则将当前节点添加到HitTestResult
列表,此时hitTest
返回true
。即只要有子节点通过了命中测试,那么它的父节点(当前节点)也会通过命中测试。 - 如果没有子节点通过命中测试,则会取
hitTestSelf
方法的返回值,如果返回值为true
,则当前节点通过命中测试,反之则否。
如果当前节点有子节点通过了命中测试或者当前节点自己通过了命中测试,则将当前节点添加到 HitTestResult
中。又因为 hitTestChildren()
中会递归调用子组件的 hitTest
方法,所以组件树的命中测试顺序深度优先的,即如果通过命中测试,子组件会比父组件会先被加入HitTestResult
中。
我们看看这两个方法默认实现如下:
hitTestChildren(HitTestResult result, { Offset position }) => false;
bool hitTestSelf(Offset position) => false;
bool
可以看到这两个方法默认都返回了 false
,如果组件包含多个子组件,就必须重写 hitTestChildren()
方法,该方法中应该调用每一个子组件的 hitTest
方法,比如我们看看 RenderBoxContainerDefaultsMixin
中的实现:
// 子类的 hitTestChildren() 中会直接调用此方法
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
// 遍历所有子组件(子节点从后向前遍历)
ChildType? child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData! as ParentDataType;
// isHit 为当前子节点调用hitTest() 的返回值
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
//调用子组件的 hitTest方法,
hitTest: (BoxHitTestResult result, Offset? transformed) {
return child!.hitTest(result, position: transformed!);
},
);
// 一旦有一个子节点的 hitTest() 方法返回 true,则终止遍历,直接返回true
if (isHit) return true;
child = childParentData.previousSibling;
}
return false;
}
bool addWithPaintOffset({
required Offset? offset,
required Offset position,
required BoxHitTest hitTest,
}) {
...// 省略无关代码
final bool isHit = hitTest(this, transformedPosition);
return isHit; // 返回 hitTest 的执行结果
}
我们可以看到上面代码的主要逻辑是遍历调用子组件的 hitTest()
方法,同时提供了一种中断机制:即遍历过程中只要有子节点的 hitTest()
返回了 true
时:
-
会终止子节点遍历,这意味着该子节点前面的兄弟节点将没有机会通过命中测试。注意,兄弟节点的遍历是倒序的。
-
父节点也会通过命中测试。因为子节点
hitTest()
返回了true
导父节点hitTestChildren
也会返回true
,最终会导致 父节点的hitTest
返回true
,父节点被添加到HitTestResult
中。
当子节点的 hitTest()
返回了 false
时,继续遍历该子节点前面的兄弟节点,对它们进行命中测试,如果所有子节点都返回 false
时,则父节点会调用自身的 hitTestSelf
方法,如果该方法也返回 false
,则父节点就会被认为没有通过命中测试。
下面思考两个问题:
-
为什么要制定这个中断呢?
因为一般情况下兄弟节点占用的布局空间是不重合的,因此当用户点击的坐标位置只会有一个节点,所以一旦找到它后(通过了命中测试,
hitTest
返回true
),就没有必要再判断其他兄弟节点了。但是也有例外情况,比如在Stack
布局中,兄弟组件的布局空间会重叠,如果我们想让位于底部的组件也能响应事件,就得有一种机制,能让我们确保:即使找到了一个节点,也不应该终止遍历,也就是说所有的子组件的hitTest
方法都必须返回false
!为此,Flutter 中通过HitTestBehavior
来定制这个过程,这个我们会在后面介绍。 -
为什么兄弟节点的遍历要倒序?
同
1
中所述,兄弟节点一般不会重叠,而一旦发生重叠的话,往往是后面的组件会在前面组件之上,点击时应该是后面的组件会响应事件,而前面被遮住的组件不能响应,所以命中测试应该优先对后面的节点进行测试,因为一旦通过测试,就不会再继续遍历了。如果我们按照正向遍历,则会出现被遮住的组件能响应事件,而位于上面的组件反而不能,这明显不符合预期。
我们回到 hitTestChildren
上,如果不重写 hitTestChildren
,则默认直接返回 false
,这也就意味着后代节点将无法参与命中测试,相当于事件被拦截了,这也正是 IgnorePointer
和 AbsorbPointer
可以拦截事件下发的原理。
如果 hitTestSelf
返回 true
,则无论子节点中是否有通过命中测试的节点,当前节点自身都会被添加到 HitTestResult
中。因此 IgnorePointer
和 AbsorbPointer
的区别就是,前者的 hitTestSelf
返回了 false
,而后者返回了 true
。
命中测试完成后,所有通过命中测试的节点都被添加到了 HitTestResult
中。
事件分发
事件分发过程很简单,即遍历HitTestResult
,调用每一个节点的 handleEvent
方法:
// 事件分发
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
...
for (final HitTestEntry entry in hitTestResult.path) {
entry.target.handleEvent(event.transformed(entry.transform), entry);
}
}
所以组件只需要重写 handleEvent
方法就可以处理事件了。
HitTestBehavior
1. HitTestBehavior简介
我们先来实现一个能够监听 PointerDownEvent
的组件:
class PointerDownListener extends SingleChildRenderObjectWidget {
PointerDownListener({Key? key, this.onPointerDown, Widget? child})
: super(key: key, child: child);
final PointerDownEventListener? onPointerDown;
RenderObject createRenderObject(BuildContext context) =>
RenderPointerDownListener()..onPointerDown = onPointerDown;
void updateRenderObject(
BuildContext context, RenderPointerDownListener renderObject) {
renderObject.onPointerDown = onPointerDown;
}
}
class RenderPointerDownListener extends RenderProxyBox {
PointerDownEventListener? onPointerDown;
bool hitTestSelf(Offset position) => true; //始终通过命中测试
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
//事件分发时处理事件
if (event is PointerDownEvent) onPointerDown?.call(event);
}
}
因为我们让 hitTestSelf
的返回值始终为 true
,所以无论子节点是否通过命中测试,PointerDownListener
都会通过,所以后续分发事件时 handleEvent
就会被调用,我们在里面判断事件类型为 PointerDownEvent
时触发回调即可,测试代码如下:
class PointerDownListenerRoute extends StatelessWidget {
const PointerDownListenerRoute({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return PointerDownListener(
child: Text('Click me'),
onPointerDown: (e) => print('down'),
);
}
}
点击文本后控制台就会打印 ‘down
’。
Listener
的实现和 PointerDownListener
的实现原理差不多,有两点不同:
Listener
监听的事件类型更多一些。Listener
的hitTestSelf
并不是一直返回true
。
这里需要重点说一下第二点。 Listener
组件有一个 behavior
参数,我们之前并没有介绍,下面我们仔细介绍一下。通过查看 Listener
源码,发现它的渲染对象 RenderPointerListener
继承了 RenderProxyBoxWithHitTestBehavior
类:
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {
//[behavior] 的默认值为 [HitTestBehavior.deferToChild].
RenderProxyBoxWithHitTestBehavior({
this.behavior = HitTestBehavior.deferToChild,
RenderBox? child,
}) : super(child);
HitTestBehavior behavior;
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) //1
result.add(BoxHitTestEntry(this, position)); // 通过命中测试
}
return hitTarget;
}
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque; //2
}
我们看到 behavior
在 hitTest
和 hitTestSelf
中会使用,它的取值会影响 Listener
的命中测试结果。我们先看看 behavior
都有哪些取值:
//在命中测试过程中 Listener 组件如何表现。
enum HitTestBehavior {
// 组件是否通过命中测试取决于子组件是否通过命中测试
deferToChild,
// 组件必然会通过命中测试,同时其 hitTest 返回值始终为 true
opaque,
// 组件必然会通过命中测试,但其 hitTest 返回值可能为 true 也可能为 false
translucent,
}
它有三个取值,我们结合 hitTest
实现来分析一下不同取值的作用:
behavior
为deferToChild
时,hitTestSelf
返回false
,当前组件是否能通过命中测试完全取决于hitTestChildren
的返回值。也就是说只要有一个子节点通过命中测试,则当前组件便会通过命中测试。behavior
为opaque
时,hitTestSelf
返回true
,hitTarget
值始终为true
,当前组件通过命中测试。behavior
为translucent
时,hitTestSelf
返回false
,hitTarget
值此时取决于hitTestChildren
的返回值,但是无论hitTarget
值是什么,当前节点都会被添加到HitTestResult
中。
注意, behavior
为 opaque
和 translucent
时当前组件都会通过命中测试,它们的区别是 hitTest()
的返回值(hitTarget
)可能不同,所以它们的区别就看 hitTest()
的返回值会影响什么,这个我们已经在上面详细介绍过了,下面我们通过一个实例来理解一下。
2. 实例:实现 App 水印
效果如图:
实现思路是,在页面的最顶层覆盖一个水印遮罩,我们可以通过 Stack
来实现,将水印组件作为最后一个孩子传给Stack
:
class WaterMaskTest extends StatelessWidget {
const WaterMaskTest({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1, Colors.white, 200),
WaterMark(
painter: TextWaterMarkPainter(text: 'wendux', rotate: -20),
),
],
);
}
Widget wChild(int index, color, double size) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
WaterMark
是实现水印的组件,具体逻辑我们将在后面介绍,现在只需知道 WaterMark
中使用了 DecoratedBox
。效果是实现了,但是我们点击 Stack
的第一个子组件(灰色矩形区域)时发现控制台没有任何输出,这是不符合预期的,原因是水印组件在最顶层,事件被它 “拦住了”,我们分析一下这个过程:
-
点击时,
Stack
有两个子组件,这是会先对第二个子组件(水印组件)进行点击测试,由于水印组件中使用了DecoratedBox
,查看源码后发现如果用户点击位置在DecoratedBox
上,它的hitTestSelf
就会返回true
,所以水印组件通过命中测试。 -
水印组件通过命中测试后就会导致
Stack
的hitTestChildren()
直接返回(终止遍历其他子节点),所以Stack
的第一个子组件将不会参与命中测试,因此也就不会响应事件。
原因找到了,解决的方法就是想办法让第一个子组件也能参与命中测试,这样的话,我们就得想办法让第二个子组件的 hitTest
返回 false
即可。因此我们可以用 IgnorePointer
包裹一下 WaterMask
即可。
IgnorePointer(child: WaterMark(...))
修改后,重新运行,发现第一个子组件可以响应事件了。
如果我们想让 Stack
的所有子组件都响应事件,应该如何实现呢?当然,这很可能是一个伪需求,现实的场景中几乎不会遇到,但考虑这个问题可以加深我们对 Flutter 事件处理流程的理解。
class StackEventTest extends StatelessWidget {
const StackEventTest({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1),
wChild(2),
],
);
}
Widget wChild(int index) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: 100,
height: 100,
color: Colors.grey,
),
);
}
}
运行后,点击灰色框,猜猜控制台会打印什么?
- 答案是只会打印一个 ‘
2
’,原因是,Stack
先遍历第二个子节点Listener
,因为Container
的hitTest
会返回true
( 实际上Container
是一个组合组件,本示例中,Container
最终会生成一个ColoredBox
,而参与命中测试的是ColoredBox
对应的RenderObject
),所以Listener
的hitTestChildren
会返回true
,最终Listener
的hitTest
也会返回true
,所以第一个子节点将不会收到事件。
那如果我们将 Listener
的 behavior
属性指定为 opaque
或 translucent
呢?其实结果还是一样的,因为只要 Container
的 hitTest
会返回 true
, 最终 Listener
的 hitTestChildren
就会返回 true
,第一个节点就不会再进行命中测试。
那 opaque
和 translucent
能体现出差异的具体场景有什么呢?理论上只有 Listener
的子节点 hitTest
返回 false
时两者才有区别,但是 Flutter 中有 UI 的组件都会在用户点击到它之上时,它的hitTest
基本都会返回 true
,因此很难找到具体场景,但是为了测试它们的区别,我们可以强行制造一个场景,比如下面代码:
class HitTestBehaviorTest extends StatelessWidget {
const HitTestBehaviorTest({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1),
wChild(2),
],
);
}
Widget wChild(int index) {
return Listener(
//behavior: HitTestBehavior.opaque, // 放开此行,点击只会输出 2
behavior: HitTestBehavior.translucent, // 放开此行,点击会同时输出 2 和 1
onPointerDown: (e) => print(index),
child: SizedBox.expand(),
);
}
}
SizedBox
没有子元素,当它被点击时,它的 hitTest
就会返回 false
,此时 Listener
的 behavior
设置为 opaque
和translucent
就会有区别(见注释)。
因为实际场景中几乎不会出现上面这样的类似的 case,所以如果想让 Stack
的所有子组件都响应事件就必须保证 Stack
的所有孩子的 hitTest
返回 false
,虽然用 IgnorePointer
包裹所有子组件就可以做到这一点,但是 IgnorePointer
也同时不会再对子组件进行命中测试,这意味着它的子组件树也将不能响应事件,比如下面的代码运行后,点击灰色区域将不会有任何输出:
class AllChildrenCanResponseEvent extends StatelessWidget {
const AllChildrenCanResponseEvent({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
IgnorePointer(child: wChild(1, 200)),
IgnorePointer(child: wChild(2, 200)),
],
);
}
Widget wChild(int index, double size) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
虽然我们在子节点中通过监听了 Container
的事件,但是子节点是在 IgnorePointer
中的,所以子节点是没有机会参与命中测试的,所以不会响应任何事件。看来没有现成的组件可以满足要求,那我们就自己动手实现一个组件然后来定制它的 hitTest
来满足我们的要求即可。
3. HitTestBlocker
下面我们定义一个可以拦截 hitTest
各个过程的 HitTestBlocker
组件。
class HitTestBlocker extends SingleChildRenderObjectWidget {
HitTestBlocker({
Key? key,
this.up = true,
this.down = false,
this.self = false,
Widget? child,
}) : super(key: key, child: child);
/// up 为 true 时 , `hitTest()` 将会一直返回 false.
final bool up;
/// down 为 true 时, 将不会调用 `hitTestChildren()`.
final bool down;
/// `hitTestSelf` 的返回值
final bool self;
RenderObject createRenderObject(BuildContext context) {
return RenderHitTestBlocker(up: up, down: down, self: self);
}
void updateRenderObject(BuildContext context, RenderHitTestBlocker renderObject) {
renderObject
..up = up
..down = down
..self = self;
}
}
class RenderHitTestBlocker extends RenderProxyBox {
RenderHitTestBlocker({this.up = true, this.down = true, this.self = true});
bool up;
bool down;
bool self;
bool hitTest(BoxHitTestResult result, {required Offset position}) {
bool hitTestDownResult = false;
if (!down) {
hitTestDownResult = hitTestChildren(result, position: position);
}
bool pass = hitTestSelf(position) || (hitTestDownResult && size.contains(position));
if (pass) {
result.add(BoxHitTestEntry(this, position));
}
return !up && pass;
}
bool hitTestSelf(Offset position) => self;
}
我们用 HitTestBlocker
直接替换 IgnorePointer
就可以实现所有子组件都可以响应事件了,代码如下:
Widget build(BuildContext context) {
return Stack(
children: [
// IgnorePointer(child: wChild(1, 200)),
// IgnorePointer(child: wChild(2, 200)),
HitTestBlocker(child: wChild(1, 200)),
HitTestBlocker(child: wChild(2, 200)),
],
);
}
点击后,控制台会同时输出 2
和 1
,原理也很简单 :
-
HitTestBlocker
的hitTest
会返回false
,这可以保证Stack
的所有的子节点都能参与命中测试; -
HitTestBlocker
的hitTest
中又会调用hitTestChildren
,所以HitTestBlocker
的后代节点是有机会参与命中测试,所以Container
上的事件会被正常触发。
HitTestBlocker
是一个非常灵活的类,它可以拦截命中测试的各个阶段,通过 HitTestBlocker
完全可以实现IgnorePointer
和 AbsorbPointer
的功能, 比如当 HitTestBlocker
的 up
和 down
都为 true
时,功能和 IgnorePointer
相同。
4. 手势存在的情况
我们稍微修改一下上面的代码, 将 Listener
换为 GestureDetector
, 代码如下:
class GestureHitTestBlockerTest extends StatelessWidget {
const GestureHitTestBlockerTest({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
HitTestBlocker(child: wChild(1, 200)),
HitTestBlocker(child: wChild(2, 200)),
],
);
}
Widget wChild(int index, double size) {
return GestureDetector( // 将 Listener 换为 GestureDetector
onTap: () => print('$index'),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
可以猜一下点击后会输出什么?答案是只会输出 2
!这是因为虽然 Stack
的两个子组件都会参与且通过命中测试,但是 GestureDetector
会在事件分发阶段来决定是否响应事件(而不是命中测试阶段),GestureDetector
有一套单独的处理手势冲突的机制,这个我们将在下文中介绍。
总结:
- 组件只有通过命中测试才能响应事件。
- 一个组件是否通过命中测试取决于
hitTestChildren(...) || hitTestSelf(...)
的值。 - 组件树中组件的命中测试顺序是深度优先的。
- 组件子节点命中测试的循序是倒序的,并且一旦有一个子节点的
hitTest
返回了true
,就会终止遍历,后续子节点将没有机会参与命中测试。这个原则可以结合Stack
组件来理解。 - 大多数情况下
Listener
的HitTestBehavior
为opaque
或translucent
效果是相同的,只有当其子节点的hitTest
返回为false
时才会有区别。 HitTestBlocker
是一个很灵活的组件,我们可以通过它干涉命中测试的各个阶段。
手势原理与手势冲突
手势识别原理
手势的识别和处理都是在事件分发阶段的,GestureDetector
是一个 StatelessWidget
, 包含了 RawGestureDetector
,我们看一下它的 build
方法实现:
Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{};
// 构建 TapGestureRecognizer
if (onTapDown != null ||
onTapUp != null ||
onTap != null ||
... //省略
) {
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
..onTap = onTap
//省略
},
);
}
return RawGestureDetector(
gestures: gestures, // 传入手势识别器
behavior: behavior, // 同 Listener 中的 HitTestBehavior
child: child,
);
}
注意,上面我们删除了很多代码,只保留了 TapGestureRecognizer
(点击手势识别器) 相关代码,我们以点击手势识别为例讲一下整个过程。RawGestureDetector
中会通过 Listener
组件监听 PointerDownEvent
事件,相关源码如下:
Widget build(BuildContext context) {
... // 省略无关代码
Widget result = Listener(
onPointerDown: _handlePointerDown,
behavior: widget.behavior ?? _defaultBehavior,
child: widget.child,
);
}
void _handlePointerDown(PointerDownEvent event) {
for (final GestureRecognizer recognizer in _recognizers!.values)
recognizer.addPointer(event);
}
下面我们看一下 TapGestureRecognizer
的几个相关方法,由于 TapGestureRecognizer
有多层继承关系,这里合并了一个简化版:
class CustomTapGestureRecognizer1 extends TapGestureRecognizer {
void addPointer(PointerDownEvent event) {
//会将 handleEvent 回调添加到 pointerRouter 中
GestureBinding.instance!.pointerRouter.addRoute(event.pointer, handleEvent);
}
void handleEvent(PointerEvent event) {
//会进行手势识别,并决定是是调用 acceptGesture 还是 rejectGesture,
}
void acceptGesture(int pointer) {
// 竞争胜出会调用
}
void rejectGesture(int pointer) {
// 竞争失败会调用
}
}
可以看到当 PointerDownEvent
事件触发时,会调用 TapGestureRecognizer
的 addPointer
,在 addPointer
中会将 handleEvent
方法添加到 pointerRouter
中保存起来。这样一来当手势发生变化时只需要在 pointerRouter
中取出 GestureRecognizer
的 handleEvent
方法进行手势识别即可。
正常情况下应该是手势直接作用的对象应该来处理手势,所以一个简单的原则就是同一个手势应该只有一个手势识别器生效,为此,手势识别才映入了 手势竞技场(Arena) 的概念,简单来讲:
-
每一个手势识别器(
GestureRecognizer
)都是一个“竞争者”(GestureArenaMember
),当发生指针事件时,他们都要在“竞技场”去竞争本次事件的处理权,默认情况最终只有一个“竞争者”会胜出(win)。 -
GestureRecognizer
的handleEvent
中会识别手势,如果手势发生了某个手势,竞争者可以宣布自己是否胜出,一旦有一个竞争者胜出,竞技场管理者(GestureArenaManager
)就会通知其他竞争者失败。 -
胜出者的
acceptGesture
会被调用,其余的rejectGesture
将会被调用。
上一节我们说过命中测试是从 RenderBinding
的 hitTest
开始的:
void hitTest(HitTestResult result, Offset position) {
// 从根节点开始进行命中测试
renderView.hitTest(result, position: position);
// 会调用 GestureBinding 中的 hitTest()方法
super.hitTest(result, position);
}
渲染树命中测试完成后会调用 GestureBinding
中的 hitTest()
方法:
// from HitTestable
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}
很简单, GestureBinding
也通过命中测试了,这样的话在事件分发阶段,GestureBinding
的 handleEvent
便也会被调用,由于它是最后被添加到 HitTestResult
中的,所以在事件分发阶段 GestureBinding
的 handleEvent
会在最后被调用:
void handleEvent(PointerEvent event, HitTestEntry entry) {
// 会调用在 pointerRouter 中添加的 GestureRecognizer 的 handleEvent
pointerRouter.route(event);
if (event is PointerDownEvent) {
// 分发完毕后,关闭竞技场
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
}
}
gestureArena
是 GestureArenaManager
类实例,负责管理竞技场。
上面关键的代码就是第一行,功能是会调用之前在 pointerRouter
中添加的 GestureRecognizer
的 handleEvent
,不同 GestureRecognizer
的 handleEvent
会识别不同的手势,然后它会和 gestureArena
交互(如果当前的 GestureRecognizer
胜出,需要 gestureArena
去通知其他竞争者它们失败了),最终,如果当前 GestureRecognizer
胜出,则最终它的 acceptGesture
会被调用,如果失败则其 rejectGesture
将会被调用,因为这部分代码不同的 GestureRecognizer
会不同,知道做了什么就行,如有兴趣可以自行查看源码。
手势竞争
如果对一个组件同时监听水平和垂直方向的拖动手势,当我们斜着拖动时哪个方向的拖动手势回调会被触发?实际上取决于第一次移动时两个轴上的位移分量,哪个轴的大,哪个轴在本次滑动事件竞争中就胜出。上面已经说过,每一个手势识别器(GestureRecognizer
)都是一个“竞争者”(GestureArenaMember
),当发生指针事件时,他们都要在“竞技场”去竞争本次事件的处理权,默认情况最终只有一个“竞争者”会胜出(win)。
例如,假设有一个ListView
,它的第一个子组件也是ListView
,如果现在滑动这个子ListView
,父ListView
会动吗?答案是否定的,这时只有子ListView
会动,因为这时子ListView
会胜出而获得滑动事件的处理权。
下面我们看一个简单的例子:
GestureDetector( // GestureDetector2
onTapUp: (x)=>print("2"), // 监听父组件 tapUp 手势
child: Container(
width:200,
height: 200,
color: Colors.red,
alignment: Alignment.center,
child: GestureDetector( //GestureDetector1
onTapUp: (x)=>print("1"), // 监听子组件 tapUp 手势
child: Container(
width: 50,
height: 50,
color: Colors.grey,
),
),
),
);
当我们点击子组件(灰色区域)时,控制台只会打印 “1
”,并不会打印 “2
”,这是因为手指抬起后,GestureDetector1
和 GestureDetector
2 会发生竞争,判定获胜的规则是“子组件优先”,所以 GestureDetector1
获胜,因为只能有一个“竞争者”胜出,所以 GestureDetector
2 将被忽略。这个例子中想要解决冲突的方法很简单,将 GestureDetector
换为 Listener
即可,具体原因我们在后面解释。
我们再看一个例子,我们以拖动手势为例,同时识别水平和垂直方向的拖动手势,当用户按下手指时就会触发竞争(水平方向和垂直方向),一旦某个方向“获胜”,则直到当次拖动手势结束都会沿着该方向移动。代码如下:
class _BothDirectionTest extends StatefulWidget {
_BothDirectionTestState createState() => _BothDirectionTestState();
}
class _BothDirectionTestState extends State<_BothDirectionTest> {
double _top = 0.0;
double _left = 0.0;
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
//垂直方向拖动事件
onVerticalDragUpdate: (DragUpdateDetails details) {
setState(() {
_top += details.delta.dy;
});
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
),
)
],
);
}
}
此示例运行后,每次拖动只会沿一个方向移动(水平或垂直),而竞争发生在手指按下后首次移动(move)时,此例中具体的“获胜”条件是:首次移动时的位移在水平和垂直方向上的分量大的一个获胜。
多手势冲突
由于手势竞争最终只有一个胜出者,所以,当我们通过一个 GestureDetector
监听多种手势时,也可能会产生冲突。假设有一个widget
,它可以左右拖动,现在我们也想检测在它上面手指按下和抬起的事件,代码如下:
class GestureConflictTestRouteState extends State<GestureConflictTestRoute> {
double _left = 0.0;
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")), // 要拖动和点击的widget
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
onHorizontalDragEnd: (details){
print("onHorizontalDragEnd");
},
onTapDown: (details){
print("down");
},
onTapUp: (details){
print("up");
},
),
)
],
);
}
}
现在我们按住圆形“A”拖动然后抬起手指,控制台日志如下:
I/flutter (17539): down
I/flutter (17539): onHorizontalDragEnd
我们发现没有打印"up
",这是因为在拖动时,刚开始按下手指且没有移动时,拖动手势还没有完整的语义,此时TapDown
手势胜出(win),此时打印"down
",而拖动时,拖动手势会胜出,当手指抬起时,onHorizontalDragEnd
和 onTapUp
发生了冲突,但是因为是在拖动的语义中,所以onHorizontalDragEnd
胜出,所以就会打印 “onHorizontalDragEnd
”。
如果我们的代码逻辑中,对于手指按下和抬起是强依赖的,比如在一个轮播图组件中,我们希望手指按下时,暂停轮播,而抬起时恢复轮播,但是由于轮播图组件中本身可能已经处理了拖动手势(如支持手动滑动切换),甚至可能也支持了缩放手势,这时我们如果在外部再用onTapDown
、onTapUp
来监听的话是不行的。这时我们应该怎么做?其实很简单,通过Listener
监听原始指针事件就行:
Positioned(
top:80.0,
left: _leftB,
child: Listener(
onPointerDown: (details) {
print("down");
},
onPointerUp: (details) {
//会触发
print("up");
},
child: GestureDetector(
child: CircleAvatar(child: Text("B")),
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_leftB += details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
print("onHorizontalDragEnd");
},
),
),
)
解决手势冲突
手势是对原始指针的语义化的识别,手势冲突只是手势级别的,也就是说只会在组件树中的多个 GestureDetector
之间才有冲突的场景,如果压根就没有使用 GestureDetector
则不存在所谓的冲突,因为每一个节点都能收到事件,只是在 GestureDetector
中为了识别语义,它会去决定哪些子节点应该忽略事件,哪些节点应该生效。
解决手势冲突的方法有两种:
-
使用
Listener
。这相当于跳出了手势识别那套规则。 -
自定义手势手势识别器(
Recognizer
)。
1. 通过 Listener 解决手势冲突
通过 Listener
解决手势冲突的原因是竞争只是针对语义化的手势, 而Listener
是监听原始指针事件,原始指针事件并非语义化的手势,所以根本不会走手势竞争的逻辑,所以也就不会相互影响。拿上面两个 Container
嵌套的例子来说,通过Listener
的解决方式为:
Listener( // 将 GestureDetector 换为 Listener 即可
onPointerUp: (x) => print("2"),
child: Container(
width: 200,
height: 200,
color: Colors.red,
alignment: Alignment.center,
child: GestureDetector(
onTap: () => print("1"),
child: Container(
width: 50,
height: 50,
color: Colors.grey,
),
),
),
);
代码很简单,只需将 GestureDetector
换为 Listener
即可,可以两个都换,也可以只换一个。可以看见,通过Listener
直接识别原始指针事件来解决冲突的方法很简单,因此,当遇到手势冲突时,我们应该优先考虑 Listener
。
2. 通过自定义 Recognizer 解决手势冲突
自定义手势识别器的方式比较麻烦,原理是当确定手势竞争胜出者时,会调用胜出者的acceptGesture
方法,表示“宣布成功”,然后会调用其他手势识别其的rejectGesture
方法,表示“宣布失败”。
既然如此,我们可以 自定义手势识别器(Recognizer
),然后去重写它的rejectGesture
方法:在里面调用 acceptGesture
方法,这就相当于在它失败时强制将它也变成成功的竞争者了,这样它的回调也就会执行。(属于是比较流氓的做法了)
我们先自定义tap
手势识别器(Recognizer
):
class CustomTapGestureRecognizer extends TapGestureRecognizer {
void rejectGesture(int pointer) {
//super.rejectGesture(pointer); // 不,我不要失败,我要成功
super.acceptGesture(pointer); // 宣布成功
}
}
// 创建一个新的GestureDetector,用我们自定义的 CustomTapGestureRecognizer 替换默认的
RawGestureDetector customGestureDetector({
GestureTapCallback? onTap,
GestureTapDownCallback? onTapDown,
Widget? child,
}) {
return RawGestureDetector(
child: child,
gestures: {
CustomTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTapGestureRecognizer>(
() => CustomTapGestureRecognizer(),
(detector) {
detector.onTap = onTap;
},
)
},
);
}
我们通过 RawGestureDetector
来自定义 customGestureDetector
,GestureDetector
中也是通过 RawGestureDetector
来包装各种Recognizer
来实现的,我们需要自定义哪个 Recognizer
,就添加哪个即可。
现在我们看看修改调用代码:
customGestureDetector( // 替换 GestureDetector
onTap: () => print("2"),
child: Container(
width: 200,
height: 200,
color: Colors.red,
alignment: Alignment.center,
child: GestureDetector(
onTap: () => print("1"),
child: Container(
width: 50,
height: 50,
color: Colors.grey,
),
),
),
);
这样就 OK 了,需要注意,这个例子同时说明了一次手势处理过程也是可以有多个胜出者的。
Gesture源码分析
具体地说,Flutter需要解决的问题有两个:一是如何确定处理手势的Widget(准确来说是RenderObject
);二是确定响应何种手势,最典型的就是单击和双击的区分。
与Flutter手势处理相关的关键类及其关系如图所示:
在图8-7中,GestureDetector
是开发者响应手势事件的入口,该Widget
对应的底层绘制节点(RenderObject
)为RenderPointerListener
,此类间接实现了HitTestTarget
接口,即该类是一个可以进行单击测试的目标。通过实现HitTestTarget
的handleEvent
方法,RenderPointerListener
将参与手势竞技场(_GestureArena
)内的手势竞争。
具体来说,在创建RenderPointerListener
的过程中,RawGestureDetectorState
会根据开发者提供的回调参数创建对应的GestureRecognizer
实例,而GestureRecognizer
又继承自GestureArenaMember
,该类被_GestureArena
持有,是手势竞争的统一抽象表示。
GestureRecognizer
的子类众多,详见图8-8。其中,OneSequenceGestureRecognizer
是开发者最常接触的GestureRecognizer
。
_GestureArena
负责管理一个手势竞技场内的各个成员(GestureArenaMember
),GestureArenaManager
负责管理所有的手势竞技场。因此,GestureArenaManager
的实例全局只需要一个,由GestureBinding
持有。GestureBinding
同时也是处理Engine发送的手势事件的入口,它通过_hitTests
字段持有一次手势事件的单击测试结果(HitTestResult
),每个单击测试结果其实是一个HitTestEntry
对象的列表,HitTestEntry
和HitTestTarget
一一对应,而后者正是前面提到的RenderPointerListener
。如此便完成了UI元素(GestureDetector
)到手势竞争模型(GestureArenaMember
等类)的闭环。
GestureRecognizer
是所有手势处理器的基类,由图8-7可知,GestureRecognizer
继承自GestureArenaMember
,将作为手势竞争的基本单位。GestureRecognizer
的子类众多,各子类负责实现对应事件的识别。OneSequenceGestureRecognizer
表示一次性手势,比如单击(TapGestureRecognizer
)、长按(LongPressGestureRecognizer
)、拖曳(DragGestureRecognizer
)等;双击(DoubleTapGestureRecognizer
)是一个非常特殊的手势事件,后面将详细分析;MultiDragGestureRecognizer
则表示更复杂的手势事件(如双指缩放),本文将不做深入分析。
手势处理主要分为两个阶段:第 1 阶段是目标(HitTestTarget
)的收集;第 2 阶段是手势的竞争。 下面依次分析。
目标收集
单击事件由Embedder生成,通过Engine的转换变成统一的数据交给Flutter处理,其在Framework中的处理入口为_handlePointerEventImmediately
,如代码清单8-45所示。
// 代码清单8-45 flutter/packages/flutter/lib/src/gestures/binding.dart
void _handlePointerEventImmediately(PointerEvent event) { // GestureBinding
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is
PointerHoverEvent) {
hitTestResult = HitTestResult(); // 第1类事件,开始形成一个手势的事件类型
hitTest(hitTestResult, event.position); // 单击测试,即收集那些可以响应本次单击的实例
if (event is PointerDownEvent) { // PointerDown类型的事件,通常是一个手势的开始
_hitTests[event.pointer] = hitTestResult; // 存储单击测试结果,以备后续使用
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
// 第2类事件,根据event pointer取得第1类事件获得的Hit TestResult,并将其移除
hitTestResult = _hitTests.remove(event.pointer);
// 接收到手势结束的事件,移除本次结果
} else if (event.down) { // 第3类事件,其他处于down类型的事件,如滑动、鼠标拖曳等
hitTestResult = _hitTests[event.pointer]; // 取出形成手势时存储的单击测试结果
}
if (hitTestResult != null || event is PointerAddedEvent || event is Pointer RemovedEvent) {
dispatchEvent(event, hitTestResult); // 向可响应手势的集合分发本次事件,见代码清单8-47
}
}
以上逻辑对不同事件采取不同的策略。对于第1类事件,会尝试收集一个单击测试的结果列表(HitTestResult
的path
字段),记录当前哪些对象响应了本次单击。对于第2类事件,将直接根据event.pointer
取出第1种事件所获得的HitTestResult
,并将其移除。对于第3类事件,则认为是前两类的中间状态,直接取出单击测试结果并使用即可。
对于hitTestResult
不为null
的情况,会尝试分发事件,将在后面详细介绍。在此,首先分析hitTest
方法的逻辑。其中,GestureBinding、RendererBinding、RenderView
和RenderBox
的实现尤为关键,如代码清单8-46所示。
// 代码清单8-46 flutter/packages/flutter/lib/src/gestures/binding.dart
// GestureBinding
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}
// RendererBinding
void hitTest(HitTestResult result, Offset position) {
renderView.hitTest(result, position: position); // 触发Render Tree的根节点
super.hitTest(result, position); // 将导致执行GestureBinding的hitTest方法
}
bool hitTest(HitTestResult result, { required Offset position }) { // RenderView
if (child != null) // Render Tree的根节点将触发子节点的hitTest方法
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this)); // 最后将自身加入单击测试结果
return true;
}
bool hitTest(BoxHitTestResult result, { required Offset position }) { // RenderBox
if (_size!.contains(position)) { // 单击位置是否在当前Layout的范围内,这是必要条件
if (hitTestChildren(result, position: position) || // 子节点通过了单击测试
hitTestSelf(position)) { // 自身通过了单击测试,这是充分条件
result.add(BoxHitTestEntry(this, position)); // 生成一个单击测试入口,加入结果
return true;
}
}
return false;
}
考虑到继承关系,RendererBinding
的hitTest
首先会执行,其逻辑主要是执行renderView
的hitTest
方法,而renderView
作为Render Tree的根节点,会遍历每个节点进行单击测试,RenderBox
的hitTest
方法最为典型,它将递归地对每个子节点和自身进行单击测试,然后依次加入队列。注意,GestureBinding
始终都会作为最后一个元素加入队列,这对后面的手势竞争非常关键。
注意,以上逻辑中,单击位置在RenderBox
的Layout
范围内并非可以加入单击测试结果的充分条件,通常还需要自身的hitTestSelf
方法返回true
,这为RenderBox
的子类提供了一个自由决定是否参与后续手势竞争的入口。
手势竞争
在获取所有可以响应单击的对象(存储于HitTestResult
)后,GestureBinding
会触发dispatchEvent
方法,完成本次事件的分发,其逻辑如代码清单8-47所示。
// 代码清单8-47 flutter/packages/flutter/lib/src/gestures/binding.dart
// GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult == null) { // 说明是PointerHoverEvent、PointerAddedEvent
try { // 或者PointerRemovedEvent,在此统一路由分发,其他情况通过handleEvent方法处理
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) { ...... }
}
}
对于hitTestResult
不为null
的情况,会依次调用每个HitTestTarget
对象的handleEvent
方法,需要处理手势的HitTestTarget
子类通过实现该方法就能够参与手势竞争,并在赢得竞争后处理手势。
日常开发中常用的GestureDetector
内部使用了RenderPointerListener
,该类实现了handleEvent
方法,并承担了手势的分发。此外GestureBinding
作为手势的核心调度类和最后一个HitTestTarget
,也实现了该类,如代码清单8-48所示。
// 代码清单8-48 flutter/packages/flutter/lib/src/rendering/proxy_box.dart
// from RenderPointerListener
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent)
return onPointerDown?.call(event); // 最终在代码清单8-49中进行调用
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
// SKIP PointerUpEvent、PointerCancelEvent等事件
}
// from GestureBinding
void handleEvent(PointerEvent event, HitTestEntry entry) {
pointerRouter.route(event); // 无条件路由给已注册成员,注册逻辑见代码清单8-54
if (event is PointerDownEvent) {
gestureArena.close(event.pointer); // 关闭
} else if (event is PointerUpEvent) {
gestureArena.sweep(event.pointer); // 清理
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event); // 解析
}
}
GestureBinding
包含一个重要的成员——gestureArena
,它负责管理所有的手势竞争,称为手势竞技场。RenderPointerListener
会在自身的handleEvent
过程中完成手势竞技场成员(GestureArenaMember
)的生成与注册,接下来以普通单击事件为例进行分析。
以上逻辑中,onPointerDown
在创建Listener
对象时引入,其本质是RawGestureDetectorState
的一个方法,如代码清单8-49所示。
// 代码清单8-49 flutter/packages/flutter/lib/src/widgets/gesture_detector.dart
void _handlePointerDown(PointerDownEvent event) { // RawGestureDetectorState
for (final GestureRecognizer recognizer in _recognizers!.values)
recognizer.addPointer(event); // 成功将事件从HitTestTarget传递到GestureRecognizer
}
void addPointer(PointerDownEvent event) { // GestureRecognizer
_pointerToKind[event.pointer] = event.kind;
if (isPointerAllowed(event)) { // 通常为true
addAllowedPointer(event);
} else {
handleNonAllowedPointer(event);
}
}
void addAllowedPointer(PointerDownEvent event) { } // 子类实现
GestureDetector
提供了onTap
、onDoubleTap
等各种参数,其内部会转换为GestureRecognizer
的各种子类,并加入_recognizers
字段中。
一次单击(onTap
)事件可以拆分为一次PointerDownEvent
和一次PointerUpEvent
,PointerDownEvent
将触发以上逻辑,onTap
对应的手势识别类为TapGestureRecognizer
,其addAllowedPointer
最终将通过startTrackingPointer
调用_addPointerToArena
方法,如代码清单8-50所示。
// 代码清单8-50 flutter/packages/flutter/lib/src/gestures/recognizer.dart
GestureArenaEntry _addPointerToArena(int pointer) { // OneSequenceGestureRecognizer
if (_team != null) // 当前recognizer隶属于某一GestureArenaTeam对象
return _team!.add(pointer, this); // 暂不考虑这种情况
return GestureBinding.instance!.gestureArena.add(pointer, this); // 加入手势竞技场
}
以上逻辑最终调用了GestureBinding
的gestureArena
成员的add
方法,gestureArena
全局只有一个,其具体逻辑如代码清单8-51所示。
// 代码清单8-51 flutter/packages/flutter/lib/src/gestures/arena.dart
GestureArenaEntry add(int pointer, GestureArenaMember member) { // GestureArenaManager
final _GestureArena state = _arenas.putIfAbsent(pointer, () {
return _GestureArena(); // 产生一个手势竞技场
});
state.add(member); // 加入当前手势竞技场
return GestureArenaEntry._(this, pointer, member);
}
_GestureArena
实例表示一个具体的竞技场,如果当前不存在则会新建一个,然后将当前GestureArenaMember
加入。如果嵌套使用多个GestureDetector
,那么会依次加入多个GestureRecognizer
。
无论前面的逻辑如何,最后都会调用GestureBinding
的handleEvent
逻辑,因为它是最后一个加入单击测试结果(HitTestResult
)列表的,如代码清单8-48所示。如果是PointerDownEvent
事件,则会关闭竞技场,因为前面的HitTestTarget
已经完成GestureArenaMember
的添加工作;如果是PointerUpEvent
事件,则清理竞技场,因为手势此时已经结束了(后面将分析双击事件这种特殊情况)。
这个阶段需要解决一个关键问题——当存在多个GestureArenaMember
(通常是TapGestureRecognizer
)时,由谁来响应。
首先分析竞技场的关闭,如代码清单8-52所示。
// 代码清单8-52 flutter/packages/flutter/lib/src/gestures/arena.dart
void close(int pointer) { // GestureArenaManager,关闭pointer对应的手势竞技场
final _GestureArena? state = _arenas[pointer];
if (state == null) return;
state.isOpen = false; // 标记关闭
_tryToResolveArena(pointer, state); // 决出竞技场内的胜者
}
void _tryToResolveArena(int pointer, _GestureArena state) {
if (state.members.length == 1) { // 只有一个成员,直接决出胜者
scheduleMicrotask(() => _resolveByDefault(pointer, state));
} else if (state.members.isEmpty) { // 没有成员,移除当前手势竞技场
_arenas.remove(pointer);
} else if (state.eagerWinner != null) { // 存在eagerWinner,定为胜者
_resolveInFavorOf(pointer, state, state.eagerWinner!);
}
}
void _resolveByDefault(int pointer, _GestureArena state) {
if (!_arenas.containsKey(pointer)) return; // 已被移除
final List<GestureArenaMember> members = state.members;
state.members.first.acceptGesture(pointer); // 直接取第1个成员作为胜者
}
以上逻辑主要是手势竞技场的关闭,在关闭阶段将尝试决出手势竞技场的胜者。以图8-9中的 Case1 为例,A 在竞技场关闭阶段作为胜者响应单击事件。至于 Case2 和 Case3,后面将详细分析。
在关闭竞技场,PointerUp
事件到来时,将会开始清理手势竞技场,如代码清单8-53 所示。
// 代码清单8-53 flutter/packages/flutter/lib/src/gestures/arena.dart
void sweep(int pointer) { // GestureArenaManager,清理pointer对应的手势竞技场
final _GestureArena? state = _arenas[pointer];
if (state == null) return; // 已移除,避免重复处理
if (state.isHeld) { // 被挂起,直接返回
state.hasPendingSweep = true;
return;
}
_arenas.remove(pointer); // 移除pointer对应的手势竞技场
if (state.members.isNotEmpty) {
state.members.first.acceptGesture(pointer); // 取第1个成员作为胜者,与_resolve
// ByDefault一致
for (int i = 1; i < state.members.length; i++)
state.members[i].rejectGesture(pointer); // 触发败者的rejectGesture方法
}
}
以上逻辑主要是清理手势竞技场,如果被挂起,则直接返回。对于未被挂起的情况,由代码清单8-46的分析可知,将取第1个元素,也就是RenderBox
中最里面的元素,这符合我们的开发经验。
对于只有一个成员的情况,手势竞技场将在close
阶段直接决出胜者,而如果存在多个成员,手势竞技场将在sweep
阶段(如果未被挂起)取第一个成员作为胜者。
双击事件
分析至此,可以发现对于普通的单击事件,以上逻辑是完全能够处理的,但对于双击事件呢?按照以上逻辑,第2次单击开始前,第1次单击就被当作一个单独的单击事件处理掉了。解决的玄机正是在isHeld
字段中,下面开始详细分析。
由代码清单8-49可知,双击事件将会触发DoubleTapGestureRecognizer
的addAllowedPointer
方法,该方法会调用_trackTap
方法,如代码清单8-54所示。
// 代码清单8-54 flutter/packages/flutter/lib/src/gestures/multitap.dart
// DoubleTapGestureRecognizer
void addAllowedPointer(PointerDownEvent event) {
if (_firstTap != null) { // 已经记录了一次单击,即当前为第2次单击,接下来进入以下逻辑
if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
return; // 超时,不认为是双击
} else if (!_firstTap!.hasElapsedMinTime() || !_firstTap!.hasSameButton(event)) {
_reset(); // 在短时间(kDoubleTapMinTime)内单击相同位置认为是单击,重置
return _trackTap(event); // 重新追踪
} else if (onDoubleTapDown != null) { // 认为是双击,触发对应回调onDoubleTapDown
final TapDownDetails details = TapDownDetails( ...... ); // 注意区别于onDoubleTap
invokeCallback<void>('onDoubleTapDown', () => onDoubleTapDown!(details));
}
} // if
_trackTap(event); // 对于首次单击,直接开始追踪,主要逻辑是挂起竞技场
}
void _trackTap(PointerDownEvent event) { // DoubleTapGestureRecognizer
_stopDoubleTapTimer(); // 见代码清单8-57
final _TapTracker tracker = _TapTracker( // 开始追踪单击事件
event: event, // 触发本次单击事件的PointerDown事件
// 加入竞技场,封装一个GestureArenaEntry对象并返回
entry: GestureBinding.instance!.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime, // 双击的最小时间间隔,默认为40ms
);
_trackers[event.pointer] = tracker;
tracker.startTrackingPointer(_handleEvent, event.transform); // 开始追踪,见代码清单8-56
}
以上逻辑将当前手势加入竞技场,并为当前事件添加路由_handleEvent
。由代码清单8-48可知,GestureBinding
在手势竞技场的关闭、清理等逻辑之前,会通过pointerRouter
路由当前事件,触发_handleEvent
。具体逻辑如代码清单8-55所示。
以上逻辑对于实现双击至关重要:首次单击事件结束时,在sweep
阶段之前优先触发_handleEvent
的逻辑以挂起竞技场,避免立即决出胜者。
// 代码清单8-55 flutter/packages/flutter/lib/src/gestures/multitap.dart
void _handleEvent(PointerEvent event) { // DoubleTapGestureRecognizer
final _TapTracker tracker = _trackers[event.pointer]!;
// 找到代码清单8-54中的_TapTracker对象
if (event is PointerUpEvent) {
if (_firstTap == null) // 首次单击抬起时触发
_registerFirstTap(tracker); // 见代码清单8-56
else // 第2次单击抬起时触发
_registerSecondTap(tracker); // 见代码清单8-56
} else if (event is PointerMoveEvent) { // 移除PointerMove事件
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
_reject(tracker); // 如果移动一段距离则不再认为是单击事件,这符合用户体验
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
由以上代码可知,因为首次单击时_firstTap
为null
,所以首次单击结束和第2次单击结束时将分别触发_registerFirstTap
和_registerSecondTap
的逻辑,如代码清单8-56所示。
// 代码清单8-56 flutter/packages/flutter/lib/src/gestures/multitap.dart
void _registerFirstTap(_TapTracker tracker) { // 首次点击,触发DoubleTapGestureRecognizer
_startDoubleTapTimer(); // 启动一个定时器,在一定时间后重置,见代码清单8-57
GestureBinding.instance!.gestureArena.hold(tracker.pointer); // 挂起当前竞技场
_freezeTracker(tracker); // 目标任务已触发,注销当前路由
_trackers.remove(tracker.pointer); // 移除tracker
_clearTrackers(); // 触发_trackers内其他tracker的_reject方法
_firstTap = tracker; // 标记首次单击事件产生,作用于代码清单8-54
}
void _registerSecondTap(_TapTracker tracker) { // 第2次单击
_firstTap!.entry.resolve(GestureDisposition.accepted); // 第1次单击的tracker,
// 见代码清单8-58
tracker.entry.resolve(GestureDisposition.accepted); // 第2次单击的tracker
_freezeTracker(tracker); // 清理掉第2次单击所注册的路由
_trackers.remove(tracker.pointer); // 移除
_checkUp(tracker.initialButtons); // 触发双击事件对应的回调
_reset(); // 重置,将释放之前挂起的竞技场,见代码清单8-57
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
}
void _checkUp(int buttons) { // 和前面介绍的onDoubleTapDown不同,此时胜者已经决出
if (onDoubleTap != null) invokeCallback<void>('onDoubleTap', onDoubleTap!);
}
void startTrackingPointer(PointerRoute route, Matrix4? transform) { // _TapTracker
if (!_isTrackingPointer) { // 避免重复注册
_isTrackingPointer = true;
GestureBinding.instance!.pointerRouter.addRoute(pointer, route, transform); // 注册路由
} // 由代码清单8-48中GestureBinding的handleEvent方法可知 第2次单击将首先触发route参数,即_handleEvent方法
void stopTrackingPointer(PointerRoute route) { // _TapTracker
if (_isTrackingPointer) {
_isTrackingPointer = false;
GestureBinding.instance!.pointerRouter.removeRoute(pointer, route); // 注销路由
}
}
以上逻辑会在首个单击事件发生时启动一个定时器,用于在一定时间后触发重置(_reset
方法)逻辑。这是因为如果连续两次单击超过一定时间间隔则不算作双击,如代码清单8-57所示。
_registerFirstTap
中还有一些其他逻辑,主要是挂起当前手势所在的竞技场,因为还未决出是否为双击事件,此外还会通过_freezeTracker
完成路由的注销,因为当前路由的职责(挂起竞技场)已经完成。一般来说,路由注销的逻辑在路由注册所触发的逻辑中,这样可以保证注册和注销是成对出现的。
_registerSecondTap
将在第2次单击中通过路由触发,并触发竞技场的决胜逻辑,这部分内容后面将详细分析。
首先分析启动计时器的逻辑,如代码清单8-57所示。
// 代码清单8-57 flutter/packages/flutter/lib/src/gestures/multitap.dart
void _startDoubleTapTimer() { // DoubleTapGestureRecognizer
_doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset); // 双击最大间隔时间,默认为300ms 超过这一时间后将触发_reset
}
void _reset() {
_stopDoubleTapTimer(); // 重置定时器
if (_firstTap != null) {
if (_trackers.isNotEmpty) _checkCancel();
final _TapTracker tracker = _firstTap!;
_firstTap = null;
_reject(tracker);
GestureBinding.instance!.gestureArena.release(tracker.pointer); // 释放首次单击所在的竞技场
}
_clearTrackers();
}
void _stopDoubleTapTimer() {
if (_doubleTapTimer != null) {
_doubleTapTimer!.cancel();
_doubleTapTimer = null;
}
}
以上逻辑十分清晰,主要是超时后通过_reset
方法完成相关成员的清理和重置。接下来分析手势竞技场的决胜逻辑,由_registerSecondTap
触发,如代码清单8-58所示。
// 代码清单8-58 flutter/packages/flutter/lib/src/gestures/arena.dart
void resolve(GestureDisposition disposition) { // GestureArenaEntry
_arena._resolve(_pointer, _member, disposition);
}
void _resolve(int pointer, GestureArenaMember member, GestureDisposition
disposition) {
final _GestureArena? state = _arenas[pointer];
if (state == null) return; // 目标竞技场已被移除,说明已经完成决胜
if (disposition == GestureDisposition.rejected) {
state.members.remove(member);
member.rejectGesture(pointer);
if (!state.isOpen) _tryToResolveArena(pointer, state);
} else {
if (state.isOpen) { // 竞技场还处在开放状态,没有关闭,则设置eagerWinner
state.eagerWinner ??= member; // 竞技场关闭时处理,见代码清单8-52
} else { // 直接决出胜者
_resolveInFavorOf(pointer, state, member); // 见代码清单8-59
}
} // if
}
通常来说,此时竞技场已经关闭但尚未清理,因此会进入_resolveInFavorOf
的逻辑,如代码清单8-59所示。
// 代码清单8-59 flutter/packages/flutter/lib/src/gestures/arena.dart
void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember
member) {
_arenas.remove(pointer); // InFavorOf,即支持传入的参数member成为竞技场的胜者
for (final GestureArenaMember rejectedMember in state.members) {
if (rejectedMember != member) rejectedMember.rejectGesture(pointer);
}
member.acceptGesture(pointer); // 触发胜者处理响应手势的逻辑 acceptGesture方法由具体子类实现
}
以上逻辑主要是触发竞技场内胜利者的acceptGesture
方法,对DoubleTapGestureRecognizer
来说,其acceptGesture
方法为空,因为响应双击事件的逻辑已经通过代码清单8-56的_checkUp
方法触发了。
双击事件由于自身逻辑的特殊性,从代码上分析比较晦涩,下面以图8-9中的 Case2 为例进行分析。对于图8-9中 B 区域,第1次单击事件发生时,Tap
、DoubleTap
和Darg
的GestureRecognizer
实例将加入竞技场,但是由于代码清单8-56中挂起了竞技场,因此手指抬起、单击结束时竞技场不会被清理。此时Drag
事件已经可以确认失败了,第2次单击发生时将释放竞技场,同时DoubleTap
判断自身是否满足条件(不超过指定时间),如果满足则触发对应回调;如果不满足首次单击则将以Tap
的形式触发。
可以看出,双击事件的核心在于竞技场短暂挂起。至此,双击事件分析完成。
拖曳事件与列表滑动
相较于双击事件,拖曳(Drag
)事件更加常见。首先要解决的第1个问题是拖曳事件如何在和单击事件的竞争中胜出,可以通过分析TapGestureRecognizer
的handleEvent
方法来解决,该方法在其父类PrimaryPointerGestureRecognizer
中,如代码清单8-60所示。
// 代码清单8-60 flutter/packages/flutter/lib/src/gestures/recognizer.dart
// PrimaryPointerGestureRecognizer
void handleEvent(PointerEvent event) {
if (state == GestureRecognizerState.possible && event.pointer == primaryPointer) {
final bool isPreAcceptSlopPastTolerance =
!_gestureAccepted && // 当前Recognizer尚在竞争手势
preAcceptSlopTolerance != null && // 判断为非单击的阈值,默认为18像素,下同
_getGlobalDistance(event) > preAcceptSlopTolerance!;
final bool isPostAcceptSlopPastTolerance =
_gestureAccepted && // 当前Recognizer已经成为胜者,例如只有一个竞技场成员时
postAcceptSlopTolerance != null && // 此时如果发现单击变滑动,则仍要拒绝
_getGlobalDistance(event) > postAcceptSlopTolerance!;
if (event is PointerMoveEvent &&
(isPreAcceptSlopPastTolerance || isPostAcceptSlopPastTolerance)) {
resolve(GestureDisposition.rejected); // 如果一次单击中滑动距离超过阈值则拒绝
stopTrackingPointer(primaryPointer!);
} else {
handlePrimaryPointer(event);
}
} // if
stopTrackingIfPointerNoLongerDown(event);
}
由以上逻辑可知,如果发生了滑动(Move
)事件,并且移动了一定距离,那么Tap
会拒绝处理。因此,如果单击之后没有立即抬起,而是滑动一定距离,单击事件也不会发生(即使没有设置响应拖曳事件的逻辑)。
通过以上逻辑已经保证了单击事件的Recognizer
不会竞争拖曳事件,那么拖曳事件又是如何识别并响应真正的拖曳手势呢?接下来开始分析,如代码清单8-61所示。
// 代码清单8-61 flutter/packages/flutter/lib/src/gestures/recognizer.dart
// DragGestureRecognizer
void handleEvent(PointerEvent event) {
// SKIP 与速度相关的计算
if (event is PointerMoveEvent) {
if (event.buttons != _initialButtons) {
_giveUpPointer(event.pointer);
return;
}
if (_state == _DragState.accepted) { // 分支1:已经胜出
_checkUpdate( // 直接更新拖曳信息
sourceTimeStamp: event.timeStamp,
delta: _getDeltaForDetails(event.localDelta),
primaryDelta: _getPrimaryValueFromOffset(event.localDelta),
globalPosition: event.position,
localPosition: event.localPosition,
);
} else { // 分支2:未胜出,正常情况下会先触发本分支
_pendingDragOffset += OffsetPair(local: event.localDelta, global: event.
delta);
_lastPendingEventTimestamp = event.timeStamp;
_lastTransform = event.transform;
final Offset movedLocally = _getDeltaForDetails(event.localDelta);
final Matrix4? localToGlobalTransform =
event.transform == null ? null : Matrix4.tryInvert(event.transform!);
_globalDistanceMoved += PointerEvent.transformDeltaViaPositions(
transform: localToGlobalTransform,
untransformedDelta: movedLocally,
untransformedEndPosition: event.localPosition,
).distance * (_getPrimaryValueFromOffset(movedLocally) ?? 1).sign; // 累计移动距离
if (_hasSufficientGlobalDistanceToAccept(event.kind)) // 达到阈值
resolve(GestureDisposition.accepted); // 接受当前GestureRecognizer
}
}
// SKIP PointerUpEvent / PointerCancelEvent处理:调用_giveUpPointer
}
void _checkUpdate({ ...... }) {
assert(_initialButtons == kPrimaryButton);
final DragUpdateDetails details = DragUpdateDetails( ...... );
if (onUpdate != null) // 赋值,见代码清单8-62
invokeCallback<void>('onUpdate', () => onUpdate!(details)); // 见代码清单8-63
}
以上逻辑中,当手指按下并且首次滑动时,拖曳手势并未胜出,因而会进入分支2,通过计算globalDistanceMoved
的大小,即当前已滑动的距离,然后判断是否超过一定阈值,若超过则在手势竞争中胜出。
决出胜者之后,_checkUpdate
会触发onUpdate
回调,将PointerMove
的细节封装成一个DragUpdateDetails
对象并调用该函数。
列表的滚动正是基于该机制,其关键类及其关系如图8-10所示。
图8-10中,ScrollableState
是Viewport
在Widget Tree中的一个祖先节点,如其名字所昭示的,它也是提供列表滑动能力的关键所在。具体来说,ScrollableState
通过_gestureRecognizers
字段持有一个GestureRecognizerFactory
实例,它将根据滑动方向生成对应的GestureRecognizer
,一般来说是DragGestureRecognizer
的子类。
RenderViewportBase
通过offset
字段确定自身当前的滚动距离,进而对每个子节点进行布局,那么核心问题就变成ScrollableState
如何将GestureRecognizer
提供的拖曳信息转换为列表在滑动方向的距离(ViewportOffset
)并触发布局更新。
回答这个问题,需要理解图8-10中的一个传递关系,即 ScrollableState
→ScrollDragController
(Drag
的子类)→ScrollPositionWithSingleContext
(ScrollActivityDelegate
的实现类),而ScrollPositionWithSingleContext
又是ViewportOffset
的子类。如此,滚动信息便可以转换为RenderViewportBase
的偏移值。此外,ViewportOffset
继承自ChangeNotifier
,它可以向RenderViewportBase
通知自身的滚动距离发生了变化。如此,手势事件便驱动了列表的滑动更新。
图8-10中,ScrollPhysics
和ScrollActivity
的子类负责实现各种滑动边界效果,比如Android平台的Clamping、iOS平台的Bouncing等。ScrollableState
和ScrollPosition
通过_position
字段和context
字段互相持有对方的引用,它们也是拖曳事件的生产者和最终的消费者进行交互的一条路径,但没有ScrollDragController
所连接的路径那样职责专一。ScrollContext
表示滑动上下文,即真正产生滑动效果的那个类。
列表是嵌套在ScrollableState
中的,而该对象会生成一个RawGestureDetector
,其_gestureRecognizers
成员会根据当前主轴方向生成对应的手势处理器,如代码清单8-62所示。
// 代码清单8-62 flutter/packages/flutter/lib/src/widgets/scrollable.dart
void setCanDrag(bool canDrag) { // ScrollableState
if (canDrag == _lastCanDrag && (!canDrag || widget.axis == _lastAxisDirection))
return;
if (!canDrag) {
_gestureRecognizers = const <Type, GestureRecognizerFactory>{};
_handleDragCancel();
} else {
switch (widget.axis) {
case Axis.vertical:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
// SKIP 其他回调注册
},
),
};
break;
case Axis.horizontal: // SKIP,HorizontalDragGestureRecognizer的注册
} // switch
} // if
_lastCanDrag = canDrag;
_lastAxisDirection = widget.axis;
if (_gestureDetectorKey.currentState != null)
_gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
}
以上逻辑由applyContentDimensions
方法触发。由代码清单8-61可知,当有新的PointerMove
事件到来时,_handleDragUpdate
将响应移动事件,该方法将调用Drag
对象的update
方法,具体逻辑在ScrollDragController
类中,如代码清单8-63所示。
// 代码清单8-63 flutter/packages/flutter/lib/src/widgets/scroll_activity.dart
// ScrollDragController
void update(DragUpdateDetails details) { // 见代码清单8-61的_checkUpdate方法
_lastDetails = details;
double offset = details.primaryDelta!; // 本次在主轴方向滑动的距离
if (offset != 0.0) {
_lastNonStationaryTimestamp = details.sourceTimeStamp;
}
_maybeLoseMomentum(offset, details.sourceTimeStamp);
offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
if (offset == 0.0) { return; }
if (_reversed) offset = -offset; // 逆向滑动
delegate.applyUserOffset(offset); // 见代码清单8-64
}
delegate
的具体实现为ScrollPositionWithSingleContext
,其applyUserOffset
逻辑如代码清单8-64所示。
// 代码清单8-64 flutter/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart
// ScrollPositionWithSingleContext
void applyUserOffset(double delta) { // 更新滚动方向
updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.
reverse);
setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta)); // 父类ScrollPosition实现
} // pixels是ViewportOffset的成员,表示当前拖曳事件在主轴方向导致的总偏移,即滑动距离
double setPixels(double newPixels) { //(ScrollPosition)更新滑动距离
if (newPixels != pixels) {
final double overscroll = applyBoundaryConditions(newPixels); // 计算滑动边缘距离
final double oldPixels = pixels;
_pixels = newPixels - overscroll;
// 注意,这里的_pixels不是直接根据newPixels进行更新
if (_pixels != oldPixels) {
notifyListeners(); // 通知列表更新,触发markNeedsLayout方法,见代码清单7-11
didUpdateScrollPositionBy(pixels - oldPixels);
}
if (overscroll != 0.0) {
didOverscrollBy(overscroll);
return overscroll;
}
}
return 0.0;
}
// ScrollPhysics的子类通过实现本方法达到不同的边缘滑动效果
double applyBoundaryConditions(double value) {
final double result = physics.applyBoundaryConditions(this, value);
return result;
}
以上逻辑将更新ViewportOffset
的_pixels
字段,而RenderViewportBase
在赋值自身的offset
字段时,已经将markNeedsLayout
添加为ViewportOffset
的监听者。由以上逻辑可知,notifyListeners
将触发performLayout
方法,驱动列表布局的更新。
接下来以图8-9中的 Case3 为例进行分析。当手指按下时,单击事件和 Drag
拖曳事件将加入竞技场,在此期间如果移动超过一定阈值,拖曳事件将胜出(见代码清单8-61),单击事件将被拒绝(见代码清单8-60)。
至此,已经完成拖曳事件及列表滑动机制的分析。
事件总线
在 App 中,我们经常会需要一个广播机制,用以跨页面事件通知,比如一个需要登录的 App 中,页面会关注用户登录或注销事件,来进行一些状态更新。这时候,一个事件总线便会非常有用,事件总线通常实现了订阅者模式,订阅者模式包含发布者和订阅者两种角色,可以通过事件总线来触发事件和监听事件。
下面我们实现一个简单的全局事件总线,我们使用单例模式,代码如下:
// 订阅者回调签名
typedef void EventCallback(arg);
class EventBus {
//私有构造函数
EventBus._internal();
//保存单例
static EventBus _singleton = EventBus._internal();
//工厂构造函数
factory EventBus()=> _singleton;
//保存事件订阅者队列,key:事件名(id),value: 对应事件的订阅者队列
final _emap = Map<Object, List<EventCallback>?>();
//添加订阅者
void on(eventName, EventCallback f) {
_emap[eventName] ??= <EventCallback>[];
_emap[eventName]!.add(f);
}
//移除订阅者
void off(eventName, [EventCallback? f]) {
var list = _emap[eventName];
if (eventName == null || list == null) return;
if (f == null) {
_emap[eventName] = null;
} else {
list.remove(f);
}
}
//触发事件,事件触发后该事件所有订阅者会被调用
void emit(eventName, [arg]) {
var list = _emap[eventName];
if (list == null) return;
int len = list.length - 1;
//反向遍历,防止订阅者在回调中移除自身带来的下标错位
for (var i = len; i > -1; --i) {
list[i](arg);
}
}
}
//定义一个top-level(全局)变量,页面引入该文件后可以直接使用bus
var bus = EventBus();
使用示例:
//页面A中
...
//监听登录事件
bus.on("login", (arg) {
// do something
});
//登录页B中
...
//登录成功后触发登录事件,页面A中订阅者会被调用
bus.emit("login", userInfo);
注意:Dart 中实现单例模式的标准做法就是使用 static
变量+工厂构造函数 的方式,这样就可以保证EventBus()
始终返回都是同一个实例,应该理解并掌握这种方法。
事件总线通常用于组件之间状态共享,但关于组件之间状态共享也有一些专门的包如redux、mobx以及前面介绍过的Provider
。对于一些简单的应用,事件总线是足以满足业务需求的,如果你决定使用状态管理包的话,一定要想清楚您的 App 是否真的有必要使用它,防止“化简为繁”、过度设计。
当然,在实际生产项目中你可以选择使用 pub.dev 社区上的流行库 event_bus 来作为事件总线。
通知 Notification
通知(Notification)是Flutter中一个重要的机制,在widget树中,每一个节点都可以分发通知,通知会沿着当前节点向上传递,所有父节点都可以通过NotificationListener
来监听通知。Flutter中将这种由子向父的传递通知的机制称为通知冒泡(Notification Bubbling)。通知冒泡和用户触摸事件冒泡是相似的,但有一点不同:通知冒泡可以中止,但用户触摸事件不行。
注意:通知冒泡和 Web 开发中浏览器事件冒泡原理是相似的,都是事件从出发源逐层向上传递,我们可以在上层节点任意位置来监听通知/事件,也可以终止冒泡过程,终止冒泡后,通知将不会再向上传递。
监听通知
Flutter中很多地方使用了通知,如 Scrollable
组件,它在滑动时就会分发滚动通知(ScrollNotification
),而 Scrollbar
正是通过监听 ScrollNotification
来确定滚动条位置的。
下面是一个监听可滚动组件滚动通知的例子:
NotificationListener(
onNotification: (notification){
switch (notification.runtimeType){
case ScrollStartNotification: print("开始滚动"); break;
case ScrollUpdateNotification: print("正在滚动"); break;
case ScrollEndNotification: print("滚动停止"); break;
case OverscrollNotification: print("滚动到边界"); break;
}
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text("$index"),);
}
),
);
上例中的滚动通知如ScrollStartNotification
、ScrollUpdateNotification
等都是继承自ScrollNotification
类,不同类型的通知子类会包含不同的信息,比如ScrollUpdateNotification
有一个scrollDelta
属性,它记录了移动的位移,其他通知属性可以自己查看SDK文档。
上例中,我们通过NotificationListener
来监听子ListView
的滚动通知的,NotificationListener
定义如下:
class NotificationListener<T extends Notification> extends StatelessWidget {
const NotificationListener({
Key key,
required this.child,
this.onNotification,
}) : super(key: key);
...//省略无关代码
}
我们可以看到:
-
NotificationListener
继承自StatelessWidget
类,所以它可以直接嵌套到Widget
树中。 -
NotificationListener
可以指定一个模板参数,该模板参数类型必须是继承自Notification
;当显式指定模板参数时,NotificationListener
便只会接收该参数类型的通知。举个例子,如果我们将上例子代码改为:
// 指定监听通知的类型为滚动结束通知(ScrollEndNotification)
NotificationListener<ScrollEndNotification>(
onNotification: (notification){ // 只会在滚动结束时才会触发此回调
print(notification);
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text("$index"),);
}
),
);
上面代码运行后便只会在滚动结束时在控制台打印出通知的信息。
onNotification
回调为通知处理回调,其函数签名如下:
typedef NotificationListenerCallback<T extends Notification> = bool Function(T notification);
它的返回值类型为布尔值,当返回值为true
时,阻止冒泡,其父级Widget
将再也收不到该通知;当返回值为 false
时继续向上冒泡通知。
Flutter的UI框架实现中,除了在可滚动组件在滚动过程中会发出ScrollNotification
之外,还有一些其他的通知,如SizeChangedLayoutNotification
、KeepAliveNotification
、LayoutChangedNotification
等,Flutter正是通过这种通知机制来使父元素可以在一些特定时机来做一些事情。
自定义通知
除了 Flutter 内部通知,我们也可以自定义通知,下面我们看看如何实现自定义通知:
- 定义一个通知类,要继承自
Notification
类;
class MyNotification extends Notification {
MyNotification(this.msg);
final String msg;
}
-
分发通知。
Notification
有一个dispatch(context)
方法,它是用于分发通知的,我们说过context
实际上就是操作Element
的一个接口,它与Element
树上的节点是对应的,通知会从context
对应的Element
节点向上冒泡。
下面我们看一个完整的例子:
class NotificationRoute extends StatefulWidget {
NotificationRouteState createState() {
return NotificationRouteState();
}
}
class NotificationRouteState extends State<NotificationRoute> {
String _msg="";
Widget build(BuildContext context) {
//监听通知
return NotificationListener<MyNotification>(
onNotification: (notification) {
setState(() {
_msg += notification.msg+" ";
});
return true;
},
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// ElevatedButton(
// onPressed: () => MyNotification("Hi").dispatch(context),
// child: Text("Send Notification"),
// ),
Builder(
builder: (context) {
return ElevatedButton(
// 按钮点击时分发通知
onPressed: () => MyNotification("Hi").dispatch(context),
child: Text("Send Notification"),
);
},
),
Text(_msg)
],
),
),
);
}
}
class MyNotification extends Notification {
MyNotification(this.msg);
final String msg;
}
上面代码中,我们每点一次按钮就会分发一个MyNotification
类型的通知,我们在Widget
根上监听通知,收到通知后我们将通知通过Text
显示在屏幕上。
注意:代码中注释的部分是不能正常工作的,因为这个
context
是根Context
,而NotificationListener
是监听的子树,所以我们通过Builder
来构建ElevatedButton
,来获得按钮位置的context
。
运行效果:
阻止通知冒泡
我们将上面的例子改为:
class NotificationRouteState extends State<NotificationRoute> {
String _msg="";
Widget build(BuildContext context) {
return NotificationListener<MyNotification>( // 监听通知
onNotification: (notification){
print(notification.msg); // 打印通知
return false;
},
child: NotificationListener<MyNotification>(
onNotification: (notification) {
setState(() {
_msg += notification.msg + " ";
});
return false; // 返回false表示不阻止冒泡,返回true会阻止冒泡
},
child: ...//省略重复代码
),
);
}
}
上列中两个NotificationListener
进行了嵌套,子NotificationListener
的onNotification
回调返回了false
,表示不阻止冒泡,所以父NotificationListener
仍然会受到通知,所以控制台会打印出通知信息;如果将子NotificationListener
的onNotification
回调的返回值改为true
,则父NotificationListener
便不会再打印通知了,因为子NotificationListener
已经终止通知冒泡了。
冒泡原理
我们在上面介绍了通知冒泡的现象及使用,现在我们更深入一些,介绍一下Flutter框架中是如何实现通知冒泡的。为了搞清楚这个问题,就必须看一下源码,我们从通知分发的的源头出发,然后再顺藤摸瓜。由于通知是通过Notification
的dispatch(context)
方法发出的,那我们先看看dispatch(context)
方法中做了什么,下面是相关源码:
void dispatch(BuildContext target) {
target?.visitAncestorElements(visitAncestor);
}
dispatch(context)
中调用了当前context
的visitAncestorElements
方法,该方法会从当前Element
开始向上遍历父级元素;visitAncestorElements
有一个遍历回调参数,在遍历过程中对遍历到的父级元素都会执行该回调。遍历的终止条件是:已经遍历到根Element
或某个遍历回调返回false
。
源码中传给visitAncestorElements
方法的遍历回调为visitAncestor
方法,我们看看visitAncestor
方法的实现:
// 遍历回调,会对每一个父级Element执行此回调
bool visitAncestor(Element element) {
// 判断当前element对应的Widget是否是NotificationListener。
// 由于NotificationListener是继承自StatelessWidget, 故先判断是否是StatelessElement
if (element is StatelessElement) {
// 是StatelessElement,则获取element对应的Widget,判断 是否是NotificationListener 。
final StatelessWidget widget = element.widget;
if (widget is NotificationListener<Notification>) {
// 是NotificationListener,则调用该NotificationListener的_dispatch方法
if (widget._dispatch(this, element))
return false;
}
}
return true;
}
visitAncestor
会判断每一个遍历到的父级Widget
是否是NotificationListener
,如果不是,则返回true
继续向上遍历,如果是,则调用NotificationListener
的_dispatch
方法,我们看看_dispatch
方法的源码:
bool _dispatch(Notification notification, Element element) {
// 如果通知监听器不为空,并且当前通知类型是该NotificationListener
// 监听的通知类型,则调用当前NotificationListener的onNotification
if (onNotification != null && notification is T) {
final bool result = onNotification(notification);
// 返回值决定是否继续向上遍历
return result == true;
}
return false;
}
我们可以看到NotificationListener
的onNotification
回调最终是在_dispatch
方法中执行的,然后会根据返回值来确定是否继续向上冒泡。上面的源码实现其实并不复杂,通过阅读这些源码,一些额外的点可以注意一下:
Context
上也提供了遍历Element
树的方法。- 我们可以通过
Element.widget
得到element
节点对应的widget
;我们已经反复讲过Widget
和Element
的对应关系,可以通过这些源码来加深理解。
参考:
- 《Flutter实战·第二版》
- 《Flutter内核源码剖析》