likes
comments
collection
share

Flutter中用户交互事件

作者站长头像
站长
· 阅读数 44

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

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

指针事件

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

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

在手指接触屏幕,触摸事件发起时,Flutter 会确定手指与屏幕发生接触的位置上究竟有哪些组件,并将触摸事件交给最内层的组件去响应。与浏览器中的事件冒泡机制类似,事件会从这个最内层的组件开始,沿着组件树向根节点向上冒泡分发。

不过 Flutter 无法像浏览器冒泡那样取消或者停止事件进一步分发,我们只能通过 hitTestBehavior 去调整组件在命中测试期内应该如何表现,比如把触摸事件交给子组件,或者交给其视图层级之下的组件去响应。

Listener(
        child: Container(
          color: Colors.red,// 背景色红色
          width: 300,
          height: 300,
        ),
        onPointerDown: (PointerDownEvent event) {
          // 手势按下回调
          print("down $event");
        },
        onPointerMove: (PointerMoveEvent event){
          // 手势移动回调
          print("move $event");
        },
        onPointerUp: (PointerUpEvent event){
          // 手势抬起回调
          print("up $event");
        },
      )

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

flutter: down PointerDownEvent#b96b7(position: Offset(194.0, 437.7))
flutter: up PointerUpEvent#80dfc(position: Offset(194.0, 437.7))
flutter: down PointerDownEvent#d6970(position: Offset(160.3, 491.7))

参数PointerDownEvent、PointerMoveEvent、PointerUpEvent都是PointerEvent的一个子类,PointerEvent类中包括当前指针的一些信息,如:

  • position:它是鼠标相对于当对于全局坐标的偏移。
  • delta:两次指针移动事件(PointerMoveEvent)的距离。
  • pressure:按压力度,如果手机屏幕支持压力传感器(如iPhone的3D Touch),此属性会更有意义,如果手机不支持,则始终为1。
  • orientation:指针移动方向,是一个角度值。

behavior属性

behavior属性,它决定子组件如何响应命中测试,它的值类型为HitTestBehavior,这是一个枚举类,有三个枚举值:

  • deferToChild:子组件会一个接一个的进行命中测试,如果子组件中有测试通过的,则当前组件通过,这就意味着,如果指针事件作用于子组件上时,其父级组件也肯定可以收到该事件。

  • opaque:在命中测试时,将当前组件当成不透明处理(即使本身是透明的),最终的效果相当于当前Widget的整个区域都是点击区域。举个例子:

class TestOpaque extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: red,
      child: 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") 。 如果我们想让整个300×150的矩形区域都能点击我们可以将behavior设为HitTestBehavior.opaque。注意,该属性并不能用于在组件树中拦截(忽略)事件,它只是决定命中测试时的组件大小。

  • translucent:当点击组件透明区域时,可以对自身边界内及底部可视区域都进行命中测试,这意味着点击顶部组件透明区域时,顶部组件和底部组件都可以接收到事件,例如
class TestTranslucent extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return 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, //放开此行注释后可以"点透"
        )
      ],
    );
  }
}

当注释掉behavior: HitTestBehavior.translucent,在左上角200*100范围内非文本区域点击时(顶部组件透明区域),控制台只会打印“down0”,也就是说顶部组件没有接收到事件,而只有底部接收到了。当放开注释后,再点击时顶部和底部都会接收到事件,此时会打印

flutter: down0
flutter: down1

忽略事件

有的时候,我们不需要相应PointEvent,这个时候可以使用IgnorePointer和AbsorbPointer。但是两个是有区别的

  • IgnorePointer:此节点和其子节点都惊忽略点击事件
  • AbsorbPointer:这个控件本身是能够响应点击事件的,他做的是阻止事件传递到他的子节点上
class IgnoreWidget extends StatefulWidget {
  @override
  _IgnoreWidget createState() => new _IgnoreWidget();
}

class _IgnoreWidget extends State<IgnoreWidget> {
  bool _ignore = true;
  @override
  Widget build(BuildContext context) {
    return Center(
        child:Column(
          children: <Widget>[
            Switch(value: _ignore, onChanged: (value){
              setState(() {
                _ignore = value;
              });
            }),
            GestureDetector(
              onTap: () => print("GestureDetector Clicked"),
              child: IgnorePointer(
                ignoring: _ignore,
                child: RaisedButton(
                  onPressed: () => print("IgnorePointer clicked"),
                  child: Text("IgnorePointer"),
                ),
              ),
            ),
            GestureDetector(
              onTap: () => print("GestureDetector Clicked"),
              child: AbsorbPointer(
                absorbing: _ignore,
                child: RaisedButton(
                  onPressed: () => print("AbsorbPointer clicked"),
                  child: Text("AbsorbPointer"),
                ),
              ),
            )
          ],
        )
    );
  }
}

当我们开始忽略事件时,点击AbsorbPointer,控制台就会打印。而点击IgnorePointer按钮之后,控制台不会打印

手势识别

1、GestureDetector

GestureDetector是一个用于手势识别的功能性组件,我们通过它可以来识别各种手势。GestureDetector实际上是指针事件的语义化封装

  • 点击事件
    • onTapDown:按下时回调。
    • onTapUp:抬起时回调。
    • onTap:点击事件回调。
    • onTapCancel:点击取消事件回调。
  • 双击事件:双击是快速且连续2次在同一个位置点击,双击监听使用onDoubleTap方法
  • 长按事件:长按事件(LongPress)包含长按开始、移动、抬起、结束事件
    • onLongPressStart:长按开始事件回调
    • onLongPressMoveUpdate:长按移动事件回调。
    • onLongPressUp:长按抬起事件回调。
    • onLongPressEnd:长按结束事件回调。
    • onLongPress:长按事件回调。
  • 拖动事件
    • onPanDown:手指按下时会触发此回调
    • onPanUpdate:手指滑动时会触发此回调
    • onPanEnd:结束回调
  • 水平/垂直拖动事件:垂直/水平拖动事件包括按下、开始、移动更新、结束、取消事件
    • onVerticalDragDown:垂直拖动按下事件回调
    • onVerticalDragStart:垂直拖动开始事件回调
    • onVerticalDragUpdate:指针移动更新事件回调
    • onVerticalDragEnd:垂直拖动结束事件回调
    • onVerticalDragCancel:垂直拖动取消事件回调
  • 缩放事件:缩放(Scale)包含缩放开始、更新、结束。
    • onScaleStart:缩放开始事件回调。
    • onScaleUpdate:缩放更新事件回调。
    • onScaleEnd:缩放结束事件回调。
class _GestureDetectorWidgetState extends State<GestureDetectorWidget> {
  // 红色 container 坐标
  double _top = 0.0;
  double _left = 0.0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          top: _top,
          left: _left,
          child: GestureDetector(// 手势识别
            child: Container(color: Colors.red,width: 50,height: 50),// 红色子视图
            onTap: ()=>print("Tap"),// 点击回调
            onDoubleTap: ()=>print("Double Tap"),// 双击回调
            onLongPress: ()=>print("Long Press"),// 长按回调
            //手指按下时会触发此回调
            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);
            },
          ),
        )
      ],
    );
  }
}
  • DragDownDetails.globalPosition:当用户按下时,此属性为用户按下的位置相对于屏幕(而非父组件)原点(左上角)的偏移。
  • DragUpdateDetails.delta:当用户在屏幕上滑动时,会触发多次Update事件,delta指一次Update事件的滑动的偏移量。
  • DragEndDetails.velocity:该属性代表用户抬起手指时的滑动速度(包含x、y两个轴的),示例中并没有处理手指抬起时的速度,常见的效果是根据用户抬起手指时的速度做一个减速动画

GestureRecognizer

GestureDetector内部是使用一个或多个GestureRecognizer来识别各种手势的,而GestureRecognizer的作用就是通过Listener来将原始指针事件转换为语义手势,GestureDetector直接可以接收一个子widget。GestureRecognizer是一个抽象类,一种手势的识别器对应一个GestureRecognizer的子类,Flutter实现了丰富的手势识别器,我们可以直接使用。

假设我们要给一段富文本(RichText)的不同部分分别添加点击事件处理器,但是TextSpan并不是一个widget,这时我们不能用GestureDetector,但TextSpan有一个recognizer属性,它可以接收一个GestureRecognizer。

class _GestureRecognizerStatusState extends State<GestureRecognizerStatus> {
  TapGestureRecognizer _tapGestureRecognizer = new TapGestureRecognizer();
  bool _toggle = false;  

  @override
  void dispose() {
    //用到GestureRecognizer的话一定要调用其dispose方法释放资源
    _tapGestureRecognizer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text.rich(
          TextSpan(
              children: [
                TextSpan(text: "Hello Flutter "),
                TextSpan(
                  text: "点我有变化",
                  style: TextStyle(
                      fontSize: _toggle ? 15.0 : 20.0,
                      color: Colors.red
                  ),
                  recognizer: _tapGestureRecognizer
                    ..onTap = () {
                      setState(() {
                        _toggle = !_toggle;
                      });
                    },
                ),
              ]
          )
      ),
    );
  }
}

注意:使用GestureRecognizer后一定要调用其dispose()方法来释放资源(主要是取消内部的计时器)。

手势竞争与冲突

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

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

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

在下面的示例中,我定义了两个嵌套的 Container 容器,分别加入了点击识别事件:

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

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

为了让父容器也能接收到手势,我们需要同时使用 RawGestureDetector 和 GestureFactory,来改变竞技场决定由谁来响应用户事件的结果。 在此之前,我们还需要自定义一个手势识别器,让这个识别器在竞技场被 PK 失败时,能够再把自己重新添加回来,以便接下来还能继续去响应用户事件。

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

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

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

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

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

RawGestureDetector(// 自己构造父 Widget 的手势识别映射关系
  gestures: {
    // 建立多手势识别器与手势识别工厂类的映射关系,从而返回可以响应该手势的 recognizer
    MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
        MultipleTapGestureRecognizer>(
          () => MultipleTapGestureRecognizer(),
          (MultipleTapGestureRecognizer instance) {
        instance.onTap = () => print('parent tapped ');// 点击回调
      },
    )
  },
  child: Container(
    color: Colors.pinkAccent,
    child: Center(
      child: GestureDetector(// 子视图可以继续使用 GestureDetector
        onTap: () => print('Child tapped'),
        child: Container(
              color: Colors.blueAccent,
              width: 200.0,
              height: 200.0
            )
      ),
    ),
  ),
);

运行一下这段代码,我们可以看到,当点击蓝色容器时,其父容器也收到了 Tap 事件

flutter: Child tapped
flutter: parent tapped

代码地址:github.com/SunshineBro…

转载自:https://juejin.cn/post/6919402878754881543
评论
请登录