Flutter|初探事件处理(二)

上篇通过一个需求熟悉了Flutter中常见的几种事件处理相关组件,这一篇再通过一个Bug具体了解下Flutter事件分发过程。

发现问题

对于视频帖子,点击视频区域会出现蒙层,可以进行拖动进度、全屏观看等操作。在一次体验中,突然发现全屏按钮点击无响应,于是开始排查。


定位原因

首先验证全屏按钮的点击事件是否正常,经测试发现事件本身没有问题,而是根本没有接收到点击事件。那事件被谁消费了呢?于是又扩大范围找相关的布局和组件。
视频和视频蒙层都收拢在MediaContentDetailWidget组件,因为在其他场景表现是正常的,所以问题出在上一层。视频区底部有个工具栏,通过Stack布局组合的,发现最近有一笔改动是加了个渐变背景,如图:

尝试去掉这个背景之后可以正常全屏了!那么前后区别到底是什么呢?toolbar是宽度自适应的(Row mainAxisSize: MainAxisSize.min),没加背景之前是不会挡住全屏按钮,设置了背景后会尽可能占用水平方向的空间,因此正好挡住了它。

此时又产生了一个疑问,虽然工具栏置于视频上方,但仅仅是工具icon有点击行为,整体并没设置点击事件,为什么会影响到事件继续传递到后面的组件?
有这种想法源于在原生的事件分发中,会先根据view定义顺序和z轴高度创建分发列表preorderedList,然后从列表第一个开始检查子view是否在点击范围内以及是否能接受触摸事件,满足条件的view基本都会通过onTouchEvent处理事件,也就是使用onClickListeneronLongClickListener消费事件,如果不能消费就继续循环寻找合适的子view。所以一般判断view能否消费事件就看有没有绑定单击和双击事件。

实际上Flutter事件分发的与Android原理一致,只是细节上有区别:仍然按照深度优先遍历渲染树,但在创建分发列表HitTestResult时就会判断好是否在点击范围内、能否消费事件等,而判断消费事件的依据不同,不是看有无绑定事件,而是看hitTest结果是否为true(具体是先调用hitTestChildren() 判断有无子节点通过命中测试,若没有再调用hitTestSelf)。这样分发过程就变简单了,遍历HitTestResult调用每一个节点的 handleEvent方法即可。
实例中的渐变背景使用了DecoratedBoxRenderDecoratedBox)组件,查看源码可知点击后其hitTestSelf会返回true,所以直接通过命中测试,导致 StackRenderStack)的 hitTestChildren 直接返回,就不再遍历其它子节点了。

/// 1-RenderDecoratedBox
class RenderDecoratedBox extends RenderProxyBox {
  ...
  @override
  bool hitTestSelf(Offset position) {
    // 调用 Decoration # hitTest 
    return _decoration.hitTest(size, position, textDirection: configuration.textDirection);
  }
  ...
}  
/// 2-Decoration
abstract class Decoration with Diagnosticable {
  ...
  /// 默认消费
  bool hitTest(Size size, Offset position, { TextDirection? textDirection }) => true;
  ...
}
/// 3-RenderStack
class RenderStack extends RenderBox
    with ContainerRenderObjectMixin<RenderBox, StackParentData>,
         RenderBoxContainerDefaultsMixin<RenderBox, StackParentData> {
  ...
  @override
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    return defaultHitTestChildren(result, position: position);
  }
  
  bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
    ChildType? child = lastChild;
    while (child != null) {
      ...
      // 找到一个返回停止遍历
      if (isHit)
        return true;
      child = childParentData.previousSibling;
    }
    return false;
  }
  ...
}

实际上很多组件的hitTestSelf是true,如TextRenderParagraph)、ImageRenderImage)、ColoredBox(会判断 behavior是否为HitTestBehavior.opaque,默认是)。因为Container是组合容器,如果有设置颜色color和背景decoration的话也会默认消费。

解决办法

想让组件阻止自己或子组件消费点击事件可以使用 IgnorePointerAbsorbPointer ,原理就是hitTestChildren直接返回false,其后代将无法参与命中测试。两者区别是 AbsorbPointer 本身还会接收指针事件但子组件不会, IgnorePointer 本身和子组件都不会。
在这个实例中,因为组件优先级和能否点击关系是:视频组件(需要点击) < 渐变背景(不需要点击) < 工具栏(需要点击),所以不能把渐变背景和工具栏绑定在一起再使用 IgnorePointer,而是将背景单拎出来,这样就不会影响到两边的点击了。

总结

对于常用的线性布局(RowColumn)和流式布局(Wrap)因为不存在堆叠关系点击层级是比较直观是不容易出错的,需要关注的是层叠布局Stack上的组件,组件可重叠、部分场景需要上层组件背景很浅是甚至透明以凸显后面的组件,修改时多考虑下是否会影响到后面的组件。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容