Flutter 事件传递简单概述、事件冒泡、事件穿透

前言

当前案例 Flutter SDK版本:3.13.2

本文对 事件传递只做 简单概述 ,主要讲解,事件传递过程中可能遇到的问题解决,比如 事件冒泡事件穿透

不是我偷懒,是自认为没有这几位写的详细、仔细,非常建议先看完这几篇参考文档,不然下面讲解一些对象或者函数会不理解。

深入进阶-从一次点击探寻Flutter事件分发原理 - 掘金

Flutter分享:Flutter事件分发原理 - 掘金

8.3 Flutter事件机制 | 《Flutter实战·第二版》

8.4 手势原理与手势冲突 | 《Flutter实战·第二版》

Flutter事件传递简单概述

重要对象介绍

HitTestEntry: 可以把它看成 视图中的 手势监听组件 ,主要信息都在 target 属性中。

HitTestResult: 翻译为 命中测试结果 ,重点是它的 _path 集合保存着 HitTestEntry 对象;

重要函数介绍

hitTest(result,position) 翻译为 命中测试手势监听组件 内部会调用 的方法,如果返回true ,会将当前 手势监听组件 也就是 HitTestEntry 加入 HitTestResult._path 集合中,这只是默认规则 ,可以手动添加

核心代码:result.add(BoxHitTestEntry(this, position)),加入 HitTestResult._path 集合中;

还有查找 监听组件的顺序,是由深到浅的查找,比如 父子结构查找顺序:子孙手势组件、子手势组件、父手势组件,其他传统布局查找顺序:兄弟手势组件03、兄弟手势组件02、兄弟手势组件01。

那这个 hitTest函数的 布尔值是不是没用了 ?当然有用,后面会讲解,先忽略

最开始执行 的是 renderView.hitTest(result, position: position)renderView 表示 渲染树的根节点;

js 复制代码
class RenderView extends RenderObject with RenderObjectWithChildMixin<RenderBox> {

 
  bool hitTest(HitTestResult result, { required Offset position }) {
    
    // 这部分逻辑是父子结构的组件,才走的
    if (child != null) { 
      child!.hitTest(BoxHitTestResult.wrap(result), position: position);
    }

    // 你手指触摸位置的那个 手势监听组件,加入 HitTestResult._path 集合中
    result.add(HitTestEntry(this)); 
    return true;
  }

}


abstract class RenderBox extends RenderObject {

  // 父子结构的组件,走到这
  bool hitTest(BoxHitTestResult result, { required Offset position }) {   
    
    ... ...

    if (_size!.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
  }

}

常用的手势监听组件

Listener组件

只监听最原始的几种事件,down ==> move ==> ... ==> move ==> up ==> cancel;

比如 第一次将手指放在屏幕上 触发 Down 事件,手指没有离开屏幕前,手指位置发生改变 触发 Move 事件,每次位置改变都会触发一次 Move 事件,手指离开屏幕时触发 Up事件,紧接着 触发 Cancel事件;

常用的一些手势,比如 单击、双击、长按 等等,它都识别不了 ,也不负责处理事件冲突

js 复制代码
Listener(
    onPointerDown: (event) {
        debugPrint('onPointerDown');
    },
    child: Container(
        width: 100,
        height: 100,
        color: Colors.primaries[10],
    ),
)

GestureDetector

对Listener的封装后 的产物,内部加了很多 GestureRecognizer(手势识别器) ,每个识别器都代表一种手势监听 ,比如监听 单击、双击、长按、缩放 等等手势,以及可以通过自定义手势识别器 解决事件冲突 ,所以一般都用它

js 复制代码
GestureDetector(
  onTap: () {
    debugPrint('onTap');
  },
  child: Container(
    width: 100,
    height: 100,
    color: Colors.primaries[10],
  ),
)
js 复制代码
class GestureDetector extends StatelessWidget {

  ... ... 

  @override
  Widget build(BuildContext context) {

    ... ...

    // TapGestureRecognizer 单击手势识别器
    gestures[TapGestureRecognizer] = ... ...


    // DoubleTapGestureRecognizer 双击手势识别器
    gestures[DoubleTapGestureRecognizer] = ... ...

    ... ...

    return RawGestureDetector(
      ... ...
    );
  }
}

class RawGestureDetector extends StatefulWidget { 

