探索如何在 Android 应用中实现一个具有高级视觉吸引力的底部导航栏?仅靠标准视图组合可能难以创造出那种细腻的动态光影效果。本文将深入探讨如何利用 Android 图形着色语言 AGSL,在 Jetpack Compose 中构建一个会发光的底部导航栏,为你的应用界面增添赛博朋克般的科技感。
之前,我们已经探讨过如何使用 AGSL 着色器来创建沉浸式的落地页体验。在这篇文章中,我们将把这些概念应用到一个更具体的组件上,一步步构建一个会发光的底部导航栏,使用着色器为应用菜单创造一种动态、高级的光照效果。
案例研究:发光导航栏组件
本次案例研究的目标,是改造一个标准的底部导航界面,通过 AGSL 着色器赋予其独特的发光视觉效果。让我们从核心的着色器脚本开始,看看如何通过数学计算实现坐标变换和光线衰减。
const val GLOW_SHADER = """
uniform float2 resolution;
uniform float progress;
uniform float3 color;
half4 main(float2 coords) {
float2 center = resolution * 0.5;
float dist = distance(coords, center);
float maxRadius = resolution.x * 0.6;
float glow = 1.0 - smoothstep(0.0, maxRadius, dist);
glow = glow * glow;
float alpha = glow * progress * 0.4;
return half4(color, alpha);
}
"""
下面是对上述代码的技术解析:我们创建了一个从绘图区域中心发出的径向发光效果。它利用距离场来计算每个像素的亮度,从而实现从中心到边缘的柔和淡出。
这些是从 Kotlin 代码传递到着色器的动态参数:
uniform float2 resolution → UI组件(画布/盒子)的宽度和高度(以像素为单位)。这是坐标归一化的关键。
uniform float progress → 一个在 0.0 到 1.0 之间变化的值,用于控制发光的强度或不透明度,通常与动画状态绑定。
uniform float3 color → 发光的 RGB 颜色值。
这个着色器的核心依赖于距离场计算:
- *`center = resolution 0.5
** → 将分辨率减半(x/2, y/2)`,计算出绘图区域的精确中心点。
distance(coords, center) → distance() 函数计算当前像素坐标 coords 到中心点的距离。这创建了一个圆形的距离场:靠近中心的像素 dist 值较低,远离中心的则较高。
接下来的代码定义了光线衰减的柔和度:
maxRadius → 将发光效果限制在组件宽度 60% 的半径范围内。
smoothstep → smoothstep(0.0, maxRadius, dist) 在中心(dist 为 0)返回 0.0,并在边缘(dist 为 maxRadius)平滑地插值到 1.0。
1.0 - smoothstep(…) → 我们翻转这个逻辑,使中心值变为 1.0(最亮),边缘值变为 0.0(不可见)。
glow = glow * glow; 这行代码是对伽马值或衰减曲线的简单调整。对值进行平方会使衰减曲线更陡峭,让中间亮度的区域下降得更快,从而防止发光看起来过于线性或平淡,使其视觉效果更加自然。
最后一步,我们将空间发光逻辑与动画进度结合,计算出最终的颜色透明度:
alpha 计算 → 透明度是三个因子的乘积:glow(空间衰减)、progress(动画状态)和常数 0.4。这个常数作为强度的上限,确保发光不透明度不超过40%,保持效果的微妙感。
half4(color, alpha) → 函数返回一个 half4 (RGBA) 类型的颜色,将计算出的 alpha 透明度应用到输入的 color 上。
创建自定义底部菜单项
着色器脚本准备就绪后,我们需要一个承载它的界面。接下来构建一个 BottomNavigationItem 可组合项。这段代码处理选中状态逻辑,并将我们的 GLOW_SHADER 包装在 RuntimeShader 中进行渲染。
@Composable
fun RowScope.BottomNavigationItem(
icon: ImageVector,
label: String,
selected: Boolean,
onClick: () -> Unit
) {
val selectionProgress by animateFloatAsState(
targetValue = if (selected) 1.5f else 0f,
animationSpec = tween(durationMillis = 300),
label = "GlowAnimation"
)
val scale by animateFloatAsState(
targetValue = if (selected) 1.5f else 1.0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioHighBouncy,
stiffness = Spring.StiffnessMedium
),
label = "BounceAnimation"
)
val primaryColor = MaterialTheme.colorScheme.primary
val iconColor = if (selected) MaterialTheme.colorScheme.primary else TextGray
val shaderModifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val shader = remember { RuntimeShader(GLOW_SHADER) }
Modifier.drawWithCache {
val brush = ShaderBrush(shader)
shader.setFloatUniform("resolution", size.width, size.height)
shader.setFloatUniform("progress", selectionProgress)
shader.setFloatUniform("color", primaryColor.red, primaryColor.green, primaryColor.blue)
onDrawBehind {
drawRect(brush)
}
}
} else {
Modifier
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxHeight()
.weight(1f)
.clickable { onClick() }
.then(shaderModifier)
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = iconColor,
modifier = Modifier
.size(26.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
)
}
}
我们来分解一下上面的代码:
动画状态管理
selectionProgress → 使用一个标准的补间动画,该值(0f 到 1.5f)会传递给着色器,控制发光的不透明度和扩散范围。
scale → 使用一个高弹性的弹簧动画,在选中时放大图标,赋予其一种有趣的、触觉般的反馈感。
着色器实现细节
- 版本检查 →
RuntimeShader 从 Android 13 (Tiramisu) 开始引入。我们将逻辑包装在版本检查中,以确保向后兼容。在不支持的旧设备上,发光效果将不会渲染,实现优雅降级。
drawWithCache → 出于性能考虑,我们使用这个修饰符而非标准的 drawBehind。它允许我们创建一次 ShaderBrush 并复用,仅在尺寸或状态改变时更新 Uniforms。
onDrawBehind → 关键点在于,我们在内容后面绘制着色器,这样发光效果就会出现在图标的下方作为背景。
- Uniform 传值:
resolution → 传递 size.width 和 size.height,告知着色器绘图区域的中心位置。
progress → 传递动画的 selectionProgress 值。
color → 从 Compose 主题的 primaryColor 中提取红、绿、蓝分量。
最终,我们将 shaderModifier 应用到 Column 容器上。Icon 使用 .graphicsLayer { … } 来高效地应用缩放动画,避免触发重新布局。最终得到一个能够处理点击、渲染背景发光(在支持的设备上)并容纳弹跳图标的可组合项。
组装最终的自定义底部导航栏
各个组件准备就绪后,让我们来组装最终的 BottomNavBar。这一步,我们将发光的菜单项与标准的 BottomAppBar 结合,并集成一个居中的浮动操作按钮。我们使用简单的负向偏移量和 FAB 上的边框来创造出“悬浮”在导航栏上的视错觉,无需复杂的路径裁剪。
@Composable
fun BottomNavBar(onFabClick: () -> Unit) {
var selectedTab by remember { mutableIntStateOf(0) }
BottomAppBar(
modifier = Modifier.clip(RoundedCornerShape(topStart = 30.dp, topEnd = 30.dp)),
containerColor = MaterialTheme.colorScheme.surface,
contentColor = TextGray,
tonalElevation = 8.dp
) {
BottomNavigationItem(
icon = Icons.Default.CalendarMonth,
label = "Tasks",
selected = selectedTab == 0,
onClick = { selectedTab = 0 }
)
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
FloatingActionButton(
onClick = onFabClick,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = CircleShape,
modifier = Modifier
.offset(y = (-10).dp)
.size(56.dp)
.border(4.dp, MaterialTheme.colorScheme.surface, CircleShape)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add Task",
modifier = Modifier.size(24.dp)
)
}
}
BottomNavigationItem(
icon = Icons.Default.Search,
label = "Search",
selected = selectedTab == 1,
onClick = { selectedTab = 1 }
)
}
}
将所有部分组合起来,我们便得到了一个完全交互式的、带有动态发光效果的现代化底部导航栏。这种在 Android 开发中结合声明式 UI 与高性能图形的方法,极大地拓展了 Jetpack Compose 的视觉表达能力。
要点总结
构建自定义 UI 不仅是摆放元素,更是塑造用户体验的“感觉”。通过使用 AGSL,我们实现了一种如果仅用标准 Canvas 绘图命令会非常复杂且耗费资源的视觉效果。我们创建出了一种柔和、非线性的发光效果,能够即时响应用户交互,同时保持了 Compose 代码的整洁与模块化。这种 Shader + Modifier 的模式是一种可复用的技术,你可以将其轻松应用到应用中的按钮、卡片或加载状态上,为你的产品带来独特的视觉个性。
如果你对如何在移动应用开发中实现更多酷炫的 UI 动效和图形技术感兴趣,欢迎到 云栈社区 的移动开发板块与其他开发者交流探讨。
原文链接: https://proandroiddev.com/building-a-glowing-bottom-navigation-with-agsl-shaders-6a5faa547e09