请添加图片描述

项目开源地址:https://atomgit.com/nutpi/cmp_openharmony

前言

标签(Tag)是移动应用中非常常见的UI元素,用于分类、筛选、状态展示等场景。本文将详细介绍如何使用 Compose Multiplatform(CMP)在 OpenHarmony 平台上开发多种实用的标签组件,包括基础标签、可选择标签、可关闭标签、状态标签和数字徽章等。

一、数据模型定义

首先,我们需要定义标签相关的数据模型和配置项。

1.1 标签数据类

data class TagData(
    val id: String,
    val text: String,
    val color: Color = Color(0xFF6200EE),
    val isSelected: Boolean = false,
    val isCloseable: Boolean = false
)

这个数据类定义了标签的基本属性。id 用于唯一标识每个标签,在列表操作时非常重要。text 是标签显示的文本内容。color 定义标签的主题色,默认使用紫色。isSelectedisCloseable 分别控制标签的选中状态和是否可关闭。

1.2 预定义颜色

object TagColors {
    val Purple = Color(0xFF6200EE)
    val Blue = Color(0xFF2196F3)
    val Green = Color(0xFF4CAF50)
    val Orange = Color(0xFFFF9800)
    val Red = Color(0xFFF44336)
    val Pink = Color(0xFFE91E63)
    val Teal = Color(0xFF009688)
    val Indigo = Color(0xFF3F51B5)
    val Gray = Color(0xFF9E9E9E)
}

预定义颜色对象提供了一组精心挑选的颜色常量,这些颜色在视觉上协调统一,可以直接在标签组件中使用。使用 object 单例模式确保颜色值在整个应用中保持一致,同时也方便后续维护和修改。

1.3 标签尺寸枚举

enum class TagSize {
    SMALL,   // 小尺寸
    MEDIUM,  // 中等尺寸
    LARGE    // 大尺寸
}

通过枚举定义三种标准尺寸,让标签组件能够适应不同的使用场景。小尺寸适合紧凑的列表项,中等尺寸是默认选择,大尺寸则用于需要突出显示的场合。

1.4 标签样式枚举

enum class TagStyle {
    FILLED,      // 填充样式
    OUTLINED,    // 边框样式
    SOFT         // 柔和样式(浅色背景)
}

三种样式枚举覆盖了常见的标签视觉需求。FILLED 填充样式最为醒目,适合重要标签;OUTLINED 边框样式较为轻量,不会喧宾夺主;SOFT 柔和样式使用浅色背景,视觉上最为柔和,适合大量标签并存的场景。

二、基础标签组件

2.1 BasicTag 组件

@Composable
fun BasicTag(
    text: String,
    modifier: Modifier = Modifier,
    color: Color = TagColors.Purple,
    style: TagStyle = TagStyle.FILLED,
    size: TagSize = TagSize.MEDIUM,
    onClick: (() -> Unit)? = null
) {
    val (paddingH, paddingV, fontSize) = when (size) {
        TagSize.SMALL -> Triple(8.dp, 2.dp, 10.sp)
        TagSize.MEDIUM -> Triple(12.dp, 4.dp, 12.sp)
        TagSize.LARGE -> Triple(16.dp, 6.dp, 14.sp)
    }

组件函数签名设计遵循 Compose 最佳实践,将必需参数 text 放在最前面,可选参数使用默认值。使用 when 表达式根据尺寸计算对应的内边距和字体大小,Triple 数据类让我们能够优雅地一次性解构三个值。

    val (bgColor, textColor, borderColor) = when (style) {
        TagStyle.FILLED -> Triple(color, Color.White, Color.Transparent)
        TagStyle.OUTLINED -> Triple(Color.Transparent, color, color)
        TagStyle.SOFT -> Triple(color.copy(alpha = 0.15f), color, Color.Transparent)
    }

根据样式类型计算背景色、文字色和边框色。填充样式使用主题色作为背景、白色文字;边框样式背景透明、使用主题色作为文字和边框;柔和样式使用 15% 透明度的主题色作为背景,这个透明度值经过测试,在视觉上既能体现颜色又不会过于强烈。

    Box(
        modifier = modifier
            .clip(RoundedCornerShape(16.dp))
            .background(bgColor)
            .then(
                if (borderColor != Color.Transparent) {
                    Modifier.border(1.dp, borderColor, RoundedCornerShape(16.dp))
                } else Modifier
            )
            .then(
                if (onClick != null) {
                    Modifier.clickable(
                        indication = null,
                        interactionSource = remember { MutableInteractionSource() }
                    ) { onClick() }
                } else Modifier
            )
            .padding(horizontal = paddingH, vertical = paddingV),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = text,
            fontSize = fontSize,
            color = textColor,
            fontWeight = FontWeight.Medium
        )
    }
}

使用 Box 作为容器,通过 clip 裁剪实现圆角效果。then 函数用于条件性地添加修饰符,只有当边框色不透明时才添加边框,只有当 onClick 不为空时才添加点击事件。设置 indication = null 可以移除默认的点击波纹效果,让标签看起来更加简洁。

2.2 带图标的标签

@Composable
fun IconTag(
    text: String,
    icon: String,
    modifier: Modifier = Modifier,
    color: Color = TagColors.Purple,
    style: TagStyle = TagStyle.FILLED,
    size: TagSize = TagSize.MEDIUM,
    iconPosition: IconPosition = IconPosition.START,
    onClick: (() -> Unit)? = null
) {

IconTag 在基础标签的基础上增加了图标支持。icon 参数使用字符串类型,可以传入 emoji 表情或文字图标。iconPosition 参数控制图标显示在文字的前面还是后面,提供了灵活的布局选项。

    Row(
        modifier = modifier
            .clip(RoundedCornerShape(16.dp))
            .background(bgColor)
            // ... 其他修饰符
            .padding(horizontal = paddingH, vertical = paddingV),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (iconPosition == IconPosition.START) {
            Text(text = icon, fontSize = fontSize, color = textColor)
            Spacer(modifier = Modifier.width(4.dp))
        }

        Text(
            text = text,
            fontSize = fontSize,
            color = textColor,
            fontWeight = FontWeight.Medium
        )

        if (iconPosition == IconPosition.END) {
            Spacer(modifier = Modifier.width(4.dp))
            Text(text = icon, fontSize = fontSize, color = textColor)
        }
    }
}

enum class IconPosition { START, END }

使用 Row 布局来水平排列图标和文字。通过条件判断 iconPosition 的值来决定图标的位置,Spacer 组件在图标和文字之间添加 4dp 的间距,保证视觉上的舒适感。枚举 IconPosition 定义了两个位置选项,语义清晰。

三、可选择标签组件

3.1 SelectableTag 组件

@Composable
fun SelectableTag(
    text: String,
    isSelected: Boolean,
    onSelect: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    selectedColor: Color = TagColors.Purple,
    unselectedColor: Color = Color.Gray,
    size: TagSize = TagSize.MEDIUM
) {

可选择标签是交互性最强的标签类型,常用于筛选器、标签选择等场景。isSelected 参数由外部控制,遵循单向数据流原则;onSelect 回调将新的选中状态传递给父组件。

    val bgColor by animateColorAsState(
        if (isSelected) selectedColor else Color.Transparent
    )
    val textColor by animateColorAsState(
        if (isSelected) Color.White else unselectedColor
    )
    val borderColor by animateColorAsState(
        if (isSelected) selectedColor else unselectedColor.copy(alpha = 0.5f)
    )
    val scale by animateFloatAsState(
        targetValue = if (isSelected) 1.05f else 1f,
        animationSpec = spring()
    )

使用 animateColorAsStateanimateFloatAsState 为颜色和缩放添加平滑的过渡动画。当选中状态改变时,背景色、文字色、边框色都会有渐变效果,同时标签会有轻微的放大动画(1.05倍),使用 spring() 弹簧动画让效果更加自然生动。

    Box(
        modifier = modifier
            .scale(scale)
            .clip(RoundedCornerShape(16.dp))
            .background(bgColor)
            .border(1.dp, borderColor, RoundedCornerShape(16.dp))
            .clickable(
                indication = null,
                interactionSource = remember { MutableInteractionSource() }
            ) { onSelect(!isSelected) }
            .padding(horizontal = paddingH, vertical = paddingV),
        contentAlignment = Alignment.Center
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            if (isSelected) {
                Text(text = "✓", fontSize = fontSize, color = textColor)
                Spacer(modifier = Modifier.width(4.dp))
            }
            Text(
                text = text,
                fontSize = fontSize,
                color = textColor,
                fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
            )
        }
    }
}

scale 修饰符应用缩放动画效果。点击时调用 onSelect(!isSelected) 切换选中状态。选中时会在文字前显示一个勾选符号 “✓”,并将字体加粗,这些视觉反馈让用户能够清楚地识别当前选中的标签。

3.2 可关闭标签

@Composable
fun CloseableTag(
    text: String,
    onClose: () -> Unit,
    modifier: Modifier = Modifier,
    color: Color = TagColors.Purple,
    style: TagStyle = TagStyle.SOFT,
    size: TagSize = TagSize.MEDIUM
) {

可关闭标签常用于已选择的筛选条件、搜索历史等场景,用户可以点击关闭按钮移除标签。默认使用 SOFT 柔和样式,因为这类标签通常会有多个同时显示。

    Row(
        modifier = modifier
            .clip(RoundedCornerShape(16.dp))
            .background(bgColor)
            .then(
                if (style == TagStyle.OUTLINED) {
                    Modifier.border(1.dp, color, RoundedCornerShape(16.dp))
                } else Modifier
            )
            .padding(start = paddingH, end = 6.dp, top = paddingV, bottom = paddingV),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = text,
            fontSize = fontSize,
            color = textColor,
            fontWeight = FontWeight.Medium
        )

        Spacer(modifier = Modifier.width(4.dp))

        Box(
            modifier = Modifier
                .size(16.dp)
                .clip(RoundedCornerShape(8.dp))
                .background(textColor.copy(alpha = 0.2f))
                .clickable(
                    indication = null,
                    interactionSource = remember { MutableInteractionSource() }
                ) { onClose() },
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "✕",
                fontSize = 10.sp,
                color = textColor
            )
        }
    }
}

注意右侧内边距设置为 6dp 而不是 paddingH,这是为了让关闭按钮更靠近边缘,视觉上更加平衡。关闭按钮使用一个 16dp 的圆形 Box,背景色为文字色的 20% 透明度,既能看清又不会太突兀。点击关闭按钮时调用 onClose() 回调,由父组件处理标签的移除逻辑。

四、状态标签组件

4.1 StatusTag 组件

@Composable
fun StatusTag(
    text: String,
    status: TagStatus,
    modifier: Modifier = Modifier,
    size: TagSize = TagSize.MEDIUM,
    showDot: Boolean = true
) {
    val color = status.color

状态标签用于显示各种状态信息,如成功、错误、警告等。status 参数使用枚举类型,每种状态都有对应的颜色。showDot 参数控制是否显示状态指示圆点。

    Row(
        modifier = modifier
            .clip(RoundedCornerShape(12.dp))
            .background(color.copy(alpha = 0.15f))
            .padding(horizontal = paddingH, vertical = paddingV),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (showDot) {
            if (status == TagStatus.PROCESSING) {
                PulsingDot(color = color)
            } else {
                Box(
                    modifier = Modifier
                        .size(6.dp)
                        .background(color, CircleShape)
                )
            }
            Spacer(modifier = Modifier.width(6.dp))
        }

        Text(
            text = text,
            fontSize = fontSize,
            color = color,
            fontWeight = FontWeight.Medium
        )
    }
}

状态标签使用 12dp 的圆角,比普通标签稍小,视觉上更加精致。背景使用 15% 透明度的状态色。当状态为 PROCESSING(处理中)时,显示一个带脉冲动画的圆点,其他状态显示静态圆点。这个细节让"处理中"状态更加生动,用户能够直观感受到正在进行的操作。

4.2 脉冲动画圆点

@Composable
private fun PulsingDot(color: Color) {
    val infiniteTransition = rememberInfiniteTransition()
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(800),
            repeatMode = RepeatMode.Reverse
        )
    )
    val alpha by infiniteTransition.animateFloat(
        initialValue = 1f,
        targetValue = 0.5f,
        animationSpec = infiniteRepeatable(
            animation = tween(800),
            repeatMode = RepeatMode.Reverse
        )
    )

    Box(
        modifier = Modifier
            .size(6.dp)
            .scale(scale)
            .background(color.copy(alpha = alpha), CircleShape)
    )
}

脉冲动画使用 rememberInfiniteTransition 创建无限循环的动画。同时对缩放(0.8 到 1.2)和透明度(1 到 0.5)进行动画处理,800ms 的周期配合 RepeatMode.Reverse 反向重复,产生呼吸般的脉冲效果。这种动画既能吸引注意力,又不会过于干扰用户。

4.3 状态枚举

enum class TagStatus(val color: Color) {
    SUCCESS(Color(0xFF4CAF50)),
    ERROR(Color(0xFFF44336)),
    WARNING(Color(0xFFFF9800)),
    INFO(Color(0xFF2196F3)),
    PROCESSING(Color(0xFF6200EE)),
    DEFAULT(Color(0xFF9E9E9E))
}

状态枚举将状态类型和对应颜色绑定在一起,这是 Kotlin 枚举的强大特性。绿色表示成功、红色表示错误、橙色表示警告、蓝色表示信息、紫色表示处理中、灰色表示默认状态,这些颜色选择符合用户的直觉认知。

五、数字徽章组件

5.1 NumberBadge 组件

@Composable
fun NumberBadge(
    count: Int,
    modifier: Modifier = Modifier,
    color: Color = Color.Red,
    maxCount: Int = 99
) {
    if (count <= 0) return

    val text = if (count > maxCount) "$maxCount+" else count.toString()

    Box(
        modifier = modifier
            .background(color, RoundedCornerShape(10.dp))
            .padding(horizontal = 6.dp, vertical = 2.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = text,
            fontSize = 10.sp,
            color = Color.White,
            fontWeight = FontWeight.Bold
        )
    }
}

数字徽章用于显示未读消息数、通知数量等。当 count 小于等于 0 时直接返回不显示任何内容。maxCount 参数设置最大显示数字,超过时显示 “99+”,这是移动应用的常见做法,避免数字过长影响布局。使用 10dp 的圆角和紧凑的内边距,让徽章看起来小巧精致。

5.2 圆点徽章

@Composable
fun DotBadge(
    modifier: Modifier = Modifier,
    color: Color = Color.Red,
    size: Dp = 8.dp
) {
    Box(
        modifier = modifier
            .size(size)
            .background(color, CircleShape)
    )
}

圆点徽章是最简单的徽章形式,只显示一个小圆点,用于表示"有新内容"但不显示具体数量。默认 8dp 的尺寸在大多数场景下都很合适,size 参数允许根据需要调整大小。使用 CircleShape 确保无论什么尺寸都是完美的圆形。

六、使用示例

6.1 基础标签使用

// 不同样式
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    BasicTag(text = "填充样式", style = TagStyle.FILLED)
    BasicTag(text = "边框样式", style = TagStyle.OUTLINED)
    BasicTag(text = "柔和样式", style = TagStyle.SOFT)
}

// 不同颜色
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    BasicTag(text = "紫色", color = TagColors.Purple)
    BasicTag(text = "蓝色", color = TagColors.Blue)
    BasicTag(text = "绿色", color = TagColors.Green)
}

基础标签的使用非常简单,只需传入文本即可使用默认样式。通过 stylecolor 参数可以轻松定制外观。Arrangement.spacedBy(8.dp) 让标签之间保持统一的间距。

6.2 带图标标签使用

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    IconTag(text = "热门", icon = "🔥", color = TagColors.Red)
    IconTag(text = "新品", icon = "✨", color = TagColors.Orange)
    IconTag(
        text = "收藏",
        icon = "❤️",
        iconPosition = IconPosition.END,
        style = TagStyle.SOFT
    )
}

带图标的标签可以使用 emoji 作为图标,简单直观。通过 iconPosition 参数可以将图标放在文字后面,适合"收藏"、"分享"等操作类标签。

6.3 可选择标签使用

var selectedTag by remember { mutableStateOf<String?>(null) }
val tags = listOf("Kotlin", "Swift", "Java", "TypeScript")

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    tags.forEach { tag ->
        SelectableTag(
            text = tag,
            isSelected = tag == selectedTag,
            onSelect = { if (it) selectedTag = tag }
        )
    }
}

单选模式下,使用一个可空的 String 状态来记录当前选中的标签。在 onSelect 回调中,只有当 ittrue(即选中操作)时才更新状态,这样点击已选中的标签不会取消选择。

6.4 多选标签使用

var selectedTags by remember { mutableStateOf(setOf<String>()) }
val tags = listOf("Android", "iOS", "HarmonyOS", "Web")

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    tags.forEach { tag ->
        SelectableTag(
            text = tag,
            isSelected = tag in selectedTags,
            onSelect = { selected ->
                selectedTags = if (selected) {
                    if (selectedTags.size < 3) selectedTags + tag else selectedTags
                } else {
                    selectedTags - tag
                }
            },
            selectedColor = TagColors.Blue
        )
    }
}

多选模式使用 Set<String> 来存储选中的标签,Set 的特性保证不会有重复项。这里还实现了最多选择 3 个的限制,当已选择 3 个时,再点击其他标签不会生效。使用 +- 操作符可以方便地添加或移除集合元素。

6.5 状态标签使用

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    StatusTag(text = "成功", status = TagStatus.SUCCESS)
    StatusTag(text = "错误", status = TagStatus.ERROR)
    StatusTag(text = "处理中", status = TagStatus.PROCESSING)
}

状态标签的使用最为简单,只需指定文本和状态类型,颜色会自动匹配。PROCESSING 状态会自动显示脉冲动画效果。

七、最佳实践

7.1 颜色一致性

建议在整个应用中使用 TagColors 中预定义的颜色,保持视觉一致性。如果需要自定义颜色,也建议统一定义在一个地方。

7.2 尺寸选择

  • SMALL:适合列表项、紧凑布局
  • MEDIUM:默认选择,适合大多数场景
  • LARGE:适合需要突出显示的场合

7.3 样式搭配

  • 重要标签使用 FILLED 样式
  • 次要标签使用 OUTLINEDSOFT 样式
  • 大量标签并存时优先使用 SOFT 样式

7.4 动画性能

可选择标签的动画效果虽然美观,但在大量标签同时存在时可能影响性能。如果遇到性能问题,可以考虑简化动画或使用 LazyRow 进行懒加载。

总结

本文详细介绍了使用 CMP 在 OpenHarmony 平台上开发标签组件的完整过程。通过合理的数据模型设计、灵活的参数配置和流畅的动画效果,我们实现了一套功能完善、易于使用的标签组件库。这些组件可以直接应用到实际项目中,也可以根据具体需求进行扩展和定制。

CMP 的声明式 UI 编程模式让组件开发变得简洁高效,同时保持了良好的可维护性和可扩展性。希望本文能够帮助你在 OpenHarmony 应用开发中更好地使用标签组件。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