  ... ...

  @override
  RawGestureDetectorState createState() => RawGestureDetectorState();
}

class RawGestureDetectorState extends State<RawGestureDetector> {

  ... ... 

  @override
  Widget build(BuildContext context) {

    Widget result = Listener( // 原始手势监听器
        ... ... 
    );
    
    ... ...

    return result;
  }

  ... ...

}

InkWell

对GestureDetector的封装 ,加了点击时出现水波纹效果,我项目里基本不用这东西。

注意:它这个水波纹效果,实现位置是在 Child 下面,所以Child 颜色要为透明,不然看不见;

一般是通过 Material 组件设置背景色,来解决这个问题。

js 复制代码
Material(
  color: Colors.greenAccent, // 设置背景色
  child: InkWell(
    onTap: () {
      debugPrint('onTap');
    },
    child: Container(
      width: 100,
      height: 100,
    ),
  ),
),
js 复制代码
class InkWell extends InkResponse {

  ... ...

}

class InkResponse extends StatelessWidget {

  ... ...

  @override
  Widget build(BuildContext context) {

    ... ...

    return _InkResponseStateWidget(
      ... ...
    );
  }

  ... ...

}

class _InkResponseStateWidget extends StatefulWidget {

  ... ... 

  @override
  _InkResponseState createState() => _InkResponseState();

  ... ...

}

class _InkResponseState extends State<_InkResponseStateWidget> with AutomaticKeepAliveClientMixin<_InkResponseStateWidget> implements _ParentInkResponseState {

  ... ... 

  @override
  Widget build(BuildContext context) {
    ... ...

    return _ParentInkResponseProvider(
        ... ...

        child: GestureDetector( // 手势监听器
             ... ...
        ),

      ),
    );
  }
  ... ...

}

事件传递过程

这个过程是我根据断点调试顺序构思的,如有错误,还请评论区留言,共勉。

默认传递过程

使用HitTestBehavior的传递过程

HitTestBehavior

翻译 命中测试行为 ,它不是一个对象,只是一个概念 ,让我们自己写 命中测试 逻辑 ,通过以下两个对象 实现。

RenderProxyBox: 它是RenderObject的子类 ,可以重写 hitTest 命中测试函数,从而修改事件传递过程,RenderObject 属于 渲染树无法直接Widget树 中使用,需要包一层 SingleChildRenderObjectWidget。

SingleChildRenderObjectWidget: 用来将 RenderObject 类型的组件,转换成 RenderObjectWidget ,让其 可以在 Widget树中 使用;

会涉及到两个知识点:

  1. 事件中断机制;
  2. 还有 hitTest 命中测试函数 返回布尔值 有什么用;

我都写在代码注释里

如果你想自定义手势 ,建议去研究 GestureDetector 里的 GestureRecognizer(手势识别器),因为它用的最多,有的组件 甚至提供了 GestureRecognizer类型参数。

js 复制代码
class MyListener extends SingleChildRenderObjectWidget {
  MyListener(
      {super.key,
        this.downEventListener,
        this.hitTestBehavior = MyHitTestBehavior.normal,
        super.child});

  PointerDownEventListener? downEventListener;
  MyHitTestBehavior hitTestBehavior;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return MyRenderListener(
        downEventListener: downEventListener, hitTestBehavior: hitTestBehavior);
  }

  @override
  void updateRenderObject(
      BuildContext context, covariant MyRenderListener renderObject) {
    renderObject.downEventListener = downEventListener;
    renderObject.hitTestBehavior = hitTestBehavior;
  }
}

class MyRenderListener extends MyRenderHitTestBehavior {
  MyRenderListener({this.downEventListener, super.hitTestBehavior});

  PointerDownEventListener? downEventListener;

  @override
  void handleEvent(PointerEvent event, covariant HitTestEntry<HitTestTarget> entry) {
    if (event is PointerDownEvent) {
      return downEventListener?.call(event);
    }
  }

}

