1.原始指针事件处理
一次完整的事件分为三个阶段:手指按下、手指移动、和手指抬起,而更高级别的手势(如点击、双击、拖动等)都是基于这些原始事件的。
Listener 组件
Flutter中可以使用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;@overrideWidget 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),);}
}
PointerEvent常用属性:
- position:它是指针相对于当对于全局坐标的偏移。
- localPosition: 它是指针相对于当对于本身布局坐标的偏移。
- delta:两次指针移动事件(PointerMoveEvent)的距离。
- pressure:按压力度,如果手机屏幕支持压力传感器(如iPhone的3D Touch),此属性会更有意义,如果手机不支持,则始终为1。
- orientation:指针移动方向,是一个角度值。
忽略指针事件
不想让某个子树响应PointerEvent的话,我们可以使用IgnorePointer和AbsorbPointer,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,那么两个都不会输出。
2.手势识别
GestureDetector和GestureRecognizer
点击、双击、长按
通过GestureDetector对Container进行手势识别,触发相应事件后,在Container上显示事件名
class _GestureTestState extends State<GestureTest> {String _operation = "No Gesture detected!"; //保存事件名@overrideWidget 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;});}
}
拖动、滑动
拖动圆形字母A的示例:
class _Drag extends StatefulWidget {@override_DragState createState() => _DragState();
}class _DragState extends State<_Drag> with SingleTickerProviderStateMixin {double _top = 0.0; //距顶部的偏移double _left = 0.0;//距左边的偏移@overrideWidget 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 {@override_DragVerticalState createState() => _DragVerticalState();
}class _DragVerticalState extends State<_DragVertical> {double _top = 0.0;@overrideWidget 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;});},),)],);}
}
缩放
class _Scale extends StatefulWidget {const _Scale({Key? key}) : super(key: key);@override_ScaleState createState() => _ScaleState();
}class _ScaleState extends State<_Scale> {double _width = 200.0; //通过修改图片宽度来达到缩放效果@overrideWidget 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);});},),);}
}
GestureDetector内部是使用一个或多个GestureRecognizer来识别各种手势的,
而GestureRecognizer的作用就是通过Listener来将原始指针事件转换为语义手势,
GestureDetector直接可以接收一个子widget
示例:给一段富文本(RichText)的不同部分分别添加点击事件处理器,但是TextSpan并不是一个widget,这时我们不能用GestureDetector,但TextSpan有一个recognizer属性,它可以接收一个GestureRecognizer。
import 'package:flutter/gestures.dart';class _GestureRecognizer extends StatefulWidget {const _GestureRecognizer({Key? key}) : super(key: key);@override_GestureRecognizerState createState() => _GestureRecognizerState();
}class _GestureRecognizerState extends State<_GestureRecognizer> {TapGestureRecognizer _tapGestureRecognizer = TapGestureRecognizer();bool _toggle = false; //变色开关@overridevoid dispose() {//用到GestureRecognizer的话一定要调用其dispose方法释放资源_tapGestureRecognizer.dispose();super.dispose();}@overrideWidget 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: "你好世界"),],),),);}
}
3.Flutter事件机制
事件分发过程很简单,即遍历HitTestResult,调用每一个节点的 handleEvent 方法,只需要重写 handleEvent 方法就可以处理事件了。
1.命中测试:当手指按下时,触发 PointerDownEvent 事件,按照深度优先遍历当前渲染(render object)树,对每一个渲染对象进行“命中测试”(hit test),如果命中测试通过,则该渲染对象会被添加到一个 HitTestResult 列表当中。
2.事件分发:命中测试完毕后,会遍历 HitTestResult 列表,调用每一个渲染对象的事件处理方法(handleEvent)来处理 PointerDownEvent 事件,该过程称为“事件分发”(event dispatch)。随后当手指移动时,便会分发 PointerMoveEvent 事件。
3.事件清理:当手指抬( PointerUpEvent )起或事件取消时(PointerCancelEvent),会先对相应的事件进行分发,分发完毕后会清空 HitTestResult 列表。
4.手势原理与手势冲突
手势发生变化时只需要在 pointerRouter中取出 GestureRecognizer 的 handleEvent 方法进行手势识别即可。一个手势只有一个手势识别器生效
1.每一个手势识别器(GestureRecognizer)都是一个“竞争者”(GestureArenaMember),当发生指针事件时,他们都要在“竞技场”去竞争本次事件的处理权,默认情况最终只有一个“竞争者”会胜出(win)。
2.GestureRecognizer 的 handleEvent 中会识别手势,如果手势发生了某个手势,竞争者可以宣布自己是否胜出,一旦有一个竞争者胜出,竞技场管理者(GestureArenaManager)就会通知其他竞争者失败。
3.胜出者的 acceptGesture 会被调用,其余的 rejectGesture 将会被调用。
如果对一个组件同时监听水平和垂直方向的拖动手势,当我们斜着拖动时哪个方向的拖动手势回调会被触发?实际上取决于第一次移动时两个轴上的位移分量,哪个轴的大,哪个轴在本次滑动事件竞争中就胜出
解决手势冲突
解决手势冲突的方法有两种:
1.使用 Listener。这相当于跳出了手势识别那套规则。
2.自定义手势手势识别器( Recognizer)
通过 Listener 解决手势冲突的原因是竞争只是针对手势的,而 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 。
定义手势识别器的方式比较麻烦,原理是当确定手势竞争胜出者时,会调用胜出者的acceptGesture 方法,表示“宣布成功”,然后会调用其他手势识别其的rejectGesture 方法,表示“宣布失败”。
既然如此,我们可以自定义手势识别器(Recognizer),然后去重写它的rejectGesture 方法:在里面调用acceptGesture 方法,这就相当于它失败是强制将它也变成竞争的成功者了,这样它的回调也就会执行。
5.事件总线
一个需要登录的 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。对于一些简单的应用,事件总线是足以满足业务需求的
6. 通知 Notification
通知(Notification)是Flutter中一个重要的机制,在widget树中,每一个节点都可以分发通知,通知会沿着当前节点向上传递,所有父节点都可以通过NotificationListener来监听通知。Flutter中将这种由子向父的传递通知的机制称为通知冒泡
监听可滚动组件滚动通知的例子:
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"),);}),
);