任务定义
在 Jetpack Compose 中实现一个发光圆角矩形边框,需要满足以下要求:
- 按下时从原始大小扩展到完整尺寸,释放时收缩消失
- 支持圆角矩形形状
- 尽可能保持内容区域透明
- 边框绘制在Composable的外部边界之外
- 支持比经典Android阴影更强的发光强度和透明度
- 最好在一个Modifier中同时绘制细轮廓线和外部光晕,一次渲染完成

方式一:PNG边框

API支持:所有API级别
跨PNG状态渐变
这个方法虽然简单,但仍然是一个有效的概念验证。核心思路是:准备两张PNG/WebP图片------一张用于空闲状态,一张用于激活状态------然后在按下时通过CrossfadeComposable在两者之间切换。
注意事项:
- 如果使用这种方案,确保设计师从一开始就使中心区域透明
- 否则需要在代码中清除中间区域
- 对ImageModifier应用较小的缩放和负偏移,使边框延伸到Composable边界之外
优点:这种方案不应被低估,因为它将部分复杂性转移到设计师端,让开发者有更多时间专注于业务任务。
方式二:多层光晕(Multi-Layer Halo)

API支持:所有API级别
核心思路非常巧妙:不再使用真正的模糊,而是通过多次绘制相同的圆角矩形来模拟发光效果。每层逐渐扩大并逐层衰减,叠加形成光晕。
绘制代码示例:
javascript
this.drawBehind {
val layers = 25
repeat(layers) { index ->
drawRoundRect(
// 绘制逻辑
)
}
}
关于边界溢出:由于drawBehind操作在绘制阶段而非布局阶段,它可以绘制超过Composable测量尺寸的区域。实际上,这意味着将形状的topLeft移到负坐标,并绘制得比原始框更大。
局限性:
- 这种方法更像是概念验证,而非强大的生产级方案
- 要获得真正平滑的视觉效果,层数需要随着边框厚度增加而增长
- 边框越厚,需要的堆叠笔触越多,以避免出现阶梯状的渐变
- 这使得该方案在视觉和性能上都很低效
- 唯一合理的情况是非常细的边框,只需少量层即可
方式三:BlurMaskFilter - 经典Android发光效果

BlurMaskFilter从API 1就存在于Android中。但从API 11开始Android转向硬件加速后,Mask Filter不再是最可靠的路径。
这次发光不再是伪造的多层笔触,而是将边框绘制为真正的轮廓,通过BlurMaskFilter实现柔化效果。
绘制代码示例:
scss
val shadowPaint = Paint().apply {
asFrameworkPaint().apply {
maskFilter = BlurMaskFilter(blurRadiusPx, BlurMaskFilter.Blur.NORMAL)
}
}
onDrawWithContent {
drawContent()
clipPath(path, ClipOp.Difference) {
drawIntoCanvas { canvas ->
canvas.drawPath(path, shadowPaint)
}
}
}
边界溢出原理:边框超出Composable范围主要因为它作为描边绘制。描边轮廓居中在路径上,所以部分宽度自然落在原始轮廓之外。之后BlurMaskFilter进一步向外扩散这些像素,将描边变成柔和的光晕。
这是Android上最熟悉的发光效果实现方式。
方式四:RenderEffect模糊

要求:Android 12 (API 31) 或更高
这里的解决方案是将发光绘制在单独的层中,然后通过graphicsLayer.scaleX和graphicsLayer.scaleY缩放该层。模糊通过RenderEffect应用,这与blur() Modifier底层使用的机制概念相同。
绘制代码示例:
ini
val blurEffect = RenderEffect.createBlurEffect(..).asComposeRenderEffect()
Modifier.graphicsLayer {
scaleX = ..
scaleY = ..
renderEffect = blurEffect
}
局限性:
- 模糊无法严格限制在外部边框区域。一旦应用RenderEffect,它会影响整个层输出
- 如果内容放在同一层中,也会受到效果影响。解决方案是使用两个叠加的Box层
- 细边框单独绘制,放在graphicsLayer之前,所以不会随光晕一起模糊或缩放
总结:实际上不会使用这种方法。模糊更适合全层效果,而非特定形状周围的局部边框。
方式五:Shadow Passes(阴影层)

这里的技巧是让paint.setShadowLayer()完成大部分工作。将轮廓绘制为路径,阴影层将其转换为形状周围的柔和光晕。
问题:透明度控制有限。该API是为阴影设计的,而Android中的阴影并不真正为了高度饱和而设计。因此结果仍然相当透明,不像是强烈的发光效果。
解决方案:多次绘制相同路径以增加密度。
绘制代码示例:
scss
onDrawWithContent {
clipPath(path, ClipOp.Difference) {
drawIntoCanvas { canvas ->
repeat(passes) {
canvas.drawPath(path, shadowPaint)
}
}
}
drawContent()
}
优点:
- 不限于矩形形状,可用于任何可绘制为路径的自定义形状
- 自然地在Composable边缘附近保留一条细可见线,帮助保持形状轮廓
方式六:渐变画笔 - 从部件构建发光

这种方法通过多个独立的渐变切片来构建边框。直边部分使用线性渐变(linear gradients)绘制,而圆角部分则使用径向渐变(radial gradients)绘制,随后通过在矩形周围进行镜像处理,最终组合成完整的光晕效果。
缺点在于,drawWithCache 在这种场景下并不能真正帮我们节省开销。笔刷(Brushes)仍然依赖于当前光晕的宽度,且圆角渐变还取决于形状的比例,因此在动画过程中两者都必须重新计算。一个更偏向性能的替代方案是:始终以最大宽度绘制光晕,并仅将其透明度(alpha)从 0 动画到 1。虽然动画效果看起来非常相似,但渲染开销会更低。
不过,本方案采用的是真实的"光晕宽度"动画。其优化点在于:圆角部分仅创建了一个径向笔刷,并通过变换(transforms)复用了四次;同时,所有的侧边渐变也仅使用了一个线性笔刷。
针对细边框(thin stroke)也做了一个小的优化:只要光晕仍在扩散状态,它就会保持高亮,而不仅仅是在手指按下时。这避免了在连续快速点击(tap spam)期间产生额外的重绘工作。
超出组合项(composable)边界的绘制完全由手动处理。渐变先是在原始边界之外绘制,然后通过平移(translate)、缩放(scale)和旋转(rotate)复用到其他侧边和圆角。
尽管该方法具有一定的复杂性,但我仍会考虑将其投入实际使用。它的行为是可预测的,并且对于旧设备来说,依然是一个务实且可行的方案。
方式七:AGSL边框

要求:Android 13+, API 33
这种方式与之前的方法有所不同。在这里,边框不再是由多次绘制、模糊技巧或渐变切片组合而成。相反,整个效果直接在一个 AGSL 着色器 (Shader) 内部进行描述。这包含了边框的两个部分:
- 靠近形状边缘的细轮廓
- 随距离增加而逐渐消失的外部光晕
因此,我们不再是组合多种绘制技术,而是在一个着色器中描述完整的边框,并让它决定每个像素点的发光强度。
在这种实现中,同一个 AGSL 着色器以两种不同的方式被使用:
6.1 通过 RenderEffect 实现 AGSL
在第一个版本中,着色器通过 RenderEffect 附加。这使得它的行为更像是一种应用于组合项图层(Composable layer)的后处理效果。
6.2 通过 Canvas Paint 实现 AGSL
在第二个版本中,同一个着色器被直接附加到原生的 Paint 对象上,并通过画布(Canvas)进行绘制。
虽然视觉逻辑保持不变,但执行路径发生了变化。着色器不再经过 RenderEffect,而是在画布绘制期间像普通的绘制着色器一样被使用。
重点在于,这并不是两种不同的边框设计。它们是相同的边框、相同的着色器和相同的视觉模型,唯一的区别在于该着色器的执行方式。
在这两个 AGSL 版本中,透明度都来自着色器本身:它仅在圆角矩形外部生成 Alpha 值。区别在于合成方式:
- 在
RenderEffect路径中,着色器读取源图层并将光晕混合在其下方。 - 在
Canvas Paint路径中,着色器先在透明基底上绘制光晕,随后再在其上方绘制内容。
所以,没错,AGSL 能够非常出色地完成这项任务
Gemini said
7. 火焰着色器:将边框转变为酷炫的冰火特效
Android 13+, API 33
高级 AGSL 应用
它不再局限于原始的边框需求,也不再是讨论如何寻找绘制发光圆角矩形的最优实现。这纯粹是为了演示:一旦边框不再仅仅是边框,而演变成完整的视觉特效时,AGSL 究竟能发挥多大的潜力。
最初是在 ShaderToy 上使用 GLSL 构建了这个着色器。这让迭代过程变得更快,因为参数可以实时修改,视觉结果立竿见影,而不需要在每次微调后都重新构建 Android 应用。在那之后,我才将该效果移植到 AGSL。


路径起点/终点衔接问题(已修复)
其中一个实际问题是右侧的衔接点,即边框路径从终点绕回起点的地方。解决方案是增加一个小的重叠区域:当"尾部"淡出时,"头部"同时淡入,从而使过渡变得不再那么明显。
总结
本文介绍了在Jetpack Compose中实现发光圆角矩形边框的多种方法,从简单的PNG切换到高级的AGSL Shader。每种方法都有其适用场景和局限性:
| 方法 | API要求 | 适用场景 |
|---|---|---|
| PNG边框 | 所有API | 快速原型 |
| 多层光晕 | 所有API | 细边框 |
| BlurMaskFilter | 所有API | 经典效果 |
| RenderEffect | API 31+ | 现代模糊 |
| Shadow Passes | 所有API | 自定义形状 |
| 渐变画笔 | 所有API | 性能敏感 |
| AGSL | API 33+ | 高级效果 |
选择哪种方案取决于具体的UI需求、目标API级别和性能预算。