abstract class MyRenderHitTestBehavior extends RenderProxyBox {
  MyRenderHitTestBehavior({this.hitTestBehavior = MyHitTestBehavior.normal});

  MyHitTestBehavior hitTestBehavior;

  @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {

    if(hitTestBehavior == MyHitTestBehavior.normal) { // 默认
      return super.hitTest(result, position: position);
    }

    if(hitTestBehavior == MyHitTestBehavior.ignore) {
      return false; // 强制命中测试失败
    }

    // 下面两个判断,区别在于 返回布尔值不一样

    // 同一容器内的 兄弟级别事件监听组件,只要有一个返回true,
    // 其他的都会返回false,这叫 事件中断机制,触发了这个机制,
    // 这些返回false的,将不参与 事件命中测试,即使加入了 HitTestResult.path 集合 也没用
    // 因为这些 事件监听组件的 handleEvent 没有触发

    // 注意:是触发了 中断机制 之后,这些返回false的 事件监听组件 才不参与 事件命中测试
    // 不是因为返回值是false,就不参与 事件命中测试,跟 false 没啥关系

    // 不触发 中断机制 的方法
    // 全部返回 false,这样只要在 HitTestResult.path 里的事件监听组件,都会被 分发事件

    if(hitTestBehavior == MyHitTestBehavior.opaque) {
      if(size.contains(position)) { // 点击的坐标,是否在 事件监听组件 范围内
        result.add(BoxHitTestEntry(this, position));
        return true; // 强制命中测试成功,会触发中断机制
      }
    }

    if (hitTestBehavior == MyHitTestBehavior.avoidInterruptions) {

      // 注意:这里我没有使用这个 范围判断,触发范围会变成 它父级组件 范围
      // if(size.contains(position))
      result.add(BoxHitTestEntry(this, position));
      return false; // 强制命中测试失败,不会触发中断机制
    }

    return false;
  }

  @override
  bool hitTestSelf(Offset position) => super.hitTestSelf(position);

// hitTestSelf函数 是父子结构组件 的判断条件 之一,你点开 super.hitTest(result, position: position);源码

// 父子结构组件
// return Listener( // 父组件
//   ... ...
//   child: Container(
//    ... ...
//     child: Listener( // 子组件
//       ... ...
//       child: Container(
//         ... ...
//       ),
//     ),
//   ),
// );

// super.hitTest(result, position: position); 源码:

// 重点代码:如果子组件全都 命中测试失败,那就判断 hitTestSelf函数的 返回值
// if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
//    ... ...
// }

// bool hitTest(BoxHitTestResult result, { required Offset position }) {
//   ... ...
//   if (_size!.contains(position)) {
//     if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
//       result.add(BoxHitTestEntry(this, position));
//       return true;
//     }
//   }
//   return false;
// }

}

enum MyHitTestBehavior {
  ignore, // 不参与 命中测试
  opaque, // 强制命中测试成功
  avoidInterruptions, // 避免触发中断机制
  normal  // 默认
}

使用 MyListener

js 复制代码
  Widget box(int index, double size) {
    return MyListener(
      // hitTestBehavior: MyHitTestBehavior.ignore, // 事件拦截
      hitTestBehavior: MyHitTestBehavior.avoidInterruptions, // 所有兄弟节点都会被分发事件
      downEventListener: (event) {
        debugPrint('index:$index');
      },
      child: Container(
        width: size,
        height: size,
        color: Colors.primaries[index],
      ),
    );
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SizedBox(
          width: MediaQuery.of(context).size.width,
          height: MediaQuery.of(context).size.height,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [

              Container(
                color: Colors.greenAccent,
                width: 150,
                height: 400,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: [
                    box(1, 100),
                    box(2, 100),
                    box(3, 100),
                    box(4, 100),
                  ],
                ),
              ),

            ],
          )),
    );
  }

事件冒泡

事件冒泡的产生原因

在父子结构组件中,父组件会先调用 hitTestChildren 方法,最后调用自身 的 hitTest方法;
父组件判断自身是否 命中测试 的条件 :只要有一个 子组件的 hitTest 方法 返回true ,父组件 hitTest方法 也会返回true ,导致它会执行handleEvent 方法,递归这个过程,就会产生事件冒泡

