A
第 46 章KOTLIN45 分钟

Compose 常用组件

Material3 Text / Button / TextField / Image / Icon / Card / Slider / Checkbox / RadioButton / Switch / AlertDialog / Snackbar 等核心组件详解

学习目标

  • 熟练使用 Text 的样式 / 颜色 / 截断 / maxLines
  • 掌握 Button / OutlinedButton / TextButton 的取舍
  • 用 TextField 实现表单输入,配置键盘类型与 visualTransformation
  • 用 Card / Icon / Image / Slider / 开关类组件拼业务卡片
  • 用 AlertDialog 与 Snackbar(SnackbarHostState)实现弹窗与提示

学习目标

布局是骨架,组件是血肉。Material3 在 Compose 中以 @Composable 函数形式提供了一系列组件,每个都是“一个状态 + 一段 UI”的封装。本章逐个拆解 Android 日常最常用的 13 个组件。

读完本章你将能够:

  • 用 Text 配 TextStyle / overflow / maxLines 控制文本呈现;
  • 在 Button / OutlinedButton / TextButton 中按视觉重要性挑选;
  • 用 TextField / OutlinedTextField 写表单输入,掌握键盘类型、visualTransformation 与错误状态;
  • 用 Image / Icon / Card 拼出列表项卡片;
  • 用 Slider / Checkbox / RadioButton / Switch 处理用户输入;
  • 用 AlertDialog 弹模态确认框,用 Snackbar 配 SnackbarHostState 显示临时提示。

本章假设你已配置好 Compose BOM(参考第 44 章)。所有 import 路径以 androidx.compose.material3.* 为主。

Text

1. 基本使用

Text(text = "Hello Compose")

2. 样式

Text(
    text = "标题",
    style = MaterialTheme.typography.headlineMedium,
    color = MaterialTheme.colorScheme.primary,
    fontSize = 22.sp,
    fontWeight = FontWeight.Bold,
    fontFamily = FontFamily.SansSerif,
    letterSpacing = 0.5.sp,
    textAlign = TextAlign.Center,
    textDecoration = TextDecoration.Underline,
)

style 是预设模板(M3 提供了 headline / title / body / label 多个层级),其余参数会覆盖 style 中的对应字段。

3. 截断与行数

Text(
    text = "这是一段很长的描述文本,超过两行就会被截断…",
    maxLines = 2,
    overflow = TextOverflow.Ellipsis,        // 超出部分显示…
    softWrap = true,                          // 自动换行(默认 true)
)

overflow 可选 Clip / Ellipsis / Visible。配合 maxLines 处理过长文案。

4. 富文本 AnnotatedString

val annotated = buildAnnotatedString {
    append("同意 ")
    pushStringAnnotation(tag = "URL", annotation = "https://example.com")
    withStyle(SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline)) {
        append("服务条款")
    }
    pop()
    append("。")
}
Text(text = annotated)

ClickableText 还能让链接部分可点击,获取对应 annotation。

5. 多行间距

Text(
    text = "...",
    style = LocalTextStyle.current.copy(lineHeight = 28.sp),
)

Button 系列

Button(onClick = { /* 提交 */ }) {
    Text("提交")
}

参数:

参数 含义
onClick 必填,点击回调(普通 lambda)
enabled 是否可点(false 时变灰)
modifier 布局
shape 圆角,默认 ButtonShape
colors 通过 ButtonDefaults.buttonColors() 自定义颜色
contentPadding 内边距
content 子组件(一般包一个 Text + 可选 Icon)

三种 Button 的取舍:

组件 用途 视觉
Button 主要操作(提交、确认) 填充背景
OutlinedButton 次要操作(取消、重置) 描边无填充
TextButton 文本类操作(“查看更多”) 仅文本
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
    Button(onClick = { /* 提交 */ }) { Text("提交") }
    OutlinedButton(onClick = { /* 取消 */ }) { Text("取消") }
    TextButton(onClick = { /* 跳转 */ }) { Text("查看更多") }
}

带图标的按钮:

Button(onClick = { }) {
    Icon(Icons.Default.Save, contentDescription = null)
    Spacer(Modifier.width(4.dp))
    Text("保存")
}

TextField 与 OutlinedTextField

1. 受控式 API

Compose 的输入框是受控组件:状态在外部,UI 通过 value + onValueChange 反映状态。

@Composable
fun NameField() {
    var name by remember { mutableStateOf("") }
    TextField(
        value = name,
        onValueChange = { name = it },
        label = { Text("用户名") },
        singleLine = true,
        modifier = Modifier.fillMaxWidth(),
    )
}

坑:忘记把 onValueChange 写回状态,输入框不会响应任何字符。

2. 键盘选项

import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction

TextField(
    value = phone,
    onValueChange = { phone = it },
    label = { Text("手机号") },
    keyboardOptions = KeyboardOptions(
        keyboardType = KeyboardType.Phone,
        imeAction = ImeAction.Send,
        autoCorrect = false,
        capitalization = KeyboardCapitalization.None,
    ),
)

KeyboardType 选项:Text / Ascii / Number / Phone / Uri / Email / Password / NumberPassword。

ImeAction 选项:Default / Done / Go / Next / Previous / Search / Send。

3. 密码视觉转换

var password by remember { mutableStateOf("") }
var visible by remember { mutableStateOf(false) }

TextField(
    value = password,
    onValueChange = { password = it },
    label = { Text("密码") },
    singleLine = true,
    visualTransformation =
        if (visible) VisualTransformation.None
        else PasswordVisualTransformation(),
    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
    trailingIcon = {
        IconButton(onClick = { visible = !visible }) {
            Icon(
                imageVector = if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
                contentDescription = if (visible) "隐藏密码" else "显示密码",
            )
        }
    },
)

4. 错误状态

val isError = name.length !in 3..20
TextField(
    value = name,
    onValueChange = { name = it },
    isError = isError,
    label = { Text("用户名") },
    supportingText = {
        if (isError) Text("长度需 3-20") else Text("建议 3-20 字符")
    },
)

5. OutlinedTextField

OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("邮箱") },
)

行为与 TextField 一致,只是视觉是描边而不是填充背景,常用于表单批量输入。

Image 与 Icon

1. Image

Image(
    painter = painterResource(R.drawable.avatar),
    contentDescription = "用户头像",
    contentScale = ContentScale.Crop,
    modifier = Modifier
        .size(80.dp)
        .clip(CircleShape),
)

参数:

参数 含义
painter / imageBitmap / vector 资源 / 位图 / 矢量
contentDescription 无障碍描述,纯装饰图像填 null
contentScale Crop / Fit / FillBounds / Inside
alignment 在自身 bounds 内的对齐
colorFilter / alpha 颜色滤镜与透明度

加载网络图片用 Coil:

implementation("io.coil-kt:coil-compose:2.7.0")
AsyncImage(
    model = "https://example.com/a.jpg",
    contentDescription = "网络图",
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(120.dp).clip(RoundedCornerShape(8.dp)),
)

2. Icon

Icon(
    imageVector = Icons.Default.Settings,
    contentDescription = "设置",
    tint = MaterialTheme.colorScheme.onSurface,
    modifier = Modifier.size(24.dp),
)

Icons.Default.* 来自 androidx.compose.material:material-icons-extended(可选依赖)提供全套 Material 图标。tint 决定图标颜色。

装饰性图标(按钮内文字已说明用途)的 contentDescription 应填 null,避免 TalkBack 重复念。

Card

Card(
    shape = RoundedCornerShape(12.dp),
    colors = CardDefaults.cardColors(
        containerColor = MaterialTheme.colorScheme.surfaceVariant,
    ),
    elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
    border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
    modifier = Modifier.fillMaxWidth(),
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("标题", style = MaterialTheme.typography.titleLarge)
        Text("副标题", style = MaterialTheme.typography.bodyMedium)
    }
}

Card 是带阴影 / 形状 / 背景的容器。M3 推荐使用 ElevatedCard / OutlinedCard 两个变体:

变体 视觉 场景
Card 阴影 + 填充背景 通用
ElevatedCard 较强阴影 重要内容
OutlinedCard 描边 列表项

加点击:

Card(onClick = { /* 打开详情 */ }) {
    Text("可点")
}

M3 起 Card 直接支持 onClick,省去 Modifier.clickable。

Slider

@Composable
fun VolumeSlider() {
    var volume by remember { mutableStateOf(50f) }
    Slider(
        value = volume,
        onValueChange = { volume = it },
        valueRange = 0f..100f,
        steps = 0,                              // 0 = 连续;N = 分 N+1 段
        onValueChangeFinished = { println("最终:$volume") },
        modifier = Modifier.fillMaxWidth(),
    )
}

steps 用于“分段滑块”(如 0 / 25 / 50 / 75 / 100):

Slider(
    value = progress,
    onValueChange = { },
    valueRange = 0f..100f,
    steps = 3,                                  // 4 个停顿点
)

开关类组件

1. Checkbox

var checked by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically) {
    Checkbox(checked = checked, onCheckedChange = { checked = it })
    Text("同意条款")
}

2. RadioButton

val options = listOf("男", "女", "保密")
var selected by remember { mutableStateOf(options.first()) }

Column {
    options.forEach { label ->
        Row(verticalAlignment = Alignment.CenterVertically) {
            RadioButton(
                selected = selected == label,
                onClick = { selected = label },
            )
            Text(label)
        }
    }
}

3. Switch

var dark by remember { mutableStateOf(false) }
Row(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.SpaceBetween,
    verticalAlignment = Alignment.CenterVertically,
) {
    Text("深色模式")
    Switch(checked = dark, onCheckedChange = { dark = it })
}

三者 API 一致:checked 状态 + onCheckedChange 回调。

AlertDialog

@Composable
fun ConfirmDialog(show: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit) {
    if (show) {
        AlertDialog(
            onDismissRequest = onDismiss,
            title = { Text("删除") },
            text = { Text("确认删除这条数据?此操作不可恢复。") },
            confirmButton = {
                TextButton(onClick = onConfirm) { Text("删除") }
            },
            dismissButton = {
                TextButton(onClick = onDismiss) { Text("取消") }
            },
        )
    }
}

要点:

  • 用 if (show) 控制显示,不是写“show ? Dialog : null”的 Java 风格 if 表达式;
  • onDismissRequest 在用户点外部 / 返回键时触发;
  • 内部用 Dialog 槽可自定义任意内容。

更复杂的对话框用 Dialog(onDismissRequest = { }) { /* 自定义 Composable */ }。

Snackbar

1. SnackbarHostState

@Composable
fun SnackBarDemo() {
    val host = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = { SnackbarHost(hostState = host) },
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {
            Button(onClick = {
                scope.launch {
                    val result = host.showSnackbar(
                        message = "已保存",
                        actionLabel = "撤销",
                        duration = SnackbarDuration.Short,
                    )
                    if (result == SnackbarResult.ActionPerformed) {
                        // 撤销逻辑
                    }
                }
            }) { Text("保存") }
        }
    }
}

要点:

  • SnackbarHostState 是状态持有者,showSnackbar 是 suspend 函数,必须在协程中调用;
  • SnackbarHost(hostState) 渲染 UI,通常放 Scaffold.snackbarHost 槽;
  • duration 选项 Short(约 1.5s)/ Long(约 3s)/ Indefinite(手动 dismiss);
  • actionLabel 配合返回 SnackbarResult.ActionPerformed 处理点击动作(如“撤销”)。

2. 与 navigation 联动

val navController = rememberNavController()
val snackbarHost = remember { SnackbarHostState() }

LaunchedEffect(Unit) {
    navController.currentBackStackEntryAsFlow().collect { entry ->
        entry?.savedStateHandle?.get<String>("snackbar")?.let { msg ->
            snackbarHost.showSnackbar(msg)
        }
    }
}

页面间传递消息常用 “navigate + savedStateHandle + Snackbar” 组合。

综合示例:设置卡片

@Composable
fun SettingsCard(
    darkMode: Boolean,
    volume: Float,
    onDarkModeChange: (Boolean) -> Unit,
    onVolumeChange: (Float) -> Unit,
    onClearCache: () -> Unit,
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text("设置", style = MaterialTheme.typography.titleLarge)

            Spacer(Modifier.height(12.dp))

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text("深色模式")
                Switch(checked = darkMode, onCheckedChange = onDarkModeChange)
            }

            Spacer(Modifier.height(8.dp))

            Column {
                Text("音量:${volume.toInt()}")
                Slider(
                    value = volume,
                    onValueChange = onVolumeChange,
                    valueRange = 0f..100f,
                )
            }

            Spacer(Modifier.height(12.dp))

            OutlinedButton(
                onClick = onClearCache,
                modifier = Modifier.fillMaxWidth(),
            ) { Text("清除缓存") }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun SettingsCardPreview() {
    MaterialTheme {
        SettingsCard(
            darkMode = false,
            volume = 60f,
            onDarkModeChange = {},
            onVolumeChange = {},
            onClearCache = {},
        )
    }
}

常见坑与最佳实践

  1. Text 不写 maxLines:长文本会撑破容器或溢出,列表项尤其明显。
  2. TextField 忘记 onValueChange:输入框看起来“无法输入”,因为状态没回流。务必把 it 写回状态。
  3. Button 子级放非 Text 没加 Spacer:图标和文字紧贴。按钮内建议 Icon + Spacer + Text 三件套。
  4. Image 不写 contentScale:默认 Fit,头像常被拉变形。圆形头像用 clip(CircleShape) + Crop。
  5. Icon 不写 tint:默认是 LocalContentColor,在非 M3 上下文中可能不可见,建议显式 tint。
  6. Card.onClick vs Modifier.clickable:M3 起用 Card(onClick = ...),自带涟漪与高亮,比 Modifier.clickable 更协调。
  7. Switch / Checkbox 状态提升:组件内部不存状态,每次重组用外部传入。把状态用 remember { mutableStateOf(...) } 放上层。
  8. AlertDialog 不处理 onDismissRequest:用户按返回键没反应。空实现也至少要 onDismiss = {}。
  9. showSnackbar 在协程外调用:showSnackbar 是 suspend,必须在 rememberCoroutineScope().launch { } 中调用。
  10. SnackbarHost 不放 Scaffold:直接渲染会盖住内容。放 Scaffold.snackbarHost 才会有自动避让。

章节小结

  • Text 用 style + maxLines + overflow = Ellipsis 控制样式与截断;富文本用 buildAnnotatedString;
  • Button / OutlinedButton / TextButton 按视觉重要性挑选,按钮内子级常用 Icon + Spacer + Text;
  • TextField / OutlinedTextField 是受控组件,必填 value + onValueChange;用 keyboardOptions 控制键盘类型与 imeAction,用 visualTransformation 隐藏密码;
  • Image 配 contentScale + clip;Icon 配 tint;网络图用 Coil 的 AsyncImage;
  • Card / ElevatedCard / OutlinedCard 是带阴影容器,M3 起支持 onClick 槽;
  • Slider / Checkbox / RadioButton / Switch 状态完全由外部持有,组件只负责渲染与回调;
  • AlertDialog 用 if (show) { ... } 控制显示;Snackbar 通过 SnackbarHostState + showSnackbar suspend 调用,渲染放 Scaffold.snackbarHost。

下一章预告

下一章我们进入 Compose 列表:LazyColumn / LazyRow 的 items / item / contentPadding、LazyVerticalGrid 的 cells、key 参数的性能意义、LazyListState 控制滚动、stickyHeader、animateItemPlacement、嵌套滚动与无障碍 contentDescription,最后给出列表性能优化清单。