发光导航栏AGSL让App底部秒变赛博夜景
使用 AGSL 着色器打造会发光的底部导航栏
大家好!之前,我探索了如何使用 AGSL 着色器来创建沉浸式的落地页体验。如果你还没看过那篇文章,我强烈建议你先读一读,熟悉一下基础知识。
在单个落地页视图中探索 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(最亮),边缘(maxRadius)值变为 0.0(不可见)。
这行代码是调整渐变伽马值或曲线的简单方法。对值进行平方 (y = x²) 会使衰减更锐利。接近 1.0(中心)的值保持较高,但中间的值下降得更快。这可以防止发光看起来是线性的或平坦的,使其更自然。
在这最后一步,我们将空间发光逻辑与动画 progress 结合起来,以确定输出颜色的确切透明度。
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 else0f, animationSpec = tween(durationMillis =300), label ="GlowAnimation" ) val scale by animateFloatAsState( targetValue =if (selected) 1.5f else1.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:关键的是,我们在内容后面绘制着色器,这样发光效果就会出现在图标下方。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 } ) } }
将所有部分组合在一起,这就是我们完全交互式的发光导航栏的样子:
要点总结
构建自定义 UI 不仅仅是把元素放在屏幕上;更是关于这些元素的感觉。通过使用 AGSL,我们创建了一种视觉效果,如果使用标准的 Canvas 绘图命令来渲染,将会非常困难和昂贵。我们实现了一种柔和、非线性的发光效果,能够即时响应用户输入,同时保持我们的 Compose 代码干净且模块化。这种 Shader + Modifier 的模式是一种可重用的技术,你可以将其应用到应用程序中的按钮、卡片或加载状态上。
如果你觉得这篇文章有帮助,请考虑给它一个 点赞 以示支持!别忘了 关注我的账号,获取更多关于 Android 技术的精彩见解。
保持好奇心,编码愉快!😃
原文链接:https://proandroiddev.com/building-a-glowing-bottom-navigation-with-agsl-shaders-6a5faa547e09
夜雨聆风