hitTestChildren(result, position) :执行子组件的 hitTest 方法;

js 复制代码
// 事件冒泡代码
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.center,
  children: [
    Listener(
      onPointerDown: (event) {
        debugPrint('Parent --- onPointerDown');
      },
      child: Container(
        width: 300,
        height: 300,
        margin: const EdgeInsets.only(bottom: 12),
        color: Colors.primaries[10],
        alignment: Alignment.center,
        child: Listener(
            onPointerDown: (event) {
              debugPrint('Child01 --- onPointerDown');
            },
            child: Container(
              width: 200,
              height: 200,
              margin: const EdgeInsets.only(bottom: 12),
              color: Colors.primaries[8],
              alignment: Alignment.center,
              child: Listener(
                onPointerDown: (event) {
                  debugPrint('Child02 --- onPointerDown');
                },
                child: Container(
                  width: 100,
                  height: 100,
                  margin: const EdgeInsets.only(bottom: 12),
                  color: Colors.primaries[11],
                ),
              )
            )
        ),
      ),
    ),
  ],
)

解决方式一:通过变量判断

js 复制代码
// 解决方式一:通过变量判断
Builder(
  builder: (context) {
    bool childEvent = false;
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        Listener(
          onPointerDown: (event) {
            if(!childEvent) {
              debugPrint('Parent --- onPointerDown');
            }
            childEvent = false;
          },
          child: Container(
            width: 300,
            height: 300,
            margin: const EdgeInsets.only(bottom: 12),
            color: Colors.primaries[10],
            alignment: Alignment.center,
            child: Listener(
                onPointerDown: (event) {
                  if(!childEvent) {
                    debugPrint('Child01 --- onPointerDown');
                    childEvent = true;
                  }
                },
                child: Container(
                    width: 200,
                    height: 200,
                    margin: const EdgeInsets.only(bottom: 12),
                    color: Colors.primaries[8],
                    alignment: Alignment.center,
                    child: Listener(
                      onPointerDown: (event) {
                        debugPrint('Child02 --- onPointerDown');
                        childEvent = true;
                      },
                      child: Container(
                        width: 100,
                        height: 100,
                        margin: const EdgeInsets.only(bottom: 12),
                        color: Colors.primaries[11],
                      ),
                    )
                )
            ),
          ),
        ),
      ],
    );
  }
),

解决方式二:使用GestureDetector

js 复制代码
// 使用GestureDetector解决
// 注意一:
// 有参数的事件回调,还是会触发冒泡,比如onTapDown(details),以此类推
// onTap():可以防止冒泡,onTapDown(details)不可以;
// onDoubleTap():可以防止冒泡,onDoubleTapDown(details)不可以;
//
// 注意二:而且它俩都是up事件,手指离开屏幕时才会触发
// ... ...
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.center,
  children: [
    GestureDetector(
      onTap: () {
        debugPrint('Parent --- onPointerDown');
      },
      child: Container(
        width: 300,
        height: 300,
        margin: const EdgeInsets.only(bottom: 12),
        color: Colors.primaries[10],
        alignment: Alignment.center,
        child: GestureDetector(
            onTap: () {
              debugPrint('Child01 --- onPointerDown');
            },
            child: Container(
                width: 200,
                height: 200,
                margin: const EdgeInsets.only(bottom: 12),
                color: Colors.primaries[8],
                alignment: Alignment.center,
                child: GestureDetector(
                  onTap: () {
                    debugPrint('Child02 --- onPointerDown');
                  },
                  child: Container(
                    width: 100,
                    height: 100,
                    margin: const EdgeInsets.only(bottom: 12),
                    color: Colors.primaries[11],
                  ),
                )
            )
        ),
      ),
    ),
  ],
),

事件穿透

在叠加布局中,两个组件是位置相同相互覆盖 ,且两个都注册了事件监听器,如何忽略盖在上面的组件事件,只触发底层组件的事件,这种场景出现的很少;

这里介绍一下 IgnorePointer 和 AbsorbPointer 组件,它们的原理就是让这些组件不参与命中测试 ,从而做到事件拦截

  • IgnorePointer组件:包裹的组件,以及子组件、子孙后代组件,都不参与命中测试;
  • AbsorbPointer组件:包裹组件的 子组件、子孙后代组件 不参与 命中测试,但不包括自身,点击子组件区域,还是会触发自身事件;

它俩都有一个是否启用 的布尔值参数,默认为true,表示启用,可以通过变量动态操控;

使用IgnorePointer ,包裹的组件事件被完全拦截,可以 做到事件穿透的效果,反之AbsorbPointer不可以

js 复制代码
// 在叠加布局中使用
Stack(
  alignment: Alignment.center,
  children: [
    Listener(
      onPointerDown: (event) {
        debugPrint('Child01 --- onPointerDown');
      },
      child: Container(
        width: 300,
        height: 300,
        margin: const EdgeInsets.only(bottom: 12),
        color: Colors.primaries[10],
      ),
    ),

    // Listener(
    //     onPointerDown: (event) {
    //       debugPrint('Child02 --- onPointerDown');
    //     },
    //     child: IgnorePointer(
    //       child: Container(
    //         width: 200,
    //         height: 200,
    //         margin: const EdgeInsets.only(bottom: 12),
    //         color: Colors.primaries[8],
    //       ),
    //     )
    // ),

    // 或者这样写 都可以

    // 拦截当前组件事件,但同一位置的底层组件,会被触发,相当于穿透了
    IgnorePointer(
      child: Listener(
          onPointerDown: (event) {
            debugPrint('Child02 --- onPointerDown');
          },
          child: Container(
            width: 200,
            height: 200,
            margin: const EdgeInsets.only(bottom: 12),
            color: Colors.primaries[8],
          )
      ),
    ),

    // 拦截当前组件事件,但同一位置的底层组件无法触发,无法穿透
    // AbsorbPointer(
    //   child: Listener(
    //       onPointerDown: (event) {
    //         debugPrint('Child02 --- onPointerDown');
    //       },
    //       child: Container(
    //         width: 200,
    //         height: 200,
    //         margin: const EdgeInsets.only(bottom: 12),
    //         color: Colors.primaries[8],
    //       )
    //   ),
    // ),

  ],
),

事件竞争

  • 当用户触摸屏幕时,可能同时触发好几种事件,这时候需要处理 事件冲突,确定哪一种 手势操作,Flutter提供了GestureArenaManager(手势竞技场) 对象,将每一个手势当作一个竞选者,进行了筛选;

GestureArenaManager: 官方视频:www.youtube.com/watch?v=Q85...

每个手势都有自己的判定条件,且每次竞争,只能有一个胜利者,举例:

  • 短按:手指按下 200毫秒
  • 长按:手指按下 500毫秒
  • ... ...

API 过时

以后要是 找不到 hitTest 函数,就找 hitTestInView 函数

官方文档

gestures library - Dart API

相关推荐
江上清风山间明月12 小时前
Flutter开发的应用页面非常多时如何高效管理路由
android·flutter·路由·页面管理·routes·ongenerateroute
Zsnoin能1 天前
flutter国际化、主题配置、视频播放器UI、扫码功能、水波纹问题
flutter
早起的年轻人1 天前
Flutter CupertinoNavigationBar iOS 风格导航栏的组件
flutter·ios
HappyAcmen1 天前
关于Flutter前端面试题及其答案解析
前端·flutter
coooliang1 天前
Flutter 中的单例模式
javascript·flutter·单例模式
coooliang1 天前
Flutter项目中设置安卓启动页
android·flutter
JIngles1231 天前
flutter将utf-8编码的字节序列转换为中英文字符串
java·javascript·flutter
B.-1 天前
在 Flutter 中实现文件读写
开发语言·学习·flutter·android studio·xcode
freflying11192 天前
使用jenkins构建Android+Flutter项目依赖自动升级带来兼容性问题及Jenkins构建速度慢问题解决
android·flutter·jenkins
机器瓦力2 天前
Flutter应用开发:对象存储管理图片
flutter