Flutter事件之HitTest

1,112 阅读4分钟

HitTest源头

GestureBinding._handlePointerEventImmediately在收到触点信息时调用,进行hitTest的情况有三种:

  • PointerDownEvent——该触点是按下事件(通过手指按下触发)
  • PointerSignalEvent——该触点是鼠标滚轮事件(通过鼠标滚轮触发)
  • PointerHoverEvent——该触点是悬浮事件(在电脑上用触摸板触发)
void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
    assert(!_hitTests.containsKey(event.pointer));
    hitTestResult = HitTestResult();
    hitTest(hitTestResult, event.position);
    if (event is PointerDownEvent) {
      _hitTests[event.pointer] = hitTestResult;
    }
    assert(() {
      if (debugPrintHitTestResults)
        debugPrint('$event: $hitTestResult');
      return true;
    }());
  }
  ...
}

hitTest的一些类

image.png

HitTestable

抽象类

/// An object that can hit-test pointers.
abstract class HitTestable {
  HitTestable._();
  void hitTest(HitTestResult result, Offset position);
}

实现了HitTestable的类都具有hitTest(命中测试)的能力;

hitTestDispatcher

抽象类

/// An object that can dispatch events.
abstract class HitTestDispatcher {
  HitTestDispatcher._();
  void dispatchEvent(PointerEvent event, HitTestResult result);
}

实现了hitTestDispatcher的类都具有dispatchEvent(分发事件)的能力

HitTestTarget

抽象类

/// An object that can handle events.
abstract class HitTestTarget {
  HitTestTarget._();
  void handleEvent(PointerEvent event, HitTestEntry entry);
}

实现了HitTestTarget类都具有handleEvent(接受处理事件)的能力

HitTestEntry和HitTestResult

HitTestEntry是命中测试采集的数据,如果被命中,会讲被命中的目标用HitTestEntry包裹; HitTestResult是命中测试的结果,内部维护着变量_path,_path是一个数组,用来存储被命中的目标,内部元素是HitTestEntry,

实现了这些功能的类

GestureBinding中实现了HitTestable、hitTestDispatcher、HitTestTarget,因此它具备了命中测试、分发事件、处理事件的能力;

RendererBinding中使用on关键字继承了HitTestable,也具备了命中测试的能力

RendererBinding是渲染树和engine的胶水类,内部的renderView即是渲染树的根节点,hitTest的最开始也是从这里开始的

RenderObject实现了HitTestTarget,因此它具备了处理事件的能力;

RenderBox虽然没有实现HitTestable,但在该基类有hitTest方法,说明它一样具备命中测试的的能力。

Flutter中几乎所有的渲染实体都直接或间接继承自RenderBox,而RenderBox又集成自RenderObject

GestureBinding

GestureBinding是一个mixin class,它并不是一个实体类,所以一定有个类混入了它, 在程序启动入口runApp()我们可以找到答案,

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding

因此WidgetsFlutterBinding是它的主类;在通过_handlePointerEventImmediately方法开始命中测试时,会首先进入到RendererBinding.hitTest方法,为什么首先会进入这里呢,因为多个mixin类存在相同方法时,后面混入的会覆盖前面的,而RendererBinding混入的时机比GestureBinding晚。而在最后又通过super.hitTest(result, position);回调到GetureBinding.hitTest

##RendererBinding.hitTest##
@override
void hitTest(HitTestResult result, Offset position) {
  assert(renderView != null);
  assert(result != null);
  assert(position != null);
  renderView.hitTest(result, position: position);
  super.hitTest(result, position);
}
##GetureBinding.hitTest##
void hitTest(HitTestResult result, Offset position) {
  result.add(HitTestEntry(this));
}

HitTest传递链

上面说了,首先会进入到RendererBinding.hitTest方法内,然后开始调用renderView.hitTest(renderView是渲染树的最根节点),然后顺着渲染树的节点一直向下传递,在叶子节点开始返回结果,所以hitTest的执行顺序是从根节点到叶子节点,而得到的结果正好相反,叶子节点->根节点;

##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;
}

在最后会把GetureBinding也加入到命中测试列表

左边是一个Widget层级,右侧是对应的RenderObject image.png 该图来源

生成的hitTest列表如下⬇️: image.png

这里注意的是TextSpan并没有RenderObject,并且他也没有hitTest的能力,但是它是依托于RenderParagraph的,所以它会在RenderParagraph.hitTestChildren被加入列表中;

@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
  // Hit test text spans.
  bool hitText = false;
  final TextPosition textPosition = _textPainter.getPositionForOffset(position);
  final InlineSpan? span = _textPainter.text!.getSpanForPosition(textPosition);
  if (span != null && span is HitTestTarget) {
    result.add(HitTestEntry(span as HitTestTarget));
    hitText = true;
  }
  ...
}

image.png

这样就得到了一个由叶子节点到根节点的命中列表。

注意: 能加入到hitTest列表的节点不一定有hitTest能力,但是一定需要拥有handleEvent(处理事件)的能力

HitTest规则

在RenderBox基类中hitTest()方法的规则是:如果命中孩子或者命中自己即表示被命中,会加入到命中测试列表,返回true;然后它的hitTestChildren和hitTestSelf方法直接返回flase,所以子类可以实现这几个方法自定义命中规则; 下面看下RenderDecoratedBox:

RenderDecoratedBox

BoxDecoration对应的RenderObject是RenderDecoratedBox,它是一个装饰器,用来给对象添加圆角,背景图、颜色等等;

由于RenderDecoratedBox没有实现hitTest方法,所以会进入它的父类RenderBox.hitTest方法,随后进入RenderDecoratedBox.hitTestSelf

RenderDecoratedBox.hitTestSelf是极其简单的,只是调用了_decoration.hitTest,它是直接返回true,然后命中成功,将自己添加到命中列表

@override
bool hitTestSelf(Offset position) {
  return _decoration.hitTest(size, position, textDirection: configuration.textDirection);
}

HitTestBehavior的作用

behavior表示命中测试过程中的表现策略。它是一个枚举,提供了三个值,分别是:

  • HitTestBehavior.deferToChild:默认模式,表示是否命中取决于它的child是否被命中
  • HitTestBehavior.opaque:Listener的child可以被响应,可以被命中,会阻止事件穿透
  • HitTestBehavior.translucent:Listener的child可以被响应,不可以被命中,会阻止事件穿透
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
  bool hitTarget = false;
  if (size.contains(position)) {
    hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
    if (hitTarget || behavior == HitTestBehavior.translucent)
      result.add(BoxHitTestEntry(this, position));
  }
  return hitTarget;
}

@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;