[译]在Jetpack Compose中实现毛玻璃效果的底部导航栏

figure-01

在本文中,我们将创建一个玻璃形态设计风格的底部导航栏。我们将使用 Chris Banes 的新 Haze 库来完成这项工作。这个库使我们能够轻松地模糊 Composable 的背景,这意味着元素后面的任何内容都将可见,但会模糊。

最终,我们将构建一个如下所示的底部导航栏:

figure-02

准备工作

首先,让我们创建一个基本布局来应用我们的毛玻璃效果底部导航栏。我们将使用 Scaffold 来在屏幕上放置主要内容和底部导航栏,如下所示:

Scaffold(  
        bottomBar = { /* BOTTOM NAVIGATION BAR */ }  
    ) { padding ->  
        LazyColumn(  
            Modifier.fillMaxSize(),  
            contentPadding = padding  
        ) {  
            items(50) {  
                // IMAGE CARDS 
            }        
        }    
    }

注意,我们将 Scaffold 提供的padding作为 contentPadding 使用,而不是作为padding修饰符。这是为了让内容占据整个屏幕,底部导航(或顶部应用栏,如果存在)将覆盖在内容之上。 但当我们到达列表的末端时,会为导航栏留出足够的空间。

关于底部导航栏的实现,让我们创建一些要显示的示例标签。

sealed class BottomBarTab(val title: String, val icon: ImageVector, val color: Color) {  
    data object Profile : BottomBarTab(  
        title = "Profile",  
        icon = Icons.Rounded.Person,  
        color = Color(0xFFFFA574)  
    )  
    data object Home : BottomBarTab(  
        title = "Home",  
        icon = Icons.Rounded.Home,  
        color = Color(0xFFFA6FFF)  
    )  
    data object Settings : BottomBarTab(  
        title = "Settings",  
        icon = Icons.Rounded.Settings,  
        color = Color(0xFFADFF64)  
    )  
}  
  
val tabs = listOf(  
    BottomBarTab.Profile,  
    BottomBarTab.Home,  
    BottomBarTab.Settings,  
)

BottomBarTab 密封类定义了一些包含基本信息的标签。每个标签都有自己的标题、图标和选中时的独特颜色。

有了这些示例标签,让我们模拟它们之间的切换。为此,我们将使用一个在点击标签时改变的整数状态。

var selectedTabIndex by remember { mutableIntStateOf(1) }  
Box(  
	modifier = Modifier  
		.padding(vertical = 24.dp, horizontal = 64.dp)  
		.fillMaxWidth()  
		.height(64.dp)
) {  
	BottomBarTabs(  
		tabs,  
		selectedTab = selectedTabIndex,  
		onTabSelected = {  
			selectedTabIndex = tabs.indexOf(it)  
		}  
	)
}

现在让我们把这些都整合在一起,在 BottomBarTabs 中渲染底部导航栏:

@Composable  
fun BottomBarTabs(  
    tabs: List<BottomBarTab>,  
    selectedTab: Int,  
    onTabSelected: (BottomBarTab) -> Unit,  
) {  
    CompositionLocalProvider(  
        LocalTextStyle provides LocalTextStyle.current.copy(  
            fontSize = 12.sp,  
            fontWeight = FontWeight.Medium,  
        ),  
        LocalContentColor provides Color.White  
    ) {  
        Row(  
            modifier = Modifier.fillMaxSize(),  
        ) {  
            for (tab in tabs) {  
                val alpha by animateFloatAsState(  
                    targetValue = if (selectedTab == tabs.indexOf(tab)) 1f else .35f,  
                    label = "alpha"  
                )  
                val scale by animateFloatAsState(  
                    targetValue = if (selectedTab == tabs.indexOf(tab)) 1f else .98f,  
                    visibilityThreshold = .000001f,  
                    animationSpec = spring(  
                        stiffness = Spring.StiffnessLow,  
                        dampingRatio = Spring.DampingRatioMediumBouncy,  
                    ),  
                    label = "scale"  
                )  
                Column(  
                    modifier = Modifier  
                        .scale(scale)  
                        .alpha(alpha)  
                        .fillMaxHeight()  
                        .weight(1f)  
                        .pointerInput(Unit) {  
                            detectTapGestures {  
                                onTabSelected(tab)  
                            }  
                        },  
                    horizontalAlignment = Alignment.CenterHorizontally,  
                    verticalArrangement = Arrangement.Center,  
                ) {  
                    Icon(imageVector = tab.icon, contentDescription = "tab ${tab.title}")  
                    Text(text = tab.title)  
                }  
            }  
        }  
    }
}

在这里,我们遍历所有标签,并在一个Row中使用我们之前定义的标题和图标渲染它们。我们还为切换标签添加了一些基本动画。 有一个透明度动画使非活动标签变暗,以及一个缩放动画,为标签选择添加一点动感。

透明底部导航栏

现在我们已经完成了设置,让我们继续创建玻璃效果。

Haze库设置

在项目中包含 Haze 库,并确保版本是 0.4.1 或更高。

dependencies { 
	implementation("dev.chrisbanes.haze:haze-jetpack-compose:0.4.1") 
}

要实现模糊效果,我们必须告诉 Haze 哪个 Composable 需要模糊,以及哪个 Composable 引起和定位该模糊。我们分别使用 hazehazeChild 修饰符来完成这个,并使用 hazeState 将它们连接起来。

val hazeState = remember { HazeState() }

在我们的例子中,我们想要模糊的 Composable 是 LazyColumn。所以让我们给它添加 haze 修饰符。

LazyColumn(  
    Modifier  
        .haze(  
            hazeState,  
            backgroundColor = MaterialTheme.colorScheme.background,  
            tint = Color.Black.copy(alpha = .2f),  
            blurRadius = 30.dp,  
        )  
        .fillMaxSize(),  
    contentPadding = padding  
) { ... }

haze 修饰符中,传入 hazeState 后,我还传入了一些对我来说效果不错的值。但你可以进一步实验这些值来创造独特的效果。

至于 hazeChild 修饰符,我们将把它应用到底部导航栏上:

Box(  
	modifier = Modifier  
		.padding(vertical = 24.dp, horizontal = 64.dp)  
		.fillMaxWidth()  
		.height(64.dp)  
		.hazeChild(state = hazeState)
    ) {  
        BottomBarTabs(...)
    }

我们应用修饰符并传入与 LazyColumn 中相同的 hazeState。这给我们带来了以下结果。

应用了haze模糊效果

这看起来不错,但我们想要模糊区域有圆形的形状。幸运的是,Haze 库允许我们传入一个形状来定义要模糊的区域边界。

.hazeChild(state = hazeState, shape = CircleShape)

现在我们为底部导航栏有了一个漂亮的圆形模糊形状。

为模糊区域添加圆形形状

细节完善

目前我们的效果看起来还可以,但我们可以添加一些更多的细节来让它更上一层楼。第一个改变很简单,但它会大大改善我们底部导航栏的外观。

让我们在模糊区域周围添加一个边框。

.hazeChild(state = hazeState, shape = CircleShape)  
.border(  
    width = Dp.Hairline,  
    brush = Brush.verticalGradient(  
        colors = listOf(  
            Color.White.copy(alpha = .8f),  
            Color.White.copy(alpha = .2f),  
        ),  
    ),  
    shape = CircleShape  
)

这样做给元素带来了一定的秩序感。为了配合玻璃美感,我们不是简单地添加一个实心边框,而是使用一个渐变来模拟光线落在元素顶部的效果。

在模糊区域周围添加边框

接下来,我们将在选择标签时添加一些动画。

首先让我们实现一些动画值。我们将为选定的标签索引和我们之前设置的标签颜色添加动画。

BottomBarTabs(...)  
  
val animatedSelectedTabIndex by animateFloatAsState(  
    targetValue = selectedTabIndex.toFloat(), 
    label = "animatedSelectedTabIndex",  
    animationSpec = spring(  
        stiffness = Spring.StiffnessLow,  
        dampingRatio = Spring.DampingRatioLowBouncy,  
    )  
)  
  
val animatedColor by animateColorAsState(  
    targetValue = tabs[selectedTabIndex].color,  
    label = "animatedColor",  
    animationSpec = spring(  
        stiffness = Spring.StiffnessLow,  
    )  
)

有了这些值,让我们添加一个会在当前选中标签上动画的光晕,并将其光晕颜色动画到相应的标签颜色。

Canvas(  
    modifier = Modifier  
        .fillMaxSize()  
        .clip(CircleShape)  
        .blur(50.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)  
) {  
    val tabWidth = size.width / tabs.size  
    drawCircle(  
        color = animatedColor.copy(alpha = .6f),  
        radius = size.height / 2,  
        center = Offset(  
            (tabWidth * animatedSelectedTabIndex) + tabWidth / 2,  
            size.height / 2  
        )  
    )  
}

我们使用一个 Canvas 来实现这个效果,在其中绘制一个圆,其中心是当前标签的中心。这个 Canvas 的大小与底部导航栏完全相同,所以我们可以平均分割它,并使用 animatedSelectedTabIndex 为选中的标签添加动画。 圆的颜色是 animatedColor 的一个低透明度变体。

最后,我们模糊整个画布并裁剪它,这样模糊就不会落在下面的内容上。

添加了光晕指示器

最后一个细节是在底部导航栏的底部添加一个光泽。这模拟了指示器光晕在我们的玻璃状 Composable 底部边缘的反射。我们也将使用一个裁剪过的 Canvas 来实现这个效果,但不需要模糊。

Canvas(  
    modifier = Modifier  
        .fillMaxSize()  
        .clip(CircleShape)  
) {  
    val path = Path().apply {  
        addRoundRect(RoundRect(size.toRect(), CornerRadius(size.height)))  
    }  
    val length = PathMeasure().apply { setPath(path, false) }.length  
  
    val tabWidth = size.width / tabs.size  
    drawPath(  
        path,  
        brush = Brush.horizontalGradient(  
            colors = listOf(  
                animatedColor.copy(alpha = 0f),  
                animatedColor.copy(alpha = 1f),  
                animatedColor.copy(alpha = 1f),  
                animatedColor.copy(alpha = 0f),  
            ),  
            startX = tabWidth * animatedSelectedTabIndex,  
            endX = tabWidth * (animatedSelectedTabIndex + 1),  
        ),  
        style = Stroke(  
            width = 6f,  
            pathEffect = PathEffect.dashPathEffect(  
                intervals = floatArrayOf(length / 2, length)  
            )  
        )  
    )  
}

Canvas 中,我们创建一个围绕底部导航栏的圆角矩形路径,然后测量其长度。

一旦我们定义了路径,我们就用 animatedColor 的渐变来绘制它。这个渐变的位置从当前选中标签的左边缘开始,到右边缘结束。这是通过 animatedSelectedTabIndex 计算的,所以它会随着光晕平滑地动画。 最后,我们添加一个 dashPathEffect,它只绘制路径的下半部分。

这样,我们就得到了这个令人愉悦的底部导航栏动画。

最终动画效果

完整代码可在这里找到。

这只是我们用这种玻璃形态UI能实现的效果的开始。我希望这篇文章能教会你并激发你走得更远。

感谢阅读,祝你好运!