Flutter事件处理
Flutter 中的手势有两个不同的层次:第一层是原始的指针(Pointer Event)指向事件,描述了屏幕上由触摸板、鼠标、指示笔等触发的指针的位置和移动。第二层包含 gestures,描述了由上述一个或多个指针移动组成的具有特殊语义的操作。
原始事件(Pointer Event)
在指针下落事件中,框架做了一个 hit test 的操作确定与屏幕发生接触的位置上有哪些组件以及分发给最内部的组件去响应。事件会沿着组件树从这个最内部的组件向组件树的根部冒泡分发。并且不存在用于取消或停止指针事件进行进一步分发的机制。 Pointer 代表的是人机界面交互的原始数据。一共有四种指针事件:
-
PointerDownEvent 指针在特定位置与屏幕接触
-
PointerMoveEvent 指针从屏幕的一个位置移动到另外一个位置
-
PointerUpEvent 指针与屏幕停止接触
-
PointerCancelEvent 指针的输入已经不再指向此应用
Flutter中可以使用Listener来监听原始触摸事件,按照本书对组件的分类,则Listener也是一个功能性组件。下面是Listener的构造函数定义:
const Listener({
Key key,
this.onPointerDown, //手指按下回调
this.onPointerMove, //手指移动回调
this.onPointerUp, //手指抬起回调
this.onPointerCancel, //触摸事件取消回调
this.behavior = HitTestBehavior.deferToChild,
Widget child,
})
先简单看一个demo的实现效果:
......
int _downCounter = 0;
int _upCounter = 0;
double x = 0.0;
double y = 0.0;
......
//关键代码
ConstrainedBox(
constraints: BoxConstraints.tight(Size(300.0, 200.0)),
child: Listener(
onPointerDown: (PointerEvent point) => setState(() => _downCounter++),
onPointerUp: (PointerEvent point) => setState(() => _upCounter++),
onPointerMove: (PointerEvent point) {
setState(() {
x = point.position.dx;
y = point.position.dy;
});
},
child: Container(
color: Colors.lightBlueAccent,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pressed or released in this area this many times:'),
Text(
'$_downCounter presses\n$_upCounter releases',
style: Theme.of(context).textTheme.headline4,
),
Text(
'The cursor is here: (${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)})',
)
],
),
),
),
)
使用 Listener 可以在组件层直接监听指针事件。然而,一般情况下,请考虑使用下面的 gestures 替代。
手势
GestureDetector
GestureDetector 代表的是语义操作(比如点击、拖动、缩放)。通常由一系列单独的指针事件组成,甚至是一系列单独的指针组成。 GestureDetector 可以分发多种事件,对应着手势的生命周期(比如开始拖动、拖动更新、结束拖动)。
-
点击
-
onTapDown 指针在发生接触的屏幕的特定位置可能引发点击事件。
-
onTapUp 指针使在屏幕的特定位置触发的点击事件停止。
-
onTap 点击事件已经发生。
-
onTapCancel 指针已经触发了 onTapDown,但是最终不会形成一个点击事件。
-
-
双击
- onDoubleTap 用户在屏幕的相同位置上快速点击了两次。
-
长按
- onLongPress 指针在屏幕的相同位置上保持接触持续一长段时间。
-
纵向拖动
-
onVerticalDragStart 指针和屏幕产生接触并可能开始纵向移动。
-
onVerticalDragUpdate 指针和屏幕产生接触,在纵向上发生移动并保持移动。
-
onVerticalDragEnd 指针先前和屏幕产生了接触,以特定速度纵向移动,并且此后不会在屏幕接触上发生纵向移动。 横向拖动
-
onHorizontalDragStart 指针和屏幕产生接触并可能开始横向移动。
-
onHorizontalDragUpdate 指针和屏幕产生接触,在横向上发生移动并保持移动。
-
onHorizontalDragEnd 指针先前和屏幕产生了接触,以特定速度横向移动,并且此后不会在屏幕接触上发生横向移动。
-
-
移动
-
onPanStart 指针和屏幕产生接触并可能开始横向移动或者纵向移动。如果设置了 onHorizontalDragStart 或者 onVerticalDragStart,该回调方法会引发崩溃。
-
onPanUpdate 指针和屏幕产生接触,在横向或者纵向上发生移动并保持移动。如果设置了 onHorizontalDragUpdate 或者 onVerticalDragUpdate,该回调方法会引发崩溃。
-
onPanEnd 指针先前和屏幕产生了接触,并且以特定速度移动,此后不再在屏幕接触上发生移动。如果设置了 onHorizontalDragEnd 或者 onVerticalDragEnd,该回调方法会引发崩溃。
-
GestureRecognizer
GestureDetector内部是使用一个或多个GestureRecognizer来识别各种手势的,而GestureRecognizer的作用就是通过Listener来将原始指针事件转换为语义手势,GestureDetector直接可以接收一个子widget。GestureRecognizer是一个抽象类,一种手势的识别器对应一个GestureRecognizer的子类,Flutter实现了丰富的手势识别器,我们可以直接使用。
为 widgets 添加手势检测
从组件层监听手势,需要用到 GestureDetector。
以下是官方的一个简单的监听点击事件的demo:
Container(
alignment: FractionalOffset.center,
color: Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.lightbulb_outline,
color: _lights ? Colors.yellow.shade600 : Colors.black,
size: 60,
),
),
GestureDetector(
onTap: () {
setState(() {
_lights = true;
});
},
child: Container(
color: Colors.yellow.shade600,
padding: const EdgeInsets.all(8),
child: const Text('TURN LIGHTS ON'),
),
),
],
),
)
手势消歧处理
在屏幕的指定位置上,可能有多个手势捕捉器。所有的手势捕捉器监听了指针输入流事件并判断出特定手势。 GestureDetector widget 能够基于手势的回调是否非空决定是否应该尝试去识别该手势。
当屏幕上的指定指针有多个手势识别器时,框架会通过给每个手势识别器加入 gesture arena 来处理手势消歧。 gesture arena,也称作手势竞技场,会利用下述规则确定哪个手势在竞争中胜出:
- 在任何时候,识别器都可以宣告失败并离开竞技场。如果竞技场中只有一个识别器,那么这个识别器就是胜者。
- 在任何时候,任何识别器都可以宣告胜利,这将导致这个识别器胜出,其他识别器失败。
比如,当纵向拖动和横向拖动需要处理消歧,当指南针下落事件发生时,纵向和横向识别器都会进入竞技场,观测指针移动事件。如果用户在横向上移动超过了特定像素,横向识别器会宣告胜利,手势也会被当作横向拖动处理。同样的,如果用户在纵向上移动超过了特定的像素,纵向识别器会宣告胜利。
Flutter与IOS手势冲突
场景
搜狐资讯客户端是一个全屏侧滑的手势来退出当前页面,具体做法是:用一个自定义pan手势替换了navigation的边缘侧滑手势,具体实现任然是使用的系统侧滑的方法,只是手势替换了而已。生肖页面是使用flutter开发的具有纵向滑动的手势(flutter engine实现的),集成到项目,发现flutter页面滑动不了了。
分析
native集成的flutter界面是通过push或present一个UIViewController的子类(FlutterViewController),在FlutterViewController上又自定义了一个FlutterView,Flutter widget的渲染是在FlutterView上。iOS原生和Flutter各自分别提供了一套手势系统,两者是独立的。Flutter引擎定义的手势系统是基于捕获Flutter页面上的touchXXX方法,然后定义了一套手势处理机制。 iOS手势有一个尝试就是默认UIGestureReconizer的优先级永远高于基础的事件响应链的。UIGestureReconizer处理不了的事件才会交给响应链去处理。 基于这个原理就可以知道,navigation view上的pan手势消费了所有的触摸事件,事件自然传不到FlutterView上,Flutter引擎也就没机会生成自己的手势识别器了。
解决方案
简单来说就是状态转移,捕获flutter手势对该事件的处理状态,并将成功或失败的状态通过channel通知给native。native可以继承UIGestureRecognizer,自定义一个手势,通过flutter传递过来的成功或失败状态,来改变native自定义手势的state。
获得FlutterView内部手势状态
Flutter 的手势系统有一个『手势竞技场』的概念,它负责解决手势冲突,手势冲突的胜者会被调用 acceptGesture,败者会被调用 rejectGesture。有了这个机制在,我们只需要把一个自定义的 GestureRecognizer 『送进』每一次手势冲突的竞技场,如果 acceptGesture 被调用了,则说明没有任何其他 GestureRecognizer 能够处理触摸事件,反之如果 rejectGesture 被调用了,则说明至少有一个其他 GestureRecognizer 能够处理触摸事件。
实现这样的自定义手势需要满足两个条件:
-
要能持续接收触摸事件,因为有些手势判断自己是否能处理需要花费一定时间(比如长按手势),如果自定义手势很快的就确定了自己能或不能接收触摸事件,则可能忽略了长按类的手势。
-
要能与所有手势发生冲突。
经过测试发现 PanGestureRecognizer 就能满足第一个条件,我们的自定义 GestureRecognizer 继承 PanGestureRecognizer 就可以了。
第二个条件也很容易达成:将自定义 GestureRecognizer 添加到根 Widget 外层,这样它就能够与所有的手势发生冲突。
获得了 FlutterView 内部手势是否在处理触摸事件的信息后,通过 Platform Channel 传递给 iOS 层的 ProxyGestureRecognizer,再由它实现上述的状态转移逻辑即可。 自定义手势代码如下:
class PointerTracker extends PanGestureRecognizer {
bool _isFlutterGestureWorking = false;
@override
void rejectGesture(int pointer) {
// TODO: implement rejectGesture
super.rejectGesture(pointer);
_isFlutterGestureWorking = true;
_notify();
}
@override
void acceptGesture(int pointer) {
// TODO: implement acceptGesture
super.acceptGesture(pointer);
_isFlutterGestureWorking = false;
_notify();
}
void _notify() {
//flutter 通道
GestureChannel.gestureStateChanged(flag: _isFlutterGestureWorking);
}
}
关键代码如下:
RawGestureDetector(
child: SomeWidget(), //纵向滚动
behavior: HitTestBehavior.translucent,
gestures: {
PointerTracker: GestureRecognizerFactoryWithHandlers<PointerTracker>(
() => PointerTracker(),
(PointerTracker instance) {
instance.onStart = (det) {
print('localPosition********* : ${det.localPosition}');
print('globalPosition************: ${det.globalPosition}');
};
}
)
},
)
iOS自定义手势识别器
ios自定义一个手势来接受channel传递过来的状态。关键代买如下:
flutterChannel.setMethodCallHandler { [weak self] (call, result) in
guard let `self` = self else { return }
if call.method == "gestureStateChanged" {
if let flag = call.arguments as? Bool {
debugPrint(flag)
if flag {
self.proxyGesture.state = .failed
}else {
self.proxyGesture.state = .began
}
}
}
}
至此,native也获取到了flutter传递来的状态,可以使用该自定义的手势做全局的横向滑动。