Flutter 用户交互事件

前言

在 Flutter 中手势操作分为两类:

  • 原始的指针事件(Pointer Event),即原生开发中常见的触摸事件,表示屏幕上触摸(或鼠标、手写笔)行为触发的位移行为;
  • 手势识别(Gesture Detector),表示多个原始指针事件的组合操作,如点击、双击、长按等,是指针事件的语义化封装。

指针事件

指针事件表示用户交互的原始触摸数据,如手指接触屏幕 PointerDownEvent、手指在屏幕上移动 PointerMoveEvent、手指抬起 PointerUpEvent,以及触摸取消 PointerCancelEvent,这与原生系统的底层触摸事件抽象是一致的。

关于组件层面的原始指针事件的监听,Flutter 提供了 Listener Widget,可以监听其子 Widget 的原始指针事件。

案例

定义一个宽度为300 的红色正方形 Container,利用 Listener 监听其内部 Down、Move 及 Up 事件:

在红色正方形区域内进行触摸点击、移动、抬起,可以看到 Listener 监听到一系列原始指针事件,并打印出了这些事件的位置信息:

打印 Log

手势识别

使用 Listener 可以直接监听指针事件、不过指针事件毕竟太原始了,如果想要获取更多的触摸事件细节,比如判断用户是否正在拖拽控件,直接使用指针事件的话就会非常复杂。

通常情况下,响应用户交互行为的话,我们会使用封装了手势语义操作的 Gesture,如点击 onTap、双击 onDoubleTap、长按 onLongPress、拖拽 onPanUpdate、缩放 onScaleUpdate 等。另外,Gesture 可以支持同时分发多个手势交互行为,意味着我们可以通过 Gesture 同时监听多个事件。

Gesture 是手势语义的抽象,而如果我们想从组件层监听手势,则需要使用 GestureDetector。GestureDetector 是一个处理各种高级用户触摸行为的 Widget,与 Listener 一样,也是一个功能性组件。

案例

定义一个 Stack 层叠布局,使用 Postioned 组件将 1 个红色的 Container 放置在左上角,并同时监听点击、双击、长按和拖拽事件。并在拖拽事件的回调方法中,更新 Container 的位置:

运行这段代码,并查看控制台输出,可以看到,红色的 Container 除了可以响应我们的拖拽行为外,还能够同时响应点击、双击、长按这些事件。

打印点击、双击、长按 Log
拖拽示例图

尽管在上面的例子中,我们对一个 Widget 同时监听了多个手势事件,但最终只会有一个手势能够得到本次事件的处理权。对于多个手势的识别,Flutter 引入了手势竞技场(Arena)的概念,用来识别究竟哪个手势可以响应用户事件。手势竞技场会考虑用户触摸屏幕的时长、位移以及拖动方向,来确定最终手势。

那么手势竞技场具体是怎么实现的呢?

实际上,GestureDetector 内部对每一个手势都建立了一个工厂类(Gesture Factory)。而工厂类的内部会使用手势识别类(GestureRecognizer),来确定当前处理的手势。

而所有手势的工厂类都会被交给 RawGestureDetector 类,以完成监测手势的大量工作:使用 Listener 监听原始指针事件,并在状态改变时把信息同步给所有的手势识别器,然后这些手势会在竞技场决定最后由谁来响应用户事件。

通常手势识别发生在多个存在父子关系的视图时,手势竞技场会一并检查父视图和子视图的手势,并且通常最终会确认由子视图来响应事件。而这也是合乎常理的:从视觉效果上看,子视图的视图层级位于父视图之上,相当于对其进行了遮挡,因此从事件处理上看,子视图自然是事件响应的第一责任人。

案例

定义两个嵌套的 Container 容器,分别加入了点击识别事件:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: GestureDetector(
          onTap: () => print('Parent tapped'), // 父视图的点击回调
          child: Container(
            color: Colors.pinkAccent,
            child: Center(
              child: GestureDetector(
                onTap: () => print('Child tapped'), // 子视图的点击回调
                child: Container(
                  color: Colors.blueAccent,
                  width: 200.0,
                  height: 200.0,
                ),
              ),
            ),
          ),
        ));
  }
父子嵌套 GestureDetector 示例

在蓝色区域进行点击,可以发现:尽管父容器也监听了点击事件,但 Flutter 只响应了子容器的点击事件。

点击蓝色区域打印 Log

为了让父容器也能接收到手势,我们需要同时使用 RawGestureDetector 和 GestureFactory,来改变竞技场决定由谁来响应用户事件的结果。

在此之前,我们还需要自定义一个手势识别器,让这个识别器在竞技场被 PK 失败时,能够再把自己重新添加回来,以便接下来还能继续去响应用户事件。

定义了一个继承自点击手势识别器 TapGestureRecognizer 的类,并重写了其 rejectGesture 方法,手动地把自己又复活了:

import 'package:flutter/gestures.dart';

class MultipleTapGestureRecognizer extends TapGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }
}

接下来,需要将手势识别器和其工厂类传递给 RawGestureDetector,以便用户产生手势交互事件时能够立刻找到对应的识别方法。事实上,RawGestureDetector 的初始化函数所做的配置工作,就是定义不同手势识别器和其工厂类的映射关系。

这里,由于只需要处理点击事件,所以只配置一个识别器即可。工厂类的初始化采用 GestureRecognizerFactoryWithHandlers 函数完成,这个函数提供了手势识别对象创建,以及对应的初始化入口。

在下面的代码中,完成了自定义手势识别器的创建,并设置了点击事件回调方法。需要注意的是,由于我们只需要在父容器监听子容器的点击事件,所以只需要将父容器用 RawGestureDetector 包装起来就可以了,而子容器保持不变:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        body: RawGestureDetector(
          gestures: {
            MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                MultipleTapGestureRecognizer>(
              () => MultipleTapGestureRecognizer(),
              (MultipleTapGestureRecognizer instance) {
                instance.onTap = () => print('Parent tapped'); // 父视图的点击回调
              },
            )
          },
          child: Container(
            color: Colors.pinkAccent,
            child: Center(
              child: GestureDetector(
                onTap: () => print('Child tapped'), // 子视图的点击回调
                child: Container(
                  color: Colors.blueAccent,
                  width: 200.0,
                  height: 200.0,
                ),
              ),
            ),
          ),
        ));
  }

再次点击蓝色区域,其父容器也收到了 Tap 事件。

点击蓝色区域打印 Log

总结

在处理多个手势识别场景,很容易出现手势冲突的问题。比如,当需要对图片进行点击、长按、旋转、缩放、拖动等操作的时候,如何识别用户当前是点击还是长按,是旋转还是缩放。如果想要精确地处理复杂交互手势,我们势必需要介入手势识别过程,解决异常。

不过需要注意的是,冲突的只是手势的语义化识别过程,原始指针事件是不会冲突的。所以,在遇到复杂的冲突场景通过手势很难搞定时,我们也可以通过 Listener 直接识别原始指针事件,从而解决手势识别的冲突。

你可能感兴趣的:(Flutter 用户交互事件)