用途
本条目介绍在 Jetpack Compose 中,如何用一个普通的绘制修饰符(draw modifier)在宿主控件自身边界之外绘制庆祝粒子爆发效果。例如从 48dp 按钮中向上飞溅的火花,无需 overlay、popup 或额外布局节点——drawWithContent 修饰符先绘制内容,然后继续绘制,没有任何东西在边缘处阻止它。
主要能力
- 通过
Modifier.drawWithContent在内容绘制后继续绘制粒子。 - 使用
withTransform配合translate和rotate定位与旋转每个火花。 - 用
Animatable的 progress 驱动飞行(减速向外)与下落(二次重力)。 - 每个爆发的火花角度、速度、大小、旋转和颜色在创建时确定并存储。
- 所有距离基于
size缩放,使同一效果在 28dp 和 48dp 控件上读数一致。
使用方式
示例代码定义了一个 Modifier.celebrationBurst(state: BurstState) 扩展函数:
@Composable
fun Modifier.celebrationBurst(state: BurstState): Modifier {
val starPath = remember { buildUnitStarPath() }
return this.drawWithContent {
drawContent()
if (state.bursts.isEmpty()) return@drawWithContent
val origin = Offset(size.width / 2f, size.height * 0.3f)
val reach = size.height * 2.2f
state.bursts.forEach { burst ->
val p = burst.progress.value
val flight = 1f - (1f - p) * (1f - p)
val fall = p * p * reach * 0.35f
burst.sparks.forEach { spark ->
val d = spark.speed * flight * reach
withTransform({
translate(origin.x + cos(spark.angleRad) * d,
origin.y + sin(spark.angleRad) * d + fall)
rotate(spark.spin * p * 360f, pivot = Offset.Zero)
}) { drawSpark(spark, size.height * spark.relativeSize, alpha = 1f - p) }
}
}
}
}
修饰符必须应用在任何 clip 之前:Modifier.celebrationBurst(state).clip(shape) 能绘制;Modifier.clip(shape).celebrationBurst(state) 不能。修饰符链由外向内嵌套,先放置的 clip 会包裹其后的一切,包括你的绘制。
适用场景
- 需要在现有控件上叠加粒子/庆祝效果,而不重新设计布局行。
- 希望同一效果在多种尺寸控件上表现一致。
- 希望避免引入动画库、资源管线或按平台不同的渲染器。
限制与陷阱
- 修饰符顺序决定效果是否存在:clip 在前会裁掉绘制,症状不是崩溃或警告,而是爆发被整齐裁剪到宿主轮廓,看起来像“动画坏了”。
- 祖先裁剪仍然生效:如果上层的 row、card 或 capsule 裁剪,从内部无法修复。出路是结构性的:把修饰符移到该裁剪之外的节点,或检查组件是否在内部裁剪(如 Material 按钮在其自身 surface 上裁剪,位于传入修饰符之下多层,因此从外层修饰符触发的爆发不会被它裁剪)。
- 按 size 缩放,不要用 dp 常量:原点
height * 0.3f、行程height * 2.2f、火花大小height * 0.12f..0.22f。硬编码 dp 会让效果在小控件上是烟花、在大控件上是抽搐。 - draw lambda 每帧运行:在 remember 中构建 Path,无活动时提前返回,否则一屏十几个列表控件会在每个滚动帧付出十几个空粒子循环的代价。
- 随机性属于火花创建,不属于绘制:在 draw lambda 中调用 Random 会每帧重掷,爆发变成静态噪声,看起来像渲染故障。
- 从 draw scope 读取 size:缓存的 size 在 resize 或共享元素过渡期间会滞后一帧,效果会明显脱离宿主。
- 绘制越界不会扩大边界,也不应扩大:不预留空间、不改变测量、不移动邻居,因此可放到现有控件上;代价是后绘制的兄弟节点会覆盖火花,绘制顺序跟随组合顺序。若需调整,提升宿主的 zIndex,而不是用 popup。
- 每个爆发一个 Animatable:共享时钟会强制所有活动爆发同相位,第二次点击要么重启第一个,要么同步叠加。
- 不要为此引入动画库:以上全部是 Path、drawRect、withTransform 和每个爆发一个 Animatable,无资源管线、无按平台渲染器,在所有目标上输出一致。
- 组合期间的写入:修饰符推入 holder 的参数若在组合阶段赋值,仅当该字段是仅在绘制时读取的普通 var 才安全;若改为可观察状态,会造成组合期间的快照写入,可能引发重组循环。
- 修饰符只负责绘制,由其他东西决定何时绘制:将活动效果列表放在提升的 holder 中,修饰符只读取它。
验证方式
前两条从依赖缓存中读取已解析的工具包产物,无需构建,确认除非主动请求否则不会被裁剪:
UI=$(find ~/.gradle/caches -name 'ui-desktop-*.jar' | head -1)
# 1. clipToBounds() 只是一个开启 clip 的 layer
javap -c -p -classpath "$UI" androidx.compose.ui.draw.ClipKt | sed -n '/clipToBounds/,/areturn/p'
# 2. layer 自身的 clip 参数默认为 OFF(mask bit 4096 -> iconst_0)
javap -c -p -classpath "$UI" androidx.compose.ui.graphics.GraphicsLayerModifierKt \
| grep -A4 'sipush 4096' | head -5
# 3. 此树中没有绘制修饰符位于自身链中 clip 之后
grep -rn --include='*.kt' -A2 '\.clip(' . | grep -E 'draw(Behind|WithContent|WithCache)'
# 4. 列出每个自定义绘制修饰符,以便逐一检查其链位置
grep -rn --include='*.kt' 'drawWithContent\b' .
观察结果:clipToBounds 主体是单个 graphicsLayer 调用,其布尔参数为 iconst_1;裁剪是选择性加入的,由 layer 提供,单独的绘制修饰符从不安装 layer。默认值为 false(sipush 4096 / iand / ifeq / iconst_0 / istore 14)。第三条无输出;任何命中都是会被同一链中上方 clip 裁剪的绘制修饰符,正是本技能关注的缺陷。第四条列出若干定义及其 Modifier.drawWithContent { 调用点,对每个意图越界绘制的修饰符,确认其链中前面没有 .clip(,且没有祖先裁剪。
然后手动验证:在你发布的最小和最大宿主上触发效果,火花大小、行程距离和弧线在两者上必须看起来是同一效果,且火花在两者上都必须越过宿主轮廓。