Flutter中如何实现RN的hitSlop扩大热区功能

370 阅读5分钟

前言:

react-native hitSlop介绍:这一属性定义了按钮的外延范围

为了方便用户使用,公司的自研跨端框架描述语言前期对齐了react-native,最近研发的同学疯狂push框架提供对齐RN的hitSlop,但是flutter并没有提供这个能力,只能借助flutter现有能力实现。

思路

hitSlop是在不改变布局的情况下,扩大节点的可点击范围,关键的两个点:需要得知是否命中到节点或者节点的外部。当命中节点的外部时,节点同时一定是可响应点击的,比如被绝对定位覆盖,滚动出视口等场景。

  1. 点击事件命中节点内外可以通过TapRegion解决
    TapRegion:是 Flutter 框架中用于捕获点击事件的一个组件,适用于监听用户在特定区域内或外的点击操作。
    定义点击区域
    TapRegion 包裹的子组件形成一个“点击区域”。
    捕获事件
    • onTapInside:当点击事件发生在 TapRegion 所包裹的子组件内时触发。
    • onTapOutside:当点击事件发生在 TapRegion 定义的区域外时触发。
      简单写个demo看下效果:
      示例图片 示例图片

点击蓝色部分触发onTapInside,点击红色部分触发onTapOutside,点击白色部分不触发回调

  1. 节点是否可响应点击事件,可以通过hitTest当前节点的位置,检查命中结果中第一个节点是否本节点,决定是否成功命中(表示被遮挡),关于hitTest可以参考Flutter是如何处理一次点击事件有详细介绍

总结:通过 TapRegion 包裹需要扩大热区的组件,当触发 onTapOutside 时,计算点击的position是否落在hitSlop的范围,此时通过hitTest当前组件的position检查命中结果中第一个节点是否本节点,决定是否回调热区扩大。

实现

1.在根节点包裹TapRegionSurface,然后当传入的节点中的props带有hitSlop,给当前节点包一层TapRegion

 @override
 Widget build(BuildContext context) {
 ...
   Widget hitSlop != null && enableHitSlop == true
        ? TapRegion(
            child: child,
            onTapOutside: (PointerDownEvent event) {
              onTapOutside(event, hitSlop, context);
            })
        : child;

  ...
 }

2.当命中onTapOutside时,计算点击的position落入扩大热区范围内:


  bool hasHitSlop(
    Offset tapPosition,
    EdgeInsets hitSlop,
    BuildContext currentContext
  ) {
    final RenderBox renderBox = currentContext.findRenderObject();

    // 宽或者高为0时,不再扩大热区
    if (renderBox.size.width == 0 || renderBox.size.height == 0) {
      print('命中宽或者高为0时,不再扩大热区');
      return false;
    }

    // 获取左上角的位置
    final Offset topLeftPosition = renderBox.localToGlobal(Offset.zero);
    // 获取右下角的位置
    final Offset bottomRightPosition = renderBox
        .localToGlobal(Offset(renderBox.size.width, renderBox.size.height));

    // 算出新热区左上角和右下角的位置
    Offset hitSlopTopLeftPosition = Offset(
        topLeftPosition.dx - hitSlop.left, topLeftPosition.dy - hitSlop.top);
    Offset hitSlopBottomRightPosition = Offset(
        bottomRightPosition.dx + hitSlop.right,
        bottomRightPosition.dy + hitSlop.bottom);

    // 点击位置落到节点上
    bool isHitNode = tapPosition.dx < bottomRightPosition.dx &&
        tapPosition.dy < bottomRightPosition.dy &&
        tapPosition.dx > topLeftPosition.dx &&
        tapPosition.dy > topLeftPosition.dy;
    // 点击位置落到热区
    bool isHitSlop = tapPosition.dx < hitSlopBottomRightPosition.dx &&
        tapPosition.dy < hitSlopBottomRightPosition.dy &&
        tapPosition.dx > hitSlopTopLeftPosition.dx &&
        tapPosition.dy > hitSlopTopLeftPosition.dy;

    // 点击的位置在新热区的位置且不在原节点
    return isHitSlop && !isHitNode;
  }

3.当命中了扩大热区时,获取当前命中的节点的位置,然后进行从根节点执行命中测试,并取出hitTestResult中第一个节点,非本节点则证明此时本节点无法在手势竞技场赢得点击,也就不予响应扩大热区事件

  bool isCovered(BuildContext currentContext) {
    // 获取尺寸和位置
    final RenderBox renderBox = currentContext?.findRenderObject() as RenderBox;
    // 获取组件的位置
    final Offset offset = renderBox.localToGlobal(Offset.zero);

    // 执行命中测试
    final HitTestResult hitTestResult = HitTestResult();
    WidgetsBinding.instance?.hitTest(hitTestResult, offset);

    // 检查命中结果中第一个节点非本节点(表示被遮挡)
    return hitTestResult.path.first.target != renderBox;
  }

  1. 实现onTapOutside方法,完成扩大热区
  void onTapOutside(
      PointerDownEvent event, EdgeInsets hitSlop, BuildContext currentContext) {
    // 屏幕点击的位置如果在用户设置的范围,发送onpress事件
    if (hasHitSlop(event.position, hitSlop, currentContext) == true) {

      bool isCover = isCovered(currentContext);

      // 检查命中结果中第一个节点非本节点的场景不可点击
      if (isCover) {
        return;
      }

      print('点击组件-扩大后的热区');

      // 给出点击回调
      onPressCallback(
          params: {
            'nativeEvent': {
              'pageX': position.dx.toInt(),
              'pageY': position.dy.toInt(),
              'tapOutside': true // 点击到外部给一个标识
            }
          });
    }

TapRegion原理

  1. RenderTapRegionSurface中注册TapRegion,得到一个TapRegion列表,
    RenderTapRegionSurface#registerTapRegion:
  @override
  void registerTapRegion(RenderTapRegion region) {
    assert(_tapRegionDebug('Region $region registered.'));
    assert(!_registeredRegions.contains(region));
    // 把TapRegion加入列表
    _registeredRegions.add(region);
    if (region.groupId != null) {
      _groupIdToRegions[region.groupId] ??= <RenderTapRegion>{};
      _groupIdToRegions[region.groupId]?.add(region);
    }
  }

2.在TapRegionSurfacehitTest中保存从当前节点(TapRegionSurface)一直到子节点的命中测试结果_cachedResults
RenderTapRegionSurface#hitTest:

  @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    if (!size.contains(position)) {
      return false;
    }

    final bool hitTarget =
        hitTestChildren(result, position: position) || hitTestSelf(position);

    if (hitTarget) {
      final BoxHitTestEntry entry = BoxHitTestEntry(this, position);
      // 将收集到的命中测试节点列表保存下来
      _cachedResults[entry] = result;
      result.add(entry);
    }

    return hitTarget;
  }

3. handleEvent中处理当前TapRegionSurface下所有的TapRegion节点的onTapInsideonTapOutside
RenderTapRegionSurface#handleEvent:

  @override
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    ... // 省略

    // 在按下事件处理
    if (event is! PointerDownEvent) {
      return;
    }

    final BoxHitTestResult? result = _cachedResults[entry];

    // 取注册的 TapRegion列表和命中测试的交集,这些注册者响应onTapInside
    final Set<RenderTapRegion> hitRegions =
        _getRegionsHit(_registeredRegions, result.path)
            .cast<RenderTapRegion>()
            .toSet();
    final Set<RenderTapRegion> insideRegions = <RenderTapRegion>{};

    for (final RenderTapRegion region in hitRegions) {
      if (region.groupId == null) {
        insideRegions.add(region);
        continue;
      }
      // Add all grouped regions to the insideRegions so that groups act as a
      // single region.
      insideRegions.addAll(_groupIdToRegions[region.groupId]!);
    }
    // 注册的列表中不响应onTapInside则响应onTapOutside
    final Set<RenderTapRegion> outsideRegions =
        _registeredRegions.difference(insideRegions);

    bool consumeOutsideTaps = false;
    for (final RenderTapRegion region in outsideRegions) {
      ... // 省略
      // 回调onTapOutside
      region.onTapOutside?.call(event);
    }
    for (final RenderTapRegion region in insideRegions) {
      // 回调onTapInside
      region.onTapInside?.call(event);
    }

    ... // 省略
  }

总结:TapRegionSurface 捕获并分发点击事件,TapRegion 响应其范围内的点击(onTapInside)或范围外的点击(onTapOutside

结语

其实在web中并不存在扩大热区的功能,但是web灵活的布局完全能满足研发同学的日常需求,这就是跨端研发体验和h5的一个差距吧