Flutter事件和手势原理

Pointer Event

指针事件(也称为触摸事件) 分为三个阶段

  1. 手指按下
  2. 手指移动
  3. 手指抬起

Listener 组件

Flutter中使用Listener Widget来监听指针事件

kotlin 复制代码
Listener({
  Key key,
  this.onPointerDown, //手指按下回调
  this.onPointerMove, //手指移动回调
  this.onPointerUp,//手指抬起回调
  this.onPointerCancel,//指针事件取消回调
  Widget child // 子组件
})
less 复制代码
// 查看手指相对于容器的位置
class _PointerMoveIndicatorState extends State<PointerMoveIndicator> {
  PointerEvent? _event;
  @override
  Widget 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),
    );
  }
}

PointerDownEvent、 PointerMoveEvent、 PointerUpEvent 都是PointerEvent的子类 PointerEvent参数

  • position:指针的全局坐标
  • localPosition: 指针本身布局坐标。
  • delta:两次PointerMoveEvent的间距。

忽略指针事件

  • 使用IgnorePointer和AbsorbPointer,让某个子树不响应PointerEvent
  • AbsorbPointer本身会参与Hit Test(响应指针事件),但其子树不行,而IgnorePointer本身不会参与
less 复制代码
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,那么两个都不会输出。

事件处理流程

  1. hit test

    当手指按下时,触发 PointerDownEvent 事件, 遍历当前渲染对象(render object)树,对每一个object进行hit test(命中测试),如果hit test通过,则该object添加到HitTestResult列表当中

  2. event dispatch(事件分发)

    2.1 hit test完毕后,遍历 HitTestResult 列表,调用每一个Render Object的事件处理方法(handleEvent)

    2.2 随后当手指移动时,便会分发 PointerMoveEvent 事件。

  3. 事件结束

    当手指抬( PointerUpEvent )起或事件取消时(PointerCancelEvent),清空 HitTestResult 列表。

css 复制代码
子Render Object比父object先响应事件。
因为hit test按照深度优先遍历的,所以子object会比父object先加入 HitTestResult 列表。
csharp 复制代码
// 事件处理流程
void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  if (event is PointerDownEvent ) {
    hitTestResult = HitTestResult();
    // 发起命中测试
    hitTest(hitTestResult, event.position);
    if (event is PointerDownEvent) {
      _hitTests[event.pointer] = hitTestResult;
    }
  } else if (event is PointerUpEvent || event is PointerCancelEvent) {
    //获取命中测试的结果,然后移除
    hitTestResult = _hitTests.remove(event.pointer);
  } else if (event.down) { // PointerMoveEvent
    //获取命中测试的结果
    hitTestResult = _hitTests[event.pointer];
  }
  // 事件分发
  if (hitTestResult != null) {
    dispatchEvent(event, hitTestResult);
  }
}

hit test过程

arduino 复制代码
@override
void hitTest(HitTestResult result, Offset position) {
  //从渲染树(RenderObject)根节点开始按照深度优先顺序递归进行命中测试
  renderView.hitTest(result, position: position);
  // 调用 GestureBinding 的 hitTest()方法
  super.hitTest(result, position);
}
// Render View的 hit test过程
bool hitTest(HitTestResult result, { Offset position }) {
  if (child != null)
    child.hitTest(result, position: position); //递归对子树进行命中测试
  //Render View根节点始终被添加到HitTestResult列表中
  result.add(HitTestEntry(this));
  return true;
}
// 以RenderBox 为例说明child的hitTest过程
bool hitTest(HitTestResult result, { @required Offset position }) {
  if (_size.contains(position)) { // 判断事件的触发位置是否位于组件范围内
    // hitTestChildren: 判断有没有子节点通过hit test
    // 重写hitTestSelf函数并返回true,"强行声明"当前节点通过了命中测试
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true; // 当前节点通过hit test
    }
  }
  return false;
}

event dispatch(事件分发)

csharp 复制代码
// 事件分发
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
  // 遍历HitTestResult,调用每一个节点的 handleEvent
  for (final HitTestEntry entry in hitTestResult.path) {
    entry.target.handleEvent(event.transformed(entry.transform), entry);
  }
}

所以组件只需重写 handleEvent 就可以处理事件。

GestureDetector(手势识别控件)

  • 用于手势识别的Widget
  • 内部封装了 Listener

点击、双击、长按

less 复制代码
// 使用GestureDetector对Container进行手势识别,触发后, 在Container上显示事件名
class _GestureTestState extends State<GestureTest> {
  String _operation = "No Gesture detected!"; //保存事件名
  @override
  Widget 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;
    });
  }
}

拖动、滑动

  • GestureDetector 对于拖动和滑动事件是没有区分的,本质上是一样的。
  • GestureDetector 会将要监听的组件的原点(左上角)作为本次手势的原点,当用户在监听的组件上按下手指时,手势识别就会开始。下面我们看一个拖动圆形字母A的示例:
scala 复制代码
class _Drag extends StatefulWidget {
  @override
  _DragState createState() => _DragState();
}
class _DragState extends State<_Drag> with SingleTickerProviderStateMixin {
  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: 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);
            },
          ),
        )
      ],
    );
  }
}
css 复制代码
I/flutter ( 8513): 用户手指按下:Offset(26.3, 101.8)
I/flutter ( 8513): Velocity(235.5, 125.8)

缩放

scala 复制代码
class _Scale extends StatefulWidget {
  const _Scale({Key? key}) : super(key: key);
  @override
  _ScaleState createState() => _ScaleState();
}
class _ScaleState extends State<_Scale> {
  double _width = 200.0; //通过修改图片宽度来达到缩放效果
  @override
  Widget 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);
          });
        },
      ),
    );
  }
}

GestureRecognizer(手势识别器)

  • GestureDetector内部是使用GestureRecognizer来识别各种手势的
  • GestureRecognizer通过Listener将指针事件转换为手势 TextSpan不是一个widget,但是可以接收GestureRecognizer来识别手势
scala 复制代码
// 点击富文本时文本变色
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; //变色开关
  @override
  void dispose() {
    //用到GestureRecognizer的话一定要调用其dispose方法释放资源
    _tapGestureRecognizer.dispose();
    super.dispose();
  }
  @override
  Widget 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: "你好世界"),
          ],
        ),
      ),
    );
  }
}

手势识别原理

手势的识别是在事件分发阶段的,GestureDetector 是一个 StatelessWidget, 包含了 RawGestureDetector,我们看一下它的 build 方法实现:

ini 复制代码
@override
Widget build(BuildContext context) {
  final  gestures = <Type, GestureRecognizerFactory>{};
  // 构建 TapGestureRecognizer
  if (onTapDown != null ||
      onTapUp != null ||
      onTap != null ||
  ) {
    gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
      () => TapGestureRecognizer(debugOwner: this),
      (TapGestureRecognizer instance) {
        instance
          ..onTapDown = onTapDown
          ..onTapUp = onTapUp
          ..onTap = onTap
      },
    );
  }
 
  return RawGestureDetector(
    gestures: gestures, // 传入手势识别器
    behavior: behavior,
    child: child,
  );
}
scss 复制代码
@override
Widget build(BuildContext context) {
   // 使用 Listener 监听指针事件 
  Widget result = Listener(
    onPointerDown: _handlePointerDown,
    behavior: widget.behavior ?? _defaultBehavior,
    child: widget.child,
  );
}  
 
void _handlePointerDown(PointerDownEvent event) {
  for (final GestureRecognizer recognizer in _recognizers!.values)
    recognizer.addPointer(event);
}  

继续看下 TapGestureRecognizer 的几个相关方法

less 复制代码
class CustomTapGestureRecognizer1 extends TapGestureRecognizer {
  void addPointer(PointerDownEvent event) {
    //会将 handleEvent 回调添加到 pointerRouter 中
    GestureBinding.instance!.pointerRouter.addRoute(event.pointer, handleEvent);
  }
 
  @override
  void handleEvent(PointerEvent event) {
    //进行手势识别,并决定是是调用 acceptGesture 还是 rejectGesture,
  }
 
  @override
  void acceptGesture(int pointer) {
    // 竞争胜出会调用
  }
  @override
  void rejectGesture(int pointer) {
    // 竞争失败会调用
  }
}
  1. PointerDownEvent 事件触发时,调用 TapGestureRecognizer 的 addPointer,在 addPointer 中将 handleEvent 方法添加到 pointerRouter 中保存起来。

  2. 手势发生变化时, 从pointerRouter中取出 GestureRecognizer 的 handleEvent 方法进行手势识别。

  3. 同一个手势应该只有一个GestureRecognizer生效,所以引入手势竞技场(Arena)的概念

    3.1 每一个GestureRecognizer都是一个GestureArenaMember,当发生指针事件时,他们都要在Arena去竞争本次事件的处理权,最终只有一个"竞争者"会胜出(win)。

    3.2 竞技场管理者(GestureArenaManager)就会通知其他竞争者失败。

    3.3 胜出者的 acceptGesture 会被调用,其余的 rejectGesture 将会被调用。

手势竞争的两个例子

  1. 如果一个组件同时监听水平和垂直方向的拖动手势,当斜着拖动时哪个方向的拖动手势回调会被触发?

    取决于第一次移动时两个轴上的位移分量,哪个轴的大,哪个轴在本次滑动事件竞争中就胜出。

  2. 一个ListView,它有个ListView子组件,如果滑动这个子ListView,因为子ListView胜出而获得滑动事件的处理权, 所以父ListView不会滑动。

多手势冲突

由于手势竞争最终只有一个胜出者,所以通过 GestureDetector 监听多种手势时,可能会产生冲突。假设有一个widget,它可以左右拖动,现在我们也想检测在它上面手指按下和抬起的事件,代码如下:

less 复制代码
class GestureConflictTestRouteState extends State<GestureConflictTestRoute> {
  double _left = 0.0;
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          left: _left,
          child: GestureDetector(
              child: CircleAvatar(child: Text("A")), //要拖动和点击的widget
              onHorizontalDragUpdate: (DragUpdateDetails details) {
                setState(() {
                  _left += details.delta.dx;
                });
              },
              onHorizontalDragEnd: (details){
                print("onHorizontalDragEnd");
              },
              onTapDown: (details){
                print("down");
              },
              onTapUp: (details){
                print("up");
              },
          ),
        )
      ],
    );
  }
}

现在按住圆形"A"拖动然后抬起手指,控制台日志如下:

bash 复制代码
I/flutter (17539): down
I/flutter (17539): onHorizontalDragEnd

没有打印"up",因为在拖动时,刚开始按下手指且没有移动时,拖动手势还没有完整的语义,此时TapDown手势胜出(win),打印"down",而拖动时,拖动手势会胜出,当手指抬起时,onHorizontalDragEnd 和 onTapUp发生了冲突,因为是在拖动的语义中,所以onHorizontalDragEnd胜出,就会打印 "onHorizontalDragEnd"。

解决手势冲突

有如下两种方法:

1. Listener

竞争只是针对手势的,而 Listener 是监听原始指针事件,指针事件并非语义的手势,所以不会走手势竞争的逻辑,所以不会相互影响。

less 复制代码
Positioned(
  top:80.0,
  left: _leftB,
  child: Listener(
    onPointerDown: (details) {
      print("down");
    },
    onPointerUp: (details) {
      //会触发
      print("up");
    },
    child: GestureDetector(
      child: CircleAvatar(child: Text("B")),
      onHorizontalDragUpdate: (DragUpdateDetails details) {
        setState(() {
          _leftB += details.delta.dx;
        });
      },
      onHorizontalDragEnd: (details) {
        print("onHorizontalDragEnd");
      },
    ),
  ),
)

2. 自定义 Recognizer

自定义手势识别器(Recognizer),重写rejectGesture 方法:在里面调用acceptGesture 方法,强制变成竞争的成功者了,这样它的回调也就会执行。

scala 复制代码
class CustomTapGestureRecognizer extends TapGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    //super.rejectGesture(pointer);
    //宣布成功
    super.acceptGesture(pointer);
  }
}
//创建一个新的GestureDetector,用自定义的 CustomTapGestureRecognizer 替换默认的
RawGestureDetector customGestureDetector({
  GestureTapCallback? onTap,
  GestureTapDownCallback? onTapDown,
  Widget? child,
}) {
  return RawGestureDetector(
    child: child,
    gestures: {
      CustomTapGestureRecognizer:
          GestureRecognizerFactoryWithHandlers<CustomTapGestureRecognizer>(
        () => CustomTapGestureRecognizer(),
        (detector) {
          detector.onTap = onTap;
        },
      )
    },
  );
}
less 复制代码
customGestureDetector( // 替换 GestureDetector
  onTap: () => 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,
      ),
    ),
  ),
);
相关推荐
旭日猎鹰37 分钟前
Flutter踩坑记录(三)-- 更改入口执行文件
flutter
旭日猎鹰37 分钟前
Flutter踩坑记录(一)debug运行生成的项目,不能手动点击运行
flutter
️ 邪神37 分钟前
【Android、IOS、Flutter、鸿蒙、ReactNative 】自定义View
flutter·ios·鸿蒙·reactnative·anroid
Dnelic-3 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen5 小时前
MTK Android12 user版本MtkLogger
android·framework
比格丽巴格丽抱12 小时前
flutter项目苹果编译运行打包上线
flutter·ios
长亭外的少年12 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
SoaringHeart12 小时前
Flutter进阶:基于 MLKit 的 OCR 文字识别
前端·flutter
建群新人小猿15 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
1024小神16 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri