Jetpack Compose 实战:自定义自适应分段按钮 (Segmented Button)

前言

如果要从并排显示的选项中选择,我们可以使用分段按钮(Segmented Button)

图片来自:分段按钮

分段按钮有两种类型:单选多选 ,对应了 SingleChoiceSegmentedButtonRowMultiChoiceSegmentedButtonRow 布局。

我这里只演示一下单选分段按钮。

单选分段按钮

代码如下:

kotlin 复制代码
@Preview
@Composable
fun SingleChoiceSegmentedButton() {
    // 当前选中按钮的索引
    var selectedIndex by remember { mutableIntStateOf(0) }
    val options = listOf("Sleep", "Work")

    SingleChoiceSegmentedButtonRow {
        options.forEachIndexed { index, label ->
            SegmentedButton(
                // 根据按钮索引和按钮总数获取按钮的形状
                // 只有一项时:全圆;
                // 第一项:起始端圆角(Start);
                // 最后一项:末端圆角(End);
                // 中间项:直角
                shape = SegmentedButtonDefaults.itemShape(
                    index = index,
                    count = options.size
                ),
                onClick = { selectedIndex = index },
                selected = index == selectedIndex,
                label = { Text(label) }
            )
        }
    }
}

运行效果:

上述代码逻辑并不难,我们只是提供了一个标签列表 [ "Sleep", "Work" ],然后使用了 SingleChoiceSegmentedButtonRowSegmentedButton 的组合,就实现了以上效果。

这个效果在大多时候都很不错,但有时按钮中的文本会显得有些拥挤,例如:

这时,我们就需要自定义组件,让被选中的按钮的大小动态变化。

自适应分段按钮

分段按钮的 Material 3 规范:Segmented buttons

首先定义出可选项的数据模型:

kotlin 复制代码
@Immutable // 优化重组检查
data class OptionItem<T>(
    val value: T,
    val label: String,
    val selected: Boolean = false
)

然后就来完成外面的布局和内部的每一项:

kotlin 复制代码
@Composable
fun <T> LitheSegmentedButton(
    modifier: Modifier = Modifier,
    items: List<OptionItem<T>>,
    onClick: (T) -> Unit
) {
    val itemSize = items.size
    
    // 外层容器:负责绘制整体的边框和圆角
    Row(
        modifier = modifier
            .clip(CircleShape)
            .border(
                width = 0.5.dp,
                color = SegmentedButtonDefaults.colors().activeBorderColor,
                shape = CircleShape
            )
            .padding(0.5.dp)
    ) {
        items.forEachIndexed { index, item ->
            // 缓存 Shape 对象,避免每次重组时都重新创建
            val shape = remember(index, itemSize) {
                itemShape(index, itemSize)
            }

            SingleSegmentedButton(
                item = item,
                shape = shape,
                onClick = { onClick(item.value) }
            )
        }
    }
}

@Composable
private fun <T> SingleSegmentedButton(
    item: OptionItem<T>,
    shape: Shape,
    colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(),
    onClick: () -> Unit
) {
    Row(
        Modifier
            .height(40.dp)
            .clip(shape)
            .clickable(onClick = onClick)
            .border(
                width = 0.5.dp,
                color = colors.activeBorderColor,
                shape = shape
            )
            .padding(0.5.dp)
            .background( // 选中状态下的背景色
                if (item.selected) colors.activeContainerColor
                else Color.Transparent,
                shape = shape
            )
            .padding(horizontal = 18.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (item.selected) {
            Row {
                Icon(
                    imageVector = Icons.Default.Done,
                    contentDescription = null,
                    modifier = Modifier
                        .size(18.dp),
                    tint = colors.activeContentColor
                )
                Spacer(modifier = Modifier.width(8.dp))
            }
        }

        Text(
            text = item.label,
            style = MaterialTheme.typography.labelLarge.copy(
                color = if (item.selected) colors.activeContentColor
                else colors.inactiveContentColor
            )
        )
    }
}

/**
 * 根据索引和总数计算按钮的形状
 */
fun itemShape(index: Int, count: Int): Shape {
    if (count == 1) {
        return CircleShape
    }
    return when (index) {
        // 第一个:起始端(左侧)圆角
        0 -> RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50)
        // 最后一个:末端(右侧)圆角
        count - 1 -> RoundedCornerShape(topEndPercent = 50, bottomEndPercent = 50)
        // 中间:直角
        else -> RoundedCornerShape(0)
    }
}

预览一下:

kotlin 复制代码
@Preview(showBackground = true)
@Composable
fun LitheSegmentedButtonPreview() {
    var selectedIndex by remember { mutableIntStateOf(0) }
    val items = listOf(
        OptionItem(0, "Day", selected = selectedIndex == 0),
        OptionItem(1, "Month", selected = selectedIndex == 1),
        OptionItem(2, "Week", selected = selectedIndex == 2),
    )
    Box(modifier = Modifier.fillMaxSize()) {
        LitheSegmentedButton(
            items = items,
            onClick = {
                selectedIndex = it
            },
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

运行效果:

最后一步就是加上动画,我们使用 AnimatedVisibility 来完成。

具体内容可以看我的这篇博客:控制内容的显隐:AnimatedVisibility

为了实现"推动"效果,我们使用了 expandInshrinkOut。并且为了让这个效果看起来更好,又加上淡入淡出、缩放以及滑动效果:

  • 出现时,是淡入、水平展开、缩放以及轻微的纵向滑入效果。
  • 离开时,则是淡出、水平收缩、缩放以及纵向滑出的组合。
kotlin 复制代码
AnimatedVisibility(
    visible = item.selected,
    enter = fadeIn() + expandHorizontally() + scaleIn() + slideInVertically(initialOffsetY = { it / 2 }),
    exit = fadeOut() + shrinkHorizontally() + scaleOut() + slideOutVertically(targetOffsetY = { it / 2 })
) {
    Row {
        Icon(
            imageVector = Icons.Default.Done,
            contentDescription = null,
            modifier = Modifier
                .size(18.dp),
            tint = colors.activeContentColor
        )
        Spacer(modifier = Modifier.width(8.dp))
    }
}

最终效果:

相关推荐
阿巴斯甜15 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker16 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952717 小时前
Andorid Google 登录接入文档
android
黄林晴18 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android