Flutter hit,一个可以灵活控制溢出点击的第三方包

definev/hit 是一个支持点击效果可以灵活溢出控件大小的第三方包,离谱点甚至可以做到控件在原地,点击满天飞的效果,简单来说就是它的作用就是解决 "控件视觉尺寸需要小,但点击热区需要大" 的需求问题。

因为在 Flutter 里,默认布局尺寸和命中测试尺寸是绑定在一起的,这就导致了两个问题:

  • 如果想扩大一个控件的点击区域一般都是加 padding,但这就会连带着撑大布局,挤压旁边的布局
  • 如果让子控件视觉上溢出(overflow)父容器边界,Flutter 默认也不会对溢出部分做命中测试,导致溢出区域点不到,比如卡片右上角悬挂的小圆点徽标这种

然后 hit 这个包的做就是把「画什么(paint)」和「点哪里(hit)」这两个概念解耦,这样你就可以让点击热区比视觉尺寸大,甚至可以溢出到父容器布局框之外还能被点击到。

比如在下面这些场景,特别是拖动进度条,或者拖拽改变窗口大小,这种场景下加大点击热区,可以很好增加交互体验,但是又不会改变 UI 布局。

要做到这点,在整个项目里需要实现 HitLayerHitLinkHitScopeHit.defer / Hit.before 这几个概念:

less 复制代码
HitScope(
  child: Padding(
    padding: const EdgeInsets.all(12),
    child: HitLayer(
      alignment: Alignment.center,
      behavior: HitTestBehavior.deferToChild,
      hitChild: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: onPressed,
        child: const SizedBox(width: 48, height: 48),
      ),
      paintChild: const IgnorePointer(
        child: Icon(Icons.add, size: 24),
      ),
    ),
  ),
)

他们之间的关系如下图所示,首先:

  • HitLayer 把布局层和点击层变成两个子节点
  • HitLink 保存越界目标的注册表
  • HitScope 统一扫描越界目标
  • Hit.defer 提供Widget 已经被放到了父边界之外的在吃鸡

首先是 HitLayer 是一个 MultiChildRenderObjectWidget,内部实际上其实有两个 Widget:

复制代码
HitLayer
├── hitChild     点击区域
└── paintChild   视觉内容 + 布局尺寸

其中:

  • paintChild 决定 HitLayer 最终布局尺寸
  • hitChild 可以独立布局,允许比 paintChild 更大
  • alignment 决定两者如何对齐

它对应的 RenderObject 是 RenderHitLayer,所以在 performLayout() 里代码会先按照正常约束布局 paintChild,然后直接把自己的 size 设置为 paintChild.size,然后再用 constraints.loosen() 布局 hitChild,这样后者就可以获得更大的尺寸,逻辑大致相当于:

ini 复制代码
paint.layout(constraints);
size = paint.size;
​
hit.layout(constraints.loosen());
​
hit.offset = alignment.calculateOffset(
  paintSize - hitSize,
);

整个过程类似:

ini 复制代码
hitChild:48×48
paintChild:24×24
alignment:center
​
hitChild offset = (-12, -12)
​
    hitChild 48×48
    ┌────────────────┐
    │                │
    │    ┌──────┐    │
    │    │paint │    │
    │    │24×24 │    │
    │    └──────┘    │
    │                │
    └────────────────┘
​
HitLayer 对父节点报告的尺寸还是只有 24×24。

然后就是 RenderHitLayer 会计算出来相应可点击区域:

复制代码
Rect hitRect
Rect layoutRect

这时候就可以判断 hitChild 有没有超出自己的布局区域:

复制代码
hitRect.left < layoutRect.left
hitRect.top < layoutRect.top
hitRect.right > layoutRect.right
hitRect.bottom > layoutRect.bottom

然后只有真正发生溢出,并且存在 HitScope 或显式 HitLink 时,它才会注册为延迟命中目标,也就是:

  • 没有溢出的普通 HitLayer:继续使用 Flutter 原生命中路径
  • 发生溢出的 HitLayer:交给 HitScope
  • 不会把所有控件都塞进额外的全局扫描列表

所以当它进入 deferred 模式以后,本地 hitTest() 会直接返回 false,避免同一个目标同时被本地路径和 Scope 路径命中两次。

然后 HitLink 用来保存越界目标的注册表HitLink 本质上是:

scala 复制代码
class HitLink extends ChangeNotifier {
  final List<HitDeferRegistration> _targets = [];
}

也就是所有越界目标实现统一接口 HitDeferRegistration,接口里提供:

  • 用哪个 RenderBox 做坐标转换
  • 实际命中区域是多少
  • 如何执行 deferred hit-test
  • opaque 还是 translucent
  • 是否需要由 Scope 重新绘制
  • 是否使用合成图层跟踪位置

HitLink 负责注册、移除以及通知 Scope 重绘,命中顺序是后注册的目标优先,也就是 newest-first,这种顺序基本模拟了视觉上后出现、位于上层的控件优先收到事件。

然后就是HitScope 统一扫描越界目标,HitScope 是一个 StatefulWidget,并通过内部的 InheritedWidgetHitLink 暴露给子树。

这样当一个 Pointer 进入 Scope 时,它会:

  • 从后向前扫描 HitLink 中的目标
  • 把每个目标的坐标转换到当前 Scope
  • 快速判断 Pointer 是否在目标的包围盒内
  • 将 Pointer 再转换成目标的本地坐标
  • 调用目标自己的 hitTestDeferred
  • 最后视情况继续测试普通子树

这里的实现对应代码类似:

scss 复制代码
target.hitTestBox.getTransformTo(this)
MatrixUtils.transformRect(...)
result.addWithPaintTransform(...)

也就是说,它不是简单保存一个固定的全局 Rect,hit 是会按照当前 RenderObject 的 transform 做坐标换算

从仓库测试用例也可以看到,基本覆盖了 Transform.translate、嵌套 Scope、显式外层 HitLink、ClipRect 阻断以及 opaque 目标跳过普通子树等场景

当然还有一个叫 Hit.defer 的,它和 HitLayer 有什么区别呢

这两个还真有些不一样,比如 HitLayer 适合"同一个位置上,视觉层和点击层大小不同",比如:

复制代码
视觉图标:24×24
点击区域:48×48

常见场景一般用在:

  • 图标按钮
  • slider thumb
  • resize edge
  • 小型 drag handle

因为这时候它的布局尺寸是通过 paintChild 决定。

但是 Hit.defer 不一样,它适合"整个 Widget 已经被放到了父边界之外",比如

css 复制代码
Stack 100×100
Badge 位于 right: -12

也就是它不会重新设计 Widget 的内部布局,而是选择让现有 child 完全退出本地 hit-test,注册到祖先 HitScopeRenderHitDefer.hitTest() 始终返回 false,只能通过 Scope 命中。

Hit.before 的命中机制和 Hit.defer 一样,但绘制是通过 Scope 放在整个子树下面,也就是更适合:

  • 向外伸展的背景装饰
  • 边缘阴影
  • 需要在父内容下方接收事件的特殊区域

所以可以看出来,整个项目在需求上做了很完善的场景适配。

而且项目做了比较不错的适配,比如在滚动列表里 Hit.defer(paintOnTop: true) 正常来说会有一个问题:

假如越界目标位于滚动列表里面,但它通过外层 HitScope 绘制,如果只在 Scope 里计算一次全局坐标,滚动发生以后,目标位置可能已经变化,而 Scope 不一定跟着完整重绘

而项目的解决方案是用 Flutter 合成层机制:

sql 复制代码
原始位置:LeaderLayer
外层 Scope:FollowerLayer

RenderHitDefer 在原始位置放置一个 LeaderLayer,但不真正绘制 child,然后 RenderHitScope 在上层创建对应的 FollowerLayer,然后在其中绘制 child。

这样 GPU 合成阶段就能让 Follower 跟随 Leader 的滚动和 Transform,不需要每一帧重新进行整个 Scope 的绘制计算

这也是整个包里技术上相对复杂、最有有含金量的部分,对比目前常用的溢出方案,hit 的优势还是很明显的,而且解决了之前 HitSlop 在 ListView 的缺陷,整体适配度很高:

方案 扩大点击区域 不改变布局 支持父边界外点击 支持重排绘制层级
Padding
透明 Container
Stack + clipBehavior: none 只能绘制溢出
OverflowBox 部分
Transform 跟随 Transform 行为 受 parents 限制
Overlay / Portal 是,但结构更重
hit 支持 top / before

所以如果你有溢出点击,或者提高操控 UX 的场景,hit 就挺适合的。

链接

github.com/definev/hit

相关推荐
古韵1 小时前
小程序上传进度条,还要自己监听 onProgressUpdate 吗?
前端·javascript·uni-app
古韵1 小时前
axios 已经跑得好好的,还要不要上 alova?
前端·javascript·axios
噗噗122 小时前
企业微信侧边栏应用与前端 JS-SDK 的深度整合指南
前端·javascript·企业微信
我命由我123452 小时前
Kotlin 面向对象 - 枚举排序
android·java·开发语言·java-ee·kotlin·android studio·android-studio
朝阳392 小时前
react19【实战】配置化路由登录鉴权完整方案
前端·javascript·react.js
ShuiShenHuoLe2 小时前
vue如何实现国际化,它来了!
前端·javascript·vue.js
其美杰布-富贵-李2 小时前
Vue Router 实战教程
前端·javascript·vue.js
NutShell Wang2 小时前
拆解 GitHub gh-stack:堆叠 PR 工作流的设计取舍与工程实现
前端·git·开源·github·代码复审·开发者工具·vibe coding