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

前言

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

图片来自:分段按钮

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

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

单选分段按钮

代码如下:

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" ],然后使用了 SingleChoiceSegmentedButtonRow 和 SegmentedButton 的组合,就实现了以上效果。

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

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

自适应分段按钮

分段按钮的 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

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

  • 出现时,是淡入、水平展开、缩放以及轻微的纵向滑入效果。
  • 离开时,则是淡出、水平收缩、缩放以及纵向滑出的组合。
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))
    }
}

最终效果:

相关推荐
三少爷的鞋43 分钟前
Android 架构指南:suspend 如何响应取消?别再给 Repository 加 `cancel()`
android
迪飞特科技1 小时前
【无标题】
android·人工智能·本地化大模型
FlightYe2 小时前
视界原理之2D视频(二):视频文件里有什么
android·linux·网络·c++·ffmpeg·音视频·aac
传奇开心果编程2 小时前
【Flutter入门练中学】第8课:动画与过渡
android·学习·flutter·ui·ios
传奇开心果编程4 小时前
【Flutter入门练中学】第11课:状态管理进阶与声明式路由
android·学习·flutter·ui·ios
JasonSJX4 小时前
四端自建播放器怎么落地:Android、iOS、tvOS、Tizen 的 DRM 播放 SDK 技术盘点
android·ios·音视频·视频防录屏·加密保护课程·直播安全
驰骋工作流5 小时前
工作流引擎四大流程模块功能点统计:769 项能力清单梳理低代码工作流引擎表单
android·低代码·rxjava
恋猫de小郭5 小时前
Flutter 多窗口重要优化合并,多窗口性能和实用性大幅提升
android·前端·flutter
AirDroid_cn5 小时前
实时定位孩子手机超便捷!iPhone家长同步收提醒,省心又踏实
android
hai_android6 小时前
LruCache 图片浏览器内存缓存
android·java·kotlin