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))
    }
}

最终效果:

相关推荐
用户69371750013842 小时前
4.Kotlin 流程控制:强大的 when 表达式:取代 Switch
android·后端·kotlin
用户69371750013842 小时前
5.Kotlin 流程控制:循环的艺术:for 循环与区间 (Range)
android·后端·kotlin
Android系统攻城狮3 小时前
Android ALSA驱动进阶之获取周期帧数snd_pcm_lib_period_frames:用法实例(九十五)
android·pcm·android内核·音频进阶·周期帧数
AskHarries5 小时前
RevenueCat 接入 Google Play 订阅全流程详解(2025 最新)
android·flutter·google
The best are water5 小时前
MySQL FEDERATED引擎跨服务器数据同步完整方案
android·服务器·mysql
消失的旧时光-19436 小时前
我如何理解 Flutter 本质
android·前端·flutter
czhc11400756637 小时前
C#1119记录 类 string.Split type.TryParse(String,out type 变量)
android·c#
豆豆豆大王8 小时前
Android SQLite 数据库开发完全指南:从核心概念到高级操作
android·sqlite·数据库开发
_李小白8 小时前
【Android FrameWork】延伸阅读:AssetManager
android