Jetpack Compose 中绘制发光边框的多种方式

任务定义

在 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级别和性能预算。

相关推荐
智塑未来1 小时前
像素蛋糕安卓版 AI 专业修图全场景输出高清成片
android·人工智能
陆业聪3 小时前
让 Android 里的 AI 真正「干活」:Function Calling 工程实现全解
android·ai·kotlin
毕设源码-赖学姐3 小时前
【开题答辩全过程】以 基于Android的服装搭配APP为例,包含答辩的问题和答案
android
qq_717410013 小时前
Add Baidu NLP for projects without GMS packages
android
AI-小柒4 小时前
DataEyes 聚合平台 + Claude Code Max 编程实战
android·开发语言·人工智能·windows·python·macos·adb
优选资源分享5 小时前
椒盐音乐 v11.1.0 丨安卓无广本地音乐播放器
android
xiangxiongfly9156 小时前
Android ArrayMap源码分析
android·arraymap
lishutong10067 小时前
直破 Android 17 大屏困局:Navigation 3 架构深度解析
android·架构
梦里花开知多少7 小时前
AOSP Android 14 壁纸架构深度分析
android