likes
comments
collection
share

Flutter刨根问底——点击事件(上)

作者站长头像
站长
· 阅读数 6

前言

之所以将手势操作称为事件传递,是因为对应的输入都以Event的形式分发,手势操作的Event为PointerEvent,即点击事件。本文将从事件入口出发,一步步分析事件的整个传递流程以及如何被响应,从而知晓点击无效的原因以及学会正确的设置手势回调。

事件传递

入口

通过引擎JNI调用,Flutter中的入口为_dispatchPointerDataPacket

@pragma('vm:entry-point')
void _dispatchPointerDataPacket(ByteData packet) {
  PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}

在Android中由onTouchEventonGenericMotionEvent方法进行调用,这里不再介绍。

分发

在GestureBinding初始化时设置的_handlePointerDataPacket的回调中对事件进行分发,我们主要看下_handlePointerEventImmediately以及dispatchEvent这两个方法

void _handlePointerEventImmediately(PointerEvent event) {
  HitTestResult? hitTestResult;
  // 每次手势的开始事件
  if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent || event is PointerPanZoomStartEvent) {
    assert(!_hitTests.containsKey(event.pointer));
    hitTestResult = HitTestResult();
    hitTest(hitTestResult, event.position);
    if (event is PointerDownEvent || event is PointerPanZoomStartEvent) {
      _hitTests[event.pointer] = hitTestResult;
    }
  } else if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent) {
  // 结束事件
    hitTestResult = _hitTests.remove(event.pointer);
  } else if (event.down || event is PointerPanZoomUpdateEvent) {
    hitTestResult = _hitTests[event.pointer];
  }
  if (hitTestResult != null ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    assert(event.position != null);
    dispatchEvent(event, hitTestResult);
  }
}
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
  if (hitTestResult == null) {
    assert(event is PointerAddedEvent || event is PointerRemovedEvent);
    try {
    // 执行pointer tracking
      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) {
    }
  }
}

分发主要就两件事,确定点到了谁😉,之后把整个事件给到对应的处理者,即HitTestTarget,一般为RenderObject,对应的处理在handleEvent方法中。其中命运石的选择交给了hitTest,选择的结果保存在HitTestResult中的path中😊。

HitTest

这个过程影响到了我们设置的点击事件有没有效。Flutter存在一个初学者容易发现的问题:点击空白处怎么没有效果,热区太小了等等。

因为我们所用的WidgetsBinding继承了RendererBinding,并且RendererBinding重写了GestureBinding的hitTest方法,所以我们直接看RendererBinding中的hitTest

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

GestureBinding的hitTest方法只做了result.add(HitTestEntry(this))这一件事,也就是自己直接被命中了,当然它内部并没有处理handleEvent。那这个renderView是什么呢?是RenderObject树的rootNode。

那么作为一个“普通人”,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;
}

其中的hitTestChildren是来判断是否自己的child是否能够被命中,如果是那么自己也跟命中沾亲带故,故把自己也add到result中去了;hitTestSelf则是决定自己可不可以被命中。不过这些的前提都是点击区域在自己的size中,即神选之民才可以(没有任何影射😎)

当然hitTest也有一些特殊的处理,比如我们用到HitTestBehavior时 RenderProxyBoxWithHitTestBehavior:

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;

以上可以看出当HitTestBehavior为translucentopaque时均会成功命中 不过这两者有什么区别呢? 分析以上代码,当为translucent时,如果点击空白处,hitTestChildren返回false,此时hitTarget为false,hitTest将会返回false,为opaque时会返回true,即返回值不同。

这个hitTest的返回值有什么用呢?接下来看下hitTestChildren的默认实现defaultHitTestChildren

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) {
        return child!.hitTest(result, position: transformed);
      },
    );
    if (isHit) {
      return true;
    }
    child = childParentData.previousSibling;
  }
  return false;
}

以上代码,遍历child,如果isHit为true,终止循环,这将影响到后续child上车

我还没上车呢,怎么活动结束了?

所以,如果在一个Stack中设置了HitTestBehavior为opaque,那么即使下面的child在空白处可以被命中,也无法触发任何事件。

handleEvent

除了一些特殊的处理,如TextField中的点击以及长按等,一般情况下使用的是GestureDetector以及ListView,而它们的事件由Listener中的handleEvent来分发

void handleEvent(PointerEvent event, HitTestEntry entry) {
  assert(debugHandleEvent(event, entry));
  if (event is PointerDownEvent) {
    return onPointerDown?.call(event);
  }
  if (event is PointerMoveEvent) {
    return onPointerMove?.call(event);
  }
  if (event is PointerUpEvent) {
    return onPointerUp?.call(event);
  }
  ......
  if (event is PointerSignalEvent) {
    return onPointerSignal?.call(event);
  }
}

这里将事件交给对应的回调去处理,比如GestureDetector主要接收PointerDownEvent,交给GestureRecognizer去处理,Scrollable接收PointerSignalEvent来滚动

下面是这些Event的说明及作用

EventIntroduceUsage
PointerDownEvent按下事件,分发给新的hitTest标志着手势的开始
PointerUpEvent抬起事件,分发给down的hitTest点击手势的结束
PointerEnterEvent进入target区域,无需hitTest事件开始
PointerExitEvent离开target区域,无需hitTest事件结束
PointerMoveEvent移动,分发给down的hitTest滑动
PointerHoverEvent移动,无需hitTest隔空滑动
PointerAddedEvent事件开始后的tracking
PointerRemovedEvent终止tracking
PointerCancelEvent事件取消取消点击
PointerSignalEvent离散的指针信号鼠标滑轮滚动

route

回看开始的dispatchEvent,其中的 pointerRouter.route(event) 是干什么的?

这其实是一个AOP(面向切片编程)实践,与由hitTestResult的target处理事件不同,route是通过startTrackingPointer注册的router来处理,即由recognizer来处理事件,具体细节将在下半部分GestureArena中介绍。

相关案例

  1. 为什么点击空白区域没有触发点击事件?

由上面的hitTest可以知道,点击空白并不会有child命中成功,并且没有设置HitTestBehavior为translucent或opaque的话,自身也不会命中成功,没有成功命中,也不会分发事件给recognizer引起手势回调。

  1. 双指滚动为什么列表的滚动距离会翻倍?

双指会产生两个PointerDownEvent,之后的双指滑动会分别产生不同pointer的PointerMoveEvent,都会被Scrollable接收并进行滚动。其实问题中的翻倍的描述并不准确,而是列表滚动的距离是两根手指滚动距离之和,更多根手指也同理。

总结

事件会由GestureBinding进行分发,在手机上的每次点击都会有一个pointer,我们以一次滚动为例,一般以PointerDownEvent或PointerHoverEvent作为整个手势的开始(个人测试发现第一个Event为PointerHoverEvent)。

  1. 在该pointer的第一个事件开始进行hitTest并保存HitTestResult,在收到PointerDownEvent事件时,开始调用startTrackingPointer,将会添加此pointer的router;
  2. 因为GestureBinding是最后添加到hitTestResult中的,所以从Down事件开始及之后所有的事件都会通过pointerRouter进行route;
  3. 收到PointerUpEvent后清除所有该pointer的hitTestResult,并调用stopTrackingPointer清除router,标记着此次手势的结束
转载自:https://juejin.cn/post/7320135997354573876
评论
请登录