概述
在移动端,各个平台或者 UI 系统的事件模型都是基本一致,即:一次完整的事件分为三个阶段,手指按下,移动,抬起,而其他的双击,拖动等都是基于这些事件的
当指针按下时,Flutter 会对应用程序执行命中测试(Hit Test)
,以确定指针与屏幕接触的位置存在哪些 Widget,指针按下事件(以及该指针的后续事件)会被分发到由命中测试发现的最内部的组件,然后从哪里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件分发的组件树的根路径上的所有组件,这个 Web 开发浏览器的事件冒泡机制相似,但是 Flutter 中没有机制取消或者停止冒泡过程,而浏览器是可以停止的。
注意:只有通过命中测试的组件才能触发事件
原始指针事件处理
Flutter 中可以使用 Listener 来监听原始触摸事件,按照<Flutter实战> 中的分类,Listener 也是一个功能性组件,下面是 Listener 的构造函数定义:
代码语言:javascript复制Listener({
Key key,
this.onPointerDown, //手指按下回调
this.onPointerMove, //手指移动回调
this.onPointerUp,//手指抬起回调
this.onPointerCancel,//触摸事件取消回调
this.behavior = HitTestBehavior.deferToChild, //在命中测试期间如何表现
Widget child
})
复制代码
- behavior 在后面专门介绍
示例:
代码语言:javascript复制class EventTest extends StatefulWidget {
@override
_EventTestState createState() => _EventTestState();
}
class _EventTestState extends State<EventTest> {
PointerEvent _event;
@override
Widget build(BuildContext context) {
return Listener(
child: Container(
margin: EdgeInsets.only(top: 50),
color: Colors.blue,
alignment: Alignment.center,
child: Text(_event?.toString() ?? "",
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 包含当前指针的一些信息,如:
- position:他是鼠标相对于全局坐标的偏移
- delta:两次指针移动事件的距离
- pressure:按压力度,如果手机屏幕支持压力传感器,此属性才会有意义,如手机不支持,始终为 1。
- orientation:指针移动方向,是一个角度值
上面只是一些常用属性,除了这些还有很多其他属性,可自行查看 API
behavior
他决定子组件如何响应命中测试,他的值为 HitTestBehavior,是一个枚举类,有三个枚举值
- deferToChild:子组件会一个一个的进行命中测试,如果子组件中有测试通过的,则当前组件通过,这意味着指针事件作用于子组件时,其父级组件也肯定可以接收到事件
- opaque:在命中测试时,将当前组件当初不透明处理(即使本身是透明的),最终的效果相当于当前 Widget 的整个区域都是点击区域。栗子:
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(300.0, 150.0)),
child: Center(child: Text("Box A")),
),
//behavior: HitTestBehavior.opaque,
onPointerDown: (event) => print("down A")
),
上例子,只有点击文本区域才会触发点击事件,因为 deferToChild 会去子组件判断是否命中测试,该例中子组件就是 Text("Box A") 。 如果想让整个 300x150 的区域都能点击,我们可以将 behavior 设为 HitTestBehavior.opaque。 注意:该属性不能用于在组件树中拦截(忽略)事件,他只是决定命中测试时的组件大小
- translucent:当组件点击透明区域时,可以对自身边界及底部可视区域都进行命中测试。这意味着点击顶部组件透明区域时,顶部组件和底部组件都可以接收到事件,例如:
Stack(
children: <Widget>[
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(300.0, 200.0)),
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.blue)),
),
onPointerDown: (event) => print("down0"),
),
Listener(
child: ConstrainedBox(
constraints: BoxConstraints.tight(Size(200.0, 100.0)),
child: Center(child: Text("左上角200*100范围内非文本区域点击")),
),
onPointerDown: (event) => print("down1"),
//behavior: HitTestBehavior.translucent, //放开此行注释后可以"点透"
)
],
)
上栗中,当注释掉最后一行代码,在左上角200x100 范围内非文本区域点击时(顶部组件透明区域),控制台只会打印 down0,也就是说顶部没有接收到事件,只有底部接收到了 当放开注释后,再点击时顶部和底部都会接收到事件
忽略 PinterEvent
如果我们不想让某个子树响应 PointerEvent ,则可以使用 IgnorePointer
和 AbsorbPointer
,这两个组件都能阻止子树接受指针事件,不同之处在于 AbsorbPointer
会参与命中测试,而 IgnorePointer
本身不会参与,这就意味着 AbsorbPointer
本身是可以接受指针事件的(但其子树不行),而 IngorePointer
不可以,例:
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 子树上,所以不会响应指针事件,
但是 AbsorbPoniter 本身是可以接受指针事件的,所以会输出 up,如果将 AbsorbPointer
换成 IgnorePointer
,那么两个都不会输出;
手势识别
GestuerDetector
GestureDetector
是一个用于手势识别的功能性组件,我们可以通过它来识别各种手势
GestureDetector
实际上是指针事件的语义化封装,下面我们来看一下各种手势识别。
点击,双击,长按
我们通过 GestureDetector 对 Container 进行手势识别,触发相应事件后,在 Container 上显示事件名,如下:
代码语言:javascript复制class _EventTestState extends State<EventTest> {
//事件名称
String _operation = "";
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
child: Container(
width: 200,
color: Colors.blue,
alignment: Alignment.center,
height: 100,
child: Text(_operation, style: TextStyle(color: Colors.white,fontSize: 20)),
),
onTap: () => upDateText("tap"), //单击
onDoubleTap: () => upDateText("doubleTap"), //双击
onLongPress: () => upDateText("longPress"), //长按
),
);
}
void upDateText(String text) {
setState(() {
_operation = text;
});
}
}
复制代码
注意:当同时监听 onTop 和 onDoubleTap 时,当用户触发 tap 事件时,会有 200 毫秒的延时,这是因为可能会再次点击触发双击事件 如果只监听了 onTap,则不会有延时
拖动,滑动
一次完整的手势过程是指用户手指按下到抬起的整个过程,期间,用户按下后可能会移动,也可能不移动。
GestureDetector 对拖动和滑动事件时没有区分的,他们本质是一样的。
GestureDetector 会把要监听的组件的原点(左上角)作为本次手势的原点,当监听组件上手指按下时,手势识别就会开始。例:
代码语言:javascript复制class _EventTestState extends State<EventTest> with SingleTickerProviderStateMixin {
double _top = 100.0; //距离顶部的偏移
double _left = 100.0; //距离左边的偏移
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
//手指按下回调
onPanDown: (DragDownDetails e) {
print('用户手指按下 ${e.globalPosition}');
},
//手指滑动回调
onPanUpdate: (DragUpdateDetails e) {
//滑动时,更新偏移
print('滑动');
setState(() {
_left = e.delta.dx;
_top = e.delta.dy;
});
},
onPanEnd: (DragEndDetails e) {
//滑动结束,打印 x,y轴速度
print(e.velocity);
},
),
)
],
),
);
}
}
复制代码
- globalPosition:此属性为用户按下时相对于屏幕(非父组件)原点的偏移
- delta:当用户在屏幕上滑动时,会触发多次 Update 事件,dalta 指一次 Update 事件滑动的偏移量
- velocity:该属性代表用户抬起时的滑动速度(包含x,y两个轴的),上例中没有处理抬起的速度,常见的效果是根据抬起手指的速度做一个减速动画
效果如下:
代码语言:javascript复制I/flutter ( 8239): 用户手指按下 Offset(134.9, 280.7)
I/flutter ( 8239): 滑动
I/chatty ( 8239): uid=10152(com.flutter.flutter_study) 1.ui identical 302 lines
I/flutter ( 8239): 滑动
I/flutter ( 8239): Velocity(-59.6, 244.0)
复制代码
单一方向拖动
在很多场景中,我们只需要沿着一个方向来拖动,如一个垂直方向的列表
GestureDetector 支持特定方向的手势事件,例如:
代码语言:javascript复制Positioned(
top: _top,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
//手指按下回调
onPanDown: (DragDownDetails e) {
print('用户手指按下 ${e.globalPosition}');
},
onVerticalDragUpdate: (DragUpdateDetails e) {
setState(() {
_top = e.delta.dy;
});
},
onPanEnd: (DragEndDetails e) {
//滑动结束,打印 x,y轴速度
print(e.velocity);
},
),
)
复制代码
修改滑动的那个例子如上即可
缩放
GestureDetector 可以监听缩放事件,如下:
代码语言:javascript复制Center(
child: GestureDetector(
child: Image.asset("./images/avatar.jpg", width: _width),
onScaleUpdate: (ScaleUpdateDetails details) {
setState(() {
//缩放倍数在 0.8 到 10 倍之间
_width = 100 * details.scale.clamp(.8, 10.0);
});
},
),
);
复制代码
上例比较简单,实际中我们可能还需要一些其他功能,如双击放大缩小,执行动画等,有兴趣的可以先尝试一下
GestureRecognizer
getstureDetector
内部是使用一个或者多个 GestureRecognizer
来识别各种手势的,而 GestureRecognizer 的作用就是通过 Listener 将原始指针转换为语义手势
GestureRecognizer
是一个抽象类,一种手势对应一个子类,Flutter 实现了丰富的手势识别器,我们可以直接使用。
例如:
我们要给一段富文本 (RichText) ,的不同部分添加事件处理器,但是 TextSpan 并不是一个 widget,所以不能用 GestureDetector。但是 TextSpan 有一个 Recongizer 属性,他可以接收一个 GestureRecognizer。
代码语言:javascript复制bool _toggle = false; //变色开关
TapGestureRecognizer _recognizer = TapGestureRecognizer();
Widget bothDirectionTest() {
return Center(
child: Text.rich(TextSpan(children: [
TextSpan(text: "你好世界"),
TextSpan(
text: "点击变色",
style: TextStyle(
fontSize: 30, color: _toggle ? Colors.red : Colors.yellow),
recognizer: _recognizer
..onTap = () {
setState(() {
_toggle = !_toggle;
});
}),
TextSpan(text: "你好世界")
])),
);
}
@override
void dispose() {
//用到GestureRecognizer的话一定要调用其dispose方法释放资源
_recognizer.dispose();
super.dispose();
}
复制代码
注意:使用 GestureRecognizer 之后,一定要调用其 dispose 方法来释放资源(主要是取消内部的计时器),运行效果如下:
手势竞争与冲突
竞争
如在上例中,同时监听水平方向和垂直方向的拖动事件,那么斜着滑动时那个方向会生效? 实际上取决于第一次移动时两个轴上的位移分量,那个轴的大,那么哪个轴就会在本次滑动事件中胜出
实际上 Flutter 中引入了一个 Arenal 的概念,直译为 竞技场 的意思,每一个手势识别器(GestureRecognizer) 都是一个竞争者(GestureArenaMember),当发生滑动事件时,他们都要在 竞技场 去竞争本次事件的处理权,而最终只有一个竞争者会胜出。
例如有一个 ListView,他的第一个子组件也是 ListView,如果滑动子 ListView,父 ListView 会动吗?答案肯定是不会动的,这时只有子 ListView 会动,这是因为子 LsitView 货到了滑动事件的处理权。
示例
代码语言:javascript复制var _top1 = 100.0;
var _left1 = 100.0;
Widget bothDirection() {
return Stack(
children: [
Positioned(
top: _top1,
left: _left1,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
onVerticalDragUpdate: (DragUpdateDetails details) {
setState(() {
_top1 = details.delta.dy;
});
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left1 = details.delta.dx;
});
},
),
)
],
);
}
复制代码
运行之后,每次拖动只会沿着一个方向移动,而竞争者发生在手指按下后首次移动时
上例中获胜的条件是,首次移动时的位置在水平和垂直方向上分量大的一个获胜
手势冲突
由于手势竞争最终只有一个胜出者,所以,当有多个手势识别器时,可能会产生冲突;
例如有一个 Widget,可以左右拖动,现在我们也想检测它上面手指按下和抬起的事件,如下:
代码语言:javascript复制var _left2 = 100.0;
Widget flictTest() {
return Stack(
children: [
Positioned(
left: _left2,
top: 100,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left2 = details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
print('onHorizontalDragEnd');
},
onTapDown: (details) {
print('down');
},
onTapUp: (details) {
print('up');
},
),
)
],
);
}
复制代码
拖动后,日志如下:
代码语言:javascript复制0I/flutter ( 4315): down
I/flutter ( 4315): onHorizontalDragEnd
复制代码
我们发现没有打印 up,这是因为拖动时,在按下手指没有移动时,拖动手势还没有完整的语义,此时 TapDown 手势胜出,此时打印 down,而拖动时,拖动手势胜出,当抬起时, onHorizontalDragEnd 和 onTap 发生冲突,但是应为是在拖动的语义中,所以 onHorizeontalDragend 胜出,所以就会打印 onHorizontalDragEnd。
如果我们的逻辑代码中,对手指的按下和抬起时强依赖的,例如轮播组件,我们希望按下时暂停轮播,抬起时恢复轮播。但是由于轮播组件中本身可能已经处理了拖动手势,甚至支持了缩放手势,这时外部如果再用 onTapDown,onTap 来监听是不行的。
这个时候就可以同个 Listener 监听原始指针事件就行:
代码语言:javascript复制Listener(
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left2 = details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
print('onHorizontalDragEnd');
},
),
onPointerDown: (details){
print('onPointerDown');
},
onPointerUp: (details){
print('onPointerUp');
},
),
)
复制代码
手势冲突只是手势级别的,而手势是对原始指针的语义化识别,所以在遇到复杂的冲突场景时,都可以通过 Listener 直接识别原始指针事件来解决冲突
事件总线
在 App 中,我们经常需要一个广播机制,用以夸页面事件通知,例如注销登录时,某些页面可能需要进行状态更新。这个时候一个事件总线便会非常有用;
事件总线通常实现了订阅者模式,订阅者包含订阅者和发布者两个角色,可以通过事件总线来触发事件和监听事件;
代码如下:
代码语言:javascript复制typedef void EventCallback(arg);
class EventBus {
//私有构造
EventBus._internal();
static EventBus _singleton = new EventBus._internal();
//工厂构造函数
factory EventBus() => _singleton;
//保存时间订阅者队列,key:事件名(id),value:对应的实际订阅者队列
var _eMap = new Map<Object, List<EventCallback>>();
///添加订阅者
void on(eventName, EventCallback f) {
if (eventName == null || f == null) return;
_eMap[eventName] ??= [];
_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,全局变量,页面引入该文件之后可以直接使用 bug
var bus = new EventBus();
复制代码
使用如下:
代码语言:javascript复制//监听登录失效
bus.on(Event.LOGIN_OUT, (arg) {
SpUtil.putString(Application.accessToken, null);
Application.router.navigateTo(context, Routes.login, clearStack: true);
});
//触发失效事件
bus.emit(Event.LOGIN_OUT, null);
复制代码
注意:Dart 中实现点了模式的标准做法就是使用 static 变量 工厂构造函数的方式,这样就可以保证 new EventBus() 始终返回都是同一个实例
事件总线常用于组件之间的状态共享,但是关于组件之间的状态共享也有一些专门的包,如 redux,以及 Provider。
对于一些简单的应用,事件总线总是奏议满足业务需求,如果觉得使用状态管理包的话,一定要想清楚 APP 是否有必要使用它,防止化简为繁的过度设计
参考
参考自 Flutter实战