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

相关推荐
徐小夕9 小时前
JitKnow AI知识库 4.7 发布|多文档推理+拖拽 Agent + 技能库,打造更懂业务的 AI 知识中台
前端·vue.js·github
程序猿老A10 小时前
轻量应用服务器怎么用phpcms搭建网站?
android
小poop10 小时前
从“雷峰塔”到“对象”:JavaScript基础语法入门指南
前端·javascript
ReStock活库10 小时前
Zephyr异步SPI调用返回后,为什么不能立即复用发送缓冲区?
java·前端·网络
计算机魔术师10 小时前
半年营收163亿,算力债却到7800亿,这账谁算得清
前端
chunyublog11 小时前
HDFS 上传文件名空格问题排障教程
前端·javascript·npm
?abc!11 小时前
(贪心)621. 任务调度器
java·前端·c#
摸鱼仙人~11 小时前
校招帮简历字段匹配架构分析报告
java·前端·架构
陈随易12 小时前
副业搞钱点子之赛事与活动
前端·后端·程序员
电梯界知识分子12 小时前
景德镇老房重新设计阶段规划楼梯与电梯,1.7m×1.6m空间装400kg观光电梯,棕色框架茶色玻璃适配中式装修
大数据·服务器·前端·网络·家用电梯