Flutter 事件处理机制和自定义Widget事件响应

  1. Flutter事件处理

    1. 事件预处理

应用事件都是操作系统从硬件中断收集然后传递给应用程序,在flutter中事件也是由Window类通过

onPointerDataPacket来接收硬件传递过来的事件,也就是说该方法是flutter接收事件的源头,而该方法是在类GestureBinding初始化的时候设置的。所以在类GestureBinding的方法_handlePointerDataPacket中开始处理事件。

针对手势的处理流程可以参考下图

image.png

GestureBinding的源码中可以看到具体的实现,它主要做了以下三步操作。

1.接收事件

由于接收硬件的数据都是与真实设备相关的,所以需要通过PointerEventConverterexpand方法来隔离设备相关性。类似Android中的pxdp

只有将数据进行设备隔离后才能放入FIFO的双端队列——ListQueue中。笔者认为这里使用队列是为了防止Widget处理不及时从而导致阻塞。如下图所示。

image.png

2.Down事件处理

Android一样,Down事件在flutter中也是非常重要的一个事件,它是从ListQueue中取出的第一个事件。通过该事件,flutter可以获取到目标Widget到根Widget的路径,并将路径上的所有Widget添加到一个集合List中。如下图所示。

image.png

注意: 添加到集合中的Widget必须都要继承自RenderObjectWidget

将路径上的所有Widget添加到集合后,就会遍历该集合,并将Down事件交给集合中的所有Widget处理。如下图所示。

image.png

上图中WidgetHandlerEvent函数基本上都是空实现,但Listener例外。所以基本上都是通过Listener来监听手势事件。

3.其他事件处理

Down事件处理完毕以后,其他事件(如moveup等)会遍历集合,并将事件传递给集合中的所有Widget处理,如下图所示。

image.png

上图中WidgetHandlerEvent函数基本上都是空实现,但Listener例外。所以基本上都是通过Listener来监听手势事件。

可以发现事件是从目标WidgetRenderView(根Widget)传递的。如果是做前端开发的,想必对这一流程比较熟悉,因为这与前端开发中浏览器的事件冒泡机制相似, 但在flutter中是没有机制来取消或停止”冒泡“过程的,而浏览器的冒泡是可以停止的。

  1. flutter事件响应

在事件响应之前,需要对命中对象进行测试,并确认是由谁来响应,这就是hitTest的过程。在hitTest前会创建HitTestResult对象并向HitTestResult添加HitTestEntry命中对象最后保存到_hitTests表中;

hitTests命中测试

执行GestureBindinghitTest会先经过RenderBinding去执行hitTests方法,首先RenderView根节点判断是否存在子节点并判断子节点是否在命中范围内。

/flutter/packages/flutter/lib/src/rendering/binding.dart:RendererBinding->hitTest

@override
  void hitTest(HitTestResult result, Offset position) {
    renderView.hitTest(result, position: position);
    super.hitTest(result, position);
  }

1-RenderView:hitTest

/flutter/packages/flutter/lib/src/rendering/view.dart:RenderView -> hitTest

bool hitTest(HitTestResult result, { required Offset position }) {
    if (child != null) { // 子布局是否命中
      child!.hitTest(BoxHitTestResult.wrap(result), position: position);
    }
    result.add(HitTestEntry(this));
    return true;
  }

2-RenderBox:hitTest

RenderView根节点的子节点是RenderSemanticsAnnotations根本上是继承自RenderBox。默认情况下RenderBoxhitTestSelfhitTestChildren返回都是false,具体命中逻辑由继承者自行重写实现(hitTestChildren是具备有多子节点布局来实现默认情况下false表示无子节点)。

/flutter/packages/flutter/lib/src/rendering/box.dart:RenderBox -> hitTest

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;
  }
  @protected
  bool hitTestSelf(Offset position) => false;

  @protected
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) => false;

若命中了自身hitTestSelf或子级hitTestChildren时添加BoxHitTestEntry(继承自HitTestEntry);hitTestChildren又是递归调用因此组件树命中测试是深度优先遍历,符合命中规则子节点会比父节点先被添加到****HitTestResult****队列中例如RenderDecoratedBoxhitTestSelf重写方法通过DecorationhitTest进行判断是否在命中范围内。

@override
  bool hitTestSelf(Offset position) {
    return _decoration.hitTest(size, position, textDirection: configuration.textDirection);
  }
  1. flutter事件处理

    1. dispatchEvent事件分发

    void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
    
        if (hitTestResult == null) {
          try {
            pointerRouter.route(event); // 暂时忽略
          } catch (exception, stack) {
            ...
          }
          return;
        }
        for (final HitTestEntry entry in hitTestResult.path) {
          try {
            entry.target.handleEvent(event.transformed(entry.transform), entry);
          } catch (exception, stack) {
            ...
          }
        }
      }
    
  2. pointerRouter接收事件类

  3. 遍历HitTestResultHitTestEntry数组执行handleEvent方法

    遍历顺序是从最底层子节点开始往上走,从最后的HitTestEntry可以看到分别是RenderViewGestureBinding两个类处理事件分发。

    命中测试符合要求节点记录下来后通过该方法进行遍历操作并执行每个节点handleEvent方;handleEvent由每个节点重写实现具体逻辑。

    1. 事件拦截

事件既然有分发,那么肯定也能够拦截,相对于Android而言,flutter的事件拦截要简单很多。在flutter中,可以通过AbsorbPointerIgnorePointer这两个Widget来拦截事件。

AbsorbPointer

AbsorbPointer是一个Widget,它的主要作用就是拦截其子Widget响应事件。它的实现原理其实很简单,就是在响应Down事件时,不会将其子Widget添加到集合中,这样其子Widget就无法接收到事件。如下图所示。

image.png

AbsorbPointer中可以修改absorbing的值来让子Widget响应事件。

注意:AbsorbPointer本身可以响应事件

IgnorePointer

IgnorePointerAbsorbPointer类似,不同的是IgnorePointer设置为不响应事件时(即ignoring = true),IgnorePointer的child不响应事件,但是事件会传递到下一层;

image.png

IgnorePointer中可以修改ignoring的值来让自己及其子Widget响应事件。

注意:IgnorePointer本身无法响应事件

  1. 自定义widget和事件响应

flutter有“处处都是widget”的概念,对widget的自定义通常可以组合widget来完成UI绘制,但有时候我们也需要通过通过CustomPaint自绘定制控件。简单widget自绘制可以使用通过RenderObject自绘,复杂的控flutter为自绘制控件提供了ContainerRenderObjectMixin 和 RenderBoxContainerDefaultsMixin 两个 mixin,这两个 mixin 实现了通用的绘制和事件处理相关逻辑。

渲染节点数据

在组合多个结点完成自绘制时存在父节点和叶节点的树形结构,在 Flutter 的布局系统中,该类负责存储父节点所需要的子节点的布局信息,当然该信息偶尔也会用于子节点的布局。

每个 RenderObject 类中都有 parentData 这样一个成员,该成员只能通过 setupParentData 方法赋值,RenderObject 的子类可以通过重写该方法将 ParentData 的子类赋值给 parentdata,以扩展 ParentData 的功能:

void setupParentData(covariant RenderObject child) {
    if (child.parentData is! ParentData)
    child.parentData = ParentData();
    }

接下来看一下该类的 Hierarchy 结构:

ParentData 类继承结构

image.png

先无视用于滑动的 Sliver 相关的类和用于表格布局的 TabelCellParentData,我们来分析一下剩余的 ParentData类的作用。

ParentData

class ParentData {
  /// Called when the RenderObject is removed from the tree.
  @protected
  @mustCallSuper
  void detach() { }

  @override
  String toString() => '<none>';
}

这是所有 ParentData 的基类,没有存储任何信息也没有实现功能,只定义了一个空实现的 detach() 方法,该方法会在 RenderObject 被移出 tree 的时候调用,这给子类提供了一个在 RenderObject 移出时更新信息的时机。

BoxParentData

/// Parent data used by [RenderBox] and its subclasses.
class BoxParentData extends ParentData {
  /// The offset at which to paint the child in the parent's coordinate system.
  Offset offset = Offset.zero;

  @override
  String toString() => 'offset=$offset';
}

该类注释写的很明确,用于 RenderBox 和它的子类,只有一个 offset 属性,该属性用于存储 child 的布局信息,也就是 child 应该被摆在哪个位置,通常在 child 大小确定后,parent 负责根据自身逻辑将 child 的位置赋值到这里。

ContainerBoxParentData

查看源码后发现该类是个空类,只是为了方便子类混入 ContainerParentDataMixin。

ContainerParentDataMixin

该类使用频率很高,基本上所有父节点的 ParentData 都混入了该类,该类需要与ContainerRenderObjectMixin 共同使用,主要解决了对 child 的管理,它用双链表存储了所有子节点并提供了方便的接口去获取他们。对于开发者,一般来说只用到 ContainerRenderObjectMixin 中的 firstChildlastChildchildCount,用来获取首末 child,child的个数,配合使用 ContainerParentDataMixin 中的 previousSiblingnextSibling就可以对 child 进行遍历了。

这些 ParentData 的基类解决了 child 的布局位置信息的存储和 child 的管理以及引用的获取,再往下的子类就是与各布局的功能相关的类了,如 FlexParentData,存储了 flex 和 fit 的值,分别表示该 child 的 flex 比重和 布局的 fit 策略。

渲染节点布局和事件响应

RenderBoxContainerDefaultsMixin

为了简化多节点渲染widget的开发,flutter引入了ContainerRenderObjectMixin和RenderBoxContainerDefaultsMixin。

ContainerRenderObjectMixin主要负责了节点树的管理,主要绘制和事件处理都在RenderBoxContainerDefaultsMixin进行处理。其中

/// Returns the baseline of the first child with a baseline.
  ///
  /// Useful when the children are displayed vertically in the same order they
  /// appear in the child list.
double? defaultComputeDistanceToFirstActualBaseline(TextBaseline baseline) {
....
}

/// Returns the minimum baseline value among every child.
  ///
  /// Useful when the vertical position of the children isn't determined by the
  /// order in the child list.
  double? defaultComputeDistanceToHighestActualBaseline(TextBaseline baseline) {
  ...
  }

defaultComputeDistanceToFirstActualBaseline和defaultComputeDistanceToHighestActualBaseline计算对齐元素

/// Paints each child by walking the child list forwards.
  ///
  /// See also:
  ///
  ///  * [defaultHitTestChildren], which implements hit-testing of the children
  ///    in a manner appropriate for this painting strategy.
  void defaultPaint(PaintingContext context, Offset offset) {
    ChildType? child = firstChild;
    while (child != null) {
      final ParentDataType childParentData = child.parentData! as ParentDataType;
      context.paintChild(child, childParentData.offset + offset);
      child = childParentData.nextSibling;
    }
  }

defaultPaint则执行每个节点的绘制工作。参数offset为布局计算器传递的布局偏移,节点自身可通过修改childParentData.offset进行节点的偏移转换工作。

在事件处理方面,主要依赖是RenderBoxContainerDefaultsMixin中defaultHitTestChildren:

/// Performs a hit test on each child by walking the child list backwards.
  ///
  /// Stops walking once after the first child reports that it contains the
  /// given point. Returns whether any children contain the given point.
  ///
  /// See also:
  ///
  ///  * [defaultPaint], which paints the children appropriate for this
  ///    hit-testing strategy.
  bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
    ChildType? child = lastChild;
    while (child != null) {
      // The x, y parameters have the top left of the node's box as the origin.
      final ParentDataType childParentData = child.parentData! as ParentDataType;
      final bool isHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset transformed) {
          assert(transformed == position - childParentData.offset);
          return child!.hitTest(result, position: transformed);
        },
      );
      if (isHit) {
        return true;
      }
      child = childParentData.previousSibling;
    }
    return false;
  }
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容