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

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 就挺适合的。

链接

github.com/definev/hit

相关推荐
计算机魔术师15 分钟前
DeepSeek 据报道接近完成至少 800 亿元融资,腾讯与宁德时代参与
前端
ao-weilai19 分钟前
MySQL数据库:基本查询
android·数据库·mysql
可乐ea35 分钟前
多模型编排的三层:框架、模型路由与提供商路由
前端·网络·人工智能·ai智能体·多智能体协作·多模型编排·大模型路由
事圆则缓1 小时前
Kotlin 泛型方差实战:out、in、星投影与类型擦除
android·开发语言·kotlin
青柠之夏cc2 小时前
前端拖拽功能原生实现,不引入拖拽库完成业务
开发语言·前端·python
liangshanbo12153 小时前
前端大文件切片上传怎么做?
前端
传奇开心果编程3 小时前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层
android·网络·学习·ui·ios·kotlin·composer
Sand(ContextGate)3 小时前
Python Agent 测试实战:测试与评估,让 Agent 像传统软件一样可交付
前端·javascript·python·microsoft·ai
广州华水科技3 小时前
单北斗GNSS变形监测系统在城市安全与地质灾害中的应用前景
前端