Flutter | 事件处理

1,590 阅读11分钟

概述

在移动端,各个平台或者 UI 系统的事件模型都是基本一致,即:一次完整的事件分为三个阶段,手指按下,移动,抬起,而其他的双击,拖动等都是基于这些事件的

当指针按下时,Flutter 会对应用程序执行命中测试(Hit Test) ,以确定指针与屏幕接触的位置存在哪些 Widget,指针按下事件(以及该指针的后续事件)会被分发到由命中测试发现的最内部的组件,然后从哪里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件分发的组件树的根路径上的所有组件,这个 Web 开发浏览器的事件冒泡机制相似,但是 Flutter 中没有机制取消或者停止冒泡过程,而浏览器是可以停止的。

注意:只有通过命中测试的组件才能触发事件

原始指针事件处理

Flutter 中可以使用 Listener 来监听原始触摸事件,按照<Flutter实战> 中的分类,Listener 也是一个功能性组件,下面是 Listener 的构造函数定义:

Listener({
  Key key,
  this.onPointerDown, //手指按下回调
  this.onPointerMove, //手指移动回调
  this.onPointerUp,//手指抬起回调
  this.onPointerCancel,//触摸事件取消回调
  this.behavior = HitTestBehavior.deferToChild, //在命中测试期间如何表现
  Widget child
})
  • behavior 在后面专门介绍

示例:

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}),
    );
  }
}

效果如下:

image-20210330215620656

手指在蓝色区域内移动即可看到当前指针偏移,当触发指针事件时,参数 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 ,则可以使用 IgnorePointerAbsorbPointer,这两个组件都能阻止子树接受指针事件,不同之处在于 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 上显示事件名,如下:

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;
    });
  }
}

345

注意:当同时监听 onTop 和 onDoubleTap 时,当用户触发 tap 事件时,会有 200 毫秒的延时,这是因为可能会再次点击触发双击事件

如果只监听了 onTap,则不会有延时

拖动,滑动

一次完整的手势过程是指用户手指按下到抬起的整个过程,期间,用户按下后可能会移动,也可能不移动。

GestureDetector 对拖动和滑动事件时没有区分的,他们本质是一样的。

GestureDetector 会把要监听的组件的原点(左上角)作为本次手势的原点,当监听组件上手指按下时,手势识别就会开始。例:

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两个轴的),上例中没有处理抬起的速度,常见的效果是根据抬起手指的速度做一个减速动画

效果如下:

345
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 支持特定方向的手势事件,例如:

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 可以监听缩放事件,如下:

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);
      });
    },
  ),
);
345

上例比较简单,实际中我们可能还需要一些其他功能,如双击放大缩小,执行动画等,有兴趣的可以先尝试一下

GestureRecognizer

getstureDetector 内部是使用一个或者多个 GestureRecognizer 来识别各种手势的,而 GestureRecognizer 的作用就是通过 Listener 将原始指针转换为语义手势

GestureRecognizer 是一个抽象类,一种手势对应一个子类,Flutter 实现了丰富的手势识别器,我们可以直接使用。

例如:

我们要给一段富文本 (RichText) ,的不同部分添加事件处理器,但是 TextSpan 并不是一个 widget,所以不能用 GestureDetector。但是 TextSpan 有一个 Recongizer 属性,他可以接收一个 GestureRecognizer。

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 方法来释放资源(主要是取消内部的计时器),运行效果如下:

345

手势竞争与冲突

竞争

如在上例中,同时监听水平方向和垂直方向的拖动事件,那么斜着滑动时那个方向会生效? 实际上取决于第一次移动时两个轴上的位移分量,那个轴的大,那么哪个轴就会在本次滑动事件中胜出

实际上 Flutter 中引入了一个 Arenal 的概念,直译为 竞技场 的意思,每一个手势识别器(GestureRecognizer) 都是一个竞争者(GestureArenaMember),当发生滑动事件时,他们都要在 竞技场 去竞争本次事件的处理权,而最终只有一个竞争者会胜出。

例如有一个 ListView,他的第一个子组件也是 ListView,如果滑动子 ListView,父 ListView 会动吗?答案肯定是不会动的,这时只有子 ListView 会动,这是因为子 LsitView 货到了滑动事件的处理权。

示例

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,可以左右拖动,现在我们也想检测它上面手指按下和抬起的事件,如下:

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');
          },
        ),
      )
    ],
  );
}

拖动后,日志如下:

0I/flutter ( 4315): down
I/flutter ( 4315): onHorizontalDragEnd

我们发现没有打印 up,这是因为拖动时,在按下手指没有移动时,拖动手势还没有完整的语义,此时 TapDown 手势胜出,此时打印 down,而拖动时,拖动手势胜出,当抬起时, onHorizontalDragEnd 和 onTap 发生冲突,但是应为是在拖动的语义中,所以 onHorizeontalDragend 胜出,所以就会打印 onHorizontalDragEnd。

如果我们的逻辑代码中,对手指的按下和抬起时强依赖的,例如轮播组件,我们希望按下时暂停轮播,抬起时恢复轮播。但是由于轮播组件中本身可能已经处理了拖动手势,甚至支持了缩放手势,这时外部如果再用 onTapDown,onTap 来监听是不行的。

这个时候就可以同个 Listener 监听原始指针事件就行:

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 中,我们经常需要一个广播机制,用以夸页面事件通知,例如注销登录时,某些页面可能需要进行状态更新。这个时候一个事件总线便会非常有用;

事件总线通常实现了订阅者模式,订阅者包含订阅者和发布者两个角色,可以通过事件总线来触发事件和监听事件;

代码如下:

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();

使用如下:

//监听登录失效
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实战