乐于分享
好东西不私藏

发光导航栏AGSL让App底部秒变赛博夜景

发光导航栏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% 的半径内。
  • smoothstepsmoothstep(0.0, maxRadius, dist) 函数在中心(dist0)返回 0.0,并在边缘(distmaxRadius)平滑地插值到 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 → 我们使用一个标准的补间动画。这个值(0f1.5f)被传递给着色器,以控制发光的不透明度和扩散。
  • scale → 我们使用一个具有高弹性的弹簧动画。这会在选中时放大图标,赋予其一种触觉的、有趣的感觉。
  • 版本检查RuntimeShader 是在 Android 13/Tiramisu 中引入的。我们将逻辑包装在版本检查中,以确保向后兼容性。旧设备将不会渲染发光效果,优雅降级。
  • drawWithCache → 出于性能考虑,我们使用这个而不是标准的 drawBehind。它允许我们创建一次 ShaderBrush 并重复使用,只在大小或状态改变时更新 Uniforms。
  • onDrawBehind:关键的是,我们在内容后面绘制着色器,这样发光效果就会出现在图标下方。
  • resolution → 我们传递 size.widthsize.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