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

因为在 Flutter 里,默认布局尺寸和命中测试尺寸是绑定在一起的,这就导致了两个问题:
- 如果想扩大一个控件的点击区域一般都是加 padding,但这就会连带着撑大布局,挤压旁边的布局
- 如果让子控件视觉上溢出(overflow)父容器边界,Flutter 默认也不会对溢出部分做命中测试,导致溢出区域点不到,比如卡片右上角悬挂的小圆点徽标这种
然后
hit这个包的做就是把「画什么(paint)」和「点哪里(hit)」这两个概念解耦,这样你就可以让点击热区比视觉尺寸大,甚至可以溢出到父容器布局框之外还能被点击到。
比如在下面这些场景,特别是拖动进度条,或者拖拽改变窗口大小,这种场景下加大点击热区,可以很好增加交互体验,但是又不会改变 UI 布局。

要做到这点,在整个项目里需要实现 HitLayer 、HitLink、HitScope 、Hit.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,并通过内部的 InheritedWidget 把 HitLink 暴露给子树。
这样当一个 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,注册到祖先 HitScope, RenderHitDefer.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 就挺适合的。