【Compose Multiplatform跨端声明式UI实用技术】第6课:常用组件与 Material 3 设计系统

第5课完成了布局系统的学习。本课进入 UI 组件与设计系统的核心:Text、Button、Card、Icon、Image 等常用组件的使用,Material 3 主题体系(colorScheme、typography、shapes、motionScheme)的配置,动态主题与自定义设计 Token 的实现,以及组件状态(enabled、loading、error)的统一处理模式。本课内容基于 CMP 1.12.1 与 Material 3 1.5.0-alpha22。 文末附有15道配套习题与本课知识点总结。

目录

  • [一、Material 3 组件体系概览](#一、Material 3 组件体系概览)
  • 二、基础组件使用
    • [2.1 Text:文本显示](#2.1 Text:文本显示)
    • [2.2 Button:按钮](#2.2 Button:按钮)
    • [2.3 Card:卡片](#2.3 Card:卡片)
    • [2.4 Icon 与 Image](#2.4 Icon 与 Image)
  • [三、Material 3 主题体系](#三、Material 3 主题体系)
    • [3.1 MaterialTheme 的四个核心维度](#3.1 MaterialTheme 的四个核心维度)
    • [3.2 ColorScheme:语义颜色体系](#3.2 ColorScheme:语义颜色体系)
    • [3.3 Typography:排版体系](#3.3 Typography:排版体系)
    • [3.4 Shapes:形状体系](#3.4 Shapes:形状体系)
    • [3.5 MotionScheme:动效体系](#3.5 MotionScheme:动效体系)
  • [四、动态主题与自定义设计 Token](#四、动态主题与自定义设计 Token)
    • [4.1 动态颜色:Android 平台特有能力](#4.1 动态颜色:Android 平台特有能力)
    • [4.2 从种子色生成完整色板](#4.2 从种子色生成完整色板)
    • [4.3 自定义设计 Token:间距与布局参数](#4.3 自定义设计 Token:间距与布局参数)
  • 五、组件状态表达
    • [5.1 密封接口建模状态](#5.1 密封接口建模状态)
    • [5.2 统一的 LoadingUi 组件](#5.2 统一的 LoadingUi 组件)
    • [5.3 组件内置状态:enabled 与 disabled](#5.3 组件内置状态:enabled 与 disabled)
  • [六、Material 3 Expressive 主题](#六、Material 3 Expressive 主题)
    • [6.1 Expressive 主题的状态](#6.1 Expressive 主题的状态)
    • [6.2 使用 Expressive 主题](#6.2 使用 Expressive 主题)
    • [6.3 1.5.0-alpha22 中的组件更新](#6.3 1.5.0-alpha22 中的组件更新)
  • 七、跨平台注意事项
  • 配套习题(15题)
  • 本课知识点总结

一、Material 3 组件体系概览

Material 3 是 Google Material Design 的最新演进版本。Compose Multiplatform 通过 org.jetbrains.compose.material3:material3 库提供完整的 Material 3 支持。在 CMP 1.12.1 中,Material 3 库的版本为 1.12.0-alpha03,基于 Jetpack Material3 1.5.0-alpha22。稳定版 androidx.compose.material3:material3 为 1.4.0,不包含 Expressive 特性。

组件的强调层级(Emphasis Level) 是理解 Material 3 组件选择的核心框架。Material 3 为同一功能提供了多种视觉变体,对应不同的强调程度。以按钮为例:

组件 描述 强调层级
Button(Filled Button) 高强调的填充按钮 高
ElevatedButton 带阴影的填充按钮 中
FilledTonalButton 带色调背景的填充按钮 中
OutlinedButton 带边框的中等强调按钮 中
TextButton 无边框的低强调按钮 低
IconButton 紧凑的图标按钮 低

这一分类体系同样适用于卡片(Card、ElevatedCard、OutlinedCard)和芯片(AssistChip、FilterChip、InputChip)等组件族。组件选择的核心原则是:根据操作在界面中的重要性选择对应的强调层级,而非依赖颜色或尺寸的临时调整。

下面是 Material 3 组件强调层级的选择流程:
#mermaid-svg-kqq6aXIjqPmP1ulp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kqq6aXIjqPmP1ulp .error-icon{fill:#552222;}#mermaid-svg-kqq6aXIjqPmP1ulp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kqq6aXIjqPmP1ulp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kqq6aXIjqPmP1ulp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kqq6aXIjqPmP1ulp .marker.cross{stroke:#333333;}#mermaid-svg-kqq6aXIjqPmP1ulp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kqq6aXIjqPmP1ulp p{margin:0;}#mermaid-svg-kqq6aXIjqPmP1ulp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kqq6aXIjqPmP1ulp .cluster-label text{fill:#333;}#mermaid-svg-kqq6aXIjqPmP1ulp .cluster-label span{color:#333;}#mermaid-svg-kqq6aXIjqPmP1ulp .cluster-label span p{background-color:transparent;}#mermaid-svg-kqq6aXIjqPmP1ulp .label text,#mermaid-svg-kqq6aXIjqPmP1ulp span{fill:#333;color:#333;}#mermaid-svg-kqq6aXIjqPmP1ulp .node rect,#mermaid-svg-kqq6aXIjqPmP1ulp .node circle,#mermaid-svg-kqq6aXIjqPmP1ulp .node ellipse,#mermaid-svg-kqq6aXIjqPmP1ulp .node polygon,#mermaid-svg-kqq6aXIjqPmP1ulp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kqq6aXIjqPmP1ulp .rough-node .label text,#mermaid-svg-kqq6aXIjqPmP1ulp .node .label text,#mermaid-svg-kqq6aXIjqPmP1ulp .image-shape .label,#mermaid-svg-kqq6aXIjqPmP1ulp .icon-shape .label{text-anchor:middle;}#mermaid-svg-kqq6aXIjqPmP1ulp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kqq6aXIjqPmP1ulp .rough-node .label,#mermaid-svg-kqq6aXIjqPmP1ulp .node .label,#mermaid-svg-kqq6aXIjqPmP1ulp .image-shape .label,#mermaid-svg-kqq6aXIjqPmP1ulp .icon-shape .label{text-align:center;}#mermaid-svg-kqq6aXIjqPmP1ulp .node.clickable{cursor:pointer;}#mermaid-svg-kqq6aXIjqPmP1ulp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kqq6aXIjqPmP1ulp .arrowheadPath{fill:#333333;}#mermaid-svg-kqq6aXIjqPmP1ulp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kqq6aXIjqPmP1ulp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kqq6aXIjqPmP1ulp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kqq6aXIjqPmP1ulp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kqq6aXIjqPmP1ulp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kqq6aXIjqPmP1ulp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kqq6aXIjqPmP1ulp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kqq6aXIjqPmP1ulp .cluster text{fill:#333;}#mermaid-svg-kqq6aXIjqPmP1ulp .cluster span{color:#333;}#mermaid-svg-kqq6aXIjqPmP1ulp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kqq6aXIjqPmP1ulp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kqq6aXIjqPmP1ulp rect.text{fill:none;stroke-width:0;}#mermaid-svg-kqq6aXIjqPmP1ulp .icon-shape,#mermaid-svg-kqq6aXIjqPmP1ulp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kqq6aXIjqPmP1ulp .icon-shape p,#mermaid-svg-kqq6aXIjqPmP1ulp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kqq6aXIjqPmP1ulp .icon-shape .label rect,#mermaid-svg-kqq6aXIjqPmP1ulp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kqq6aXIjqPmP1ulp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kqq6aXIjqPmP1ulp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kqq6aXIjqPmP1ulp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 高
中
是
否
是
否
低
是
否
需要选择组件
操作的重要性?
Button(Filled Button)
是否需要边框?
OutlinedButton
是否需要阴影?
ElevatedButton
FilledTonalButton
是否需要图标?
IconButton
TextButton

二、基础组件使用

2.1 Text:文本显示

Text 是使用频率最高的组件,接受 text 字符串参数,并通过 style 参数引用 MaterialTheme.typography 中的文本样式:

kotlin 复制代码
Text(
    text = "标题文本",
    style = MaterialTheme.typography.headlineMedium,
    color = MaterialTheme.colorScheme.onSurface,
    maxLines = 2,
    overflow = TextOverflow.Ellipsis
)

核心参数 :style 引用主题排版,color 使用 colorScheme 中的语义颜色,maxLines 与 overflow 控制文本截断行为。

2.2 Button:按钮

Material 3 提供六种按钮变体。基础用法如下:

kotlin 复制代码
// 高强调操作
Button(onClick = { /* 处理点击 */ }) {
    Text("确认提交")
}

// 中等强调操作
FilledTonalButton(onClick = { /* 处理点击 */ }) {
    Text("保存草稿")
}

// 低强调操作
TextButton(onClick = { /* 处理点击 */ }) {
    Text("取消")
}

// 图标按钮
IconButton(onClick = { /* 处理点击 */ }) {
    Icon(Icons.Default.Share, contentDescription = "分享")
}

所有按钮变体共享 onClick、enabled、content 等核心参数。content 是 @Composable RowScope.() -> Unit 类型的 Slot,允许在按钮内放置图标和文本的组合。

2.3 Card:卡片

卡片用于将相关内容组织在一个视觉容器中。Material 3 提供三种卡片变体:

kotlin 复制代码
// 填充卡片(默认)
Card(
    modifier = Modifier.fillMaxWidth(),
    onClick = { /* 可选:可点击卡片 */ }
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("卡片标题", style = MaterialTheme.typography.titleMedium)
        Text("卡片内容描述", style = MaterialTheme.typography.bodyMedium)
    }
}

// 带阴影的卡片
ElevatedCard(onClick = { /* ... */ }) { /* ... */ }

// 带边框的卡片
OutlinedCard(onClick = { /* ... */ }) { /* ... */ }

卡片通过 CardDefaults 暴露颜色和高度接口,可在不改动组件内部实现的情况下进行定制。

2.4 Icon 与 Image

Icon 用于显示矢量图标,Image 用于显示位图资源:

kotlin 复制代码
// 矢量图标
Icon(
    imageVector = Icons.Default.Home,
    contentDescription = "首页",
    tint = MaterialTheme.colorScheme.primary
)

// 位图图片
Image(
    painter = painterResource("photo.png"),
    contentDescription = "照片描述",
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(120.dp).clip(MaterialTheme.shapes.medium)
)

Icon 的 tint 参数使其自动适配当前主题色,Image 的 contentScale 控制裁剪和缩放行为。contentDescription 是辅助功能的必要参数,纯装饰性图片可设为 null。

三、Material 3 主题体系

3.1 MaterialTheme 的四个核心维度

MaterialTheme 是 Material 3 主题的根可组合项,接受 colorScheme、typography、shapes、motionScheme 四个参数,为子树中的所有组件提供默认样式:

kotlin 复制代码
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    MaterialTheme(
        colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(),
        typography = Typography(),
        shapes = Shapes(),
        motionScheme = MotionScheme.standard(),
        content = content
    )
}

这四个维度的值通过 CompositionLocalProvider 传播,分别对应 LocalColorScheme、LocalTypography、LocalShapes 和 LocalMotionScheme。

3.2 ColorScheme:语义颜色体系

Material 3 的 ColorScheme 采用语义角色而非具体色值来定义颜色。核心颜色角色包括:

角色 用途
primary / onPrimary 主品牌色及其上的文字颜色
secondary / onSecondary 辅助色
tertiary / onTertiary 强调色
surface / onSurface 卡片、面板等表面色
background / onBackground 页面背景色
error / onError 错误状态色
outline 边框和分隔线

使用 lightColorScheme() 和 darkColorScheme() 创建浅色和深色方案,或通过 MaterialTheme.colorScheme.primary 访问当前方案中的颜色。语义角色的优势在于:组件不直接依赖具体色值,而是依赖颜色角色。当品牌色变更或切换到暗色模式时,所有组件自动适配。

3.3 Typography:排版体系

Material 3 定义了 15 种文本样式,按尺寸层级组织:

类别 样式示例 典型用途
Display displayLarge、displayMedium、displaySmall 超大标题
Headline headlineLarge、headlineMedium、headlineSmall 页面标题
Title titleLarge、titleMedium、titleSmall 区块标题
Body bodyLarge、bodyMedium、bodySmall 正文内容
Label labelLarge、labelMedium、labelSmall 按钮、标签

自定义排版通过修改 Typography() 的对应样式实现。自定义字体文件存放在 composeResources/font 目录中,支持 TTF、OTF、TTC 和可变字体格式:

kotlin 复制代码
val AppTypography = Typography(
    bodyLarge = TextStyle(
        fontFamily = FontFamily(Font(Res.font.inter_regular)),
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp
    )
)

3.4 Shapes:形状体系

Shapes 定义了五种尺寸类别的圆角形状:

类别 默认圆角 典型用途
extraSmall 4.dp Chip、小型 IconButton
small 8.dp SegmentedButton、SnackbarHost
medium 12.dp Card、Dialog、Menu
large 16.dp NavigationDrawer、BottomSheet
extraLarge 28.dp FloatingActionButton

通过 MaterialTheme.shapes.medium 引用形状,或自定义 Shapes 对象覆盖默认值。需要注意的是,ShapeKeyTokens.CornerFull 使用 RoundedCornerShape(50%)(完全圆角)而非引用 Shapes,因此按钮始终保持药丸形状,不受主题影响。

3.5 MotionScheme:动效体系

MotionScheme 是 Material 3 主题的第四个维度,用于定义动画的时序和缓动曲线。Material 3 提供两种内置动效方案:

  • MotionScheme.standard() :基础动效方案,适用于实用型 UI 元素和重复性交互,提供线性运动感。
  • MotionScheme.expressive() :表达性动效方案,用于更个性化的体验,动效更活泼、更具弹性。

动效方案通过 MaterialTheme.motionScheme 访问,组件自动使用当前方案中定义的动画规格。

下面是 MaterialTheme 四个核心维度的传播机制:
#mermaid-svg-BcR5yQam8eMti1T5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BcR5yQam8eMti1T5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BcR5yQam8eMti1T5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BcR5yQam8eMti1T5 .error-icon{fill:#552222;}#mermaid-svg-BcR5yQam8eMti1T5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BcR5yQam8eMti1T5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BcR5yQam8eMti1T5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BcR5yQam8eMti1T5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BcR5yQam8eMti1T5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BcR5yQam8eMti1T5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BcR5yQam8eMti1T5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BcR5yQam8eMti1T5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BcR5yQam8eMti1T5 .marker.cross{stroke:#333333;}#mermaid-svg-BcR5yQam8eMti1T5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BcR5yQam8eMti1T5 p{margin:0;}#mermaid-svg-BcR5yQam8eMti1T5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BcR5yQam8eMti1T5 .cluster-label text{fill:#333;}#mermaid-svg-BcR5yQam8eMti1T5 .cluster-label span{color:#333;}#mermaid-svg-BcR5yQam8eMti1T5 .cluster-label span p{background-color:transparent;}#mermaid-svg-BcR5yQam8eMti1T5 .label text,#mermaid-svg-BcR5yQam8eMti1T5 span{fill:#333;color:#333;}#mermaid-svg-BcR5yQam8eMti1T5 .node rect,#mermaid-svg-BcR5yQam8eMti1T5 .node circle,#mermaid-svg-BcR5yQam8eMti1T5 .node ellipse,#mermaid-svg-BcR5yQam8eMti1T5 .node polygon,#mermaid-svg-BcR5yQam8eMti1T5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BcR5yQam8eMti1T5 .rough-node .label text,#mermaid-svg-BcR5yQam8eMti1T5 .node .label text,#mermaid-svg-BcR5yQam8eMti1T5 .image-shape .label,#mermaid-svg-BcR5yQam8eMti1T5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-BcR5yQam8eMti1T5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BcR5yQam8eMti1T5 .rough-node .label,#mermaid-svg-BcR5yQam8eMti1T5 .node .label,#mermaid-svg-BcR5yQam8eMti1T5 .image-shape .label,#mermaid-svg-BcR5yQam8eMti1T5 .icon-shape .label{text-align:center;}#mermaid-svg-BcR5yQam8eMti1T5 .node.clickable{cursor:pointer;}#mermaid-svg-BcR5yQam8eMti1T5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BcR5yQam8eMti1T5 .arrowheadPath{fill:#333333;}#mermaid-svg-BcR5yQam8eMti1T5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BcR5yQam8eMti1T5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BcR5yQam8eMti1T5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BcR5yQam8eMti1T5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BcR5yQam8eMti1T5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BcR5yQam8eMti1T5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BcR5yQam8eMti1T5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BcR5yQam8eMti1T5 .cluster text{fill:#333;}#mermaid-svg-BcR5yQam8eMti1T5 .cluster span{color:#333;}#mermaid-svg-BcR5yQam8eMti1T5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BcR5yQam8eMti1T5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BcR5yQam8eMti1T5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-BcR5yQam8eMti1T5 .icon-shape,#mermaid-svg-BcR5yQam8eMti1T5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BcR5yQam8eMti1T5 .icon-shape p,#mermaid-svg-BcR5yQam8eMti1T5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BcR5yQam8eMti1T5 .icon-shape .label rect,#mermaid-svg-BcR5yQam8eMti1T5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BcR5yQam8eMti1T5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BcR5yQam8eMti1T5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BcR5yQam8eMti1T5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} MaterialTheme 根节点
colorScheme
typography
shapes
motionScheme
LocalColorScheme
LocalTypography
LocalShapes
LocalMotionScheme
子树中的所有组件

四、动态主题与自定义设计 Token

4.1 动态颜色:Android 平台特有能力

Android 12 及以上支持从用户壁纸提取颜色生成动态主题。Compose Multiplatform 中,动态颜色仅在 Android 平台可用 ,因此必须放在 androidMain 源集中:

kotlin 复制代码
// androidMain 中的 expect 声明
@Composable
expect fun platformDynamicColorScheme(darkTheme: Boolean): ColorScheme?

// androidMain 中的 actual 实现
@Composable
actual fun platformDynamicColorScheme(darkTheme: Boolean): ColorScheme? {
    val context = LocalContext.current
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        if (darkTheme) dynamicDarkColorScheme(context)
        else dynamicLightColorScheme(context)
    } else null
}

在 commonMain 中通过 expect/actual 机制调用,非 Android 平台返回 null 并回退到静态色板。

4.2 从种子色生成完整色板

对于非 Android 平台或需要品牌一致性控制的场景,可使用 MaterialKolor 等社区库从单个种子色生成完整的 Material 3 色板。MaterialKolor 5.0.0 支持 Compose Multiplatform 1.11.0,并通过 DynamicMaterialExpressiveTheme 提供 Material 3 Expressive 的动态主题能力:

kotlin 复制代码
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MyExpressiveTheme(
    seedColor: Color,
    isDark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    DynamicMaterialExpressiveTheme(
        seedColor = seedColor,
        motionScheme = MotionScheme.expressive(),
        isDark = isDark,
        animate = true,
        content = content
    )
}

4.3 自定义设计 Token:间距与布局参数

Material 3 的 colorScheme、typography、shapes、motionScheme 覆盖了颜色、文字、形状、动效四个维度,但间距(Spacing) 不在 Material 3 主题的默认参数中。生产级项目需要通过 CompositionLocal 实现自定义间距 Token:

kotlin 复制代码
// 定义间距 Token
data class Spacing(
    val none: Dp = 0.dp,
    val extraSmall: Dp = 4.dp,
    val small: Dp = 8.dp,
    val medium: Dp = 16.dp,
    val large: Dp = 24.dp,
    val extraLarge: Dp = 32.dp
)

val LocalSpacing = staticCompositionLocalOf { Spacing() }

@Composable
fun AppTheme(content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalSpacing provides Spacing()) {
        MaterialTheme(content = content)
    }
}

// 使用
val spacing = LocalSpacing.current
Column(modifier = Modifier.padding(spacing.medium)) { /* ... */ }

staticCompositionLocalOf 用于不频繁变化的 Token(如间距),读取时不追踪变化,性能更优;compositionLocalOf 用于需要追踪读取的频繁变化值。通过 CompositionLocalProvider 将 Token 注入组合树,实现设计系统与组件实现的解耦。

下面是动态主题与自定义设计 Token 的整体架构:
#mermaid-svg-801XrmzNuMdb9Oku{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-801XrmzNuMdb9Oku .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-801XrmzNuMdb9Oku .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-801XrmzNuMdb9Oku .error-icon{fill:#552222;}#mermaid-svg-801XrmzNuMdb9Oku .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-801XrmzNuMdb9Oku .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-801XrmzNuMdb9Oku .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-801XrmzNuMdb9Oku .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-801XrmzNuMdb9Oku .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-801XrmzNuMdb9Oku .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-801XrmzNuMdb9Oku .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-801XrmzNuMdb9Oku .marker{fill:#333333;stroke:#333333;}#mermaid-svg-801XrmzNuMdb9Oku .marker.cross{stroke:#333333;}#mermaid-svg-801XrmzNuMdb9Oku svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-801XrmzNuMdb9Oku p{margin:0;}#mermaid-svg-801XrmzNuMdb9Oku .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-801XrmzNuMdb9Oku .cluster-label text{fill:#333;}#mermaid-svg-801XrmzNuMdb9Oku .cluster-label span{color:#333;}#mermaid-svg-801XrmzNuMdb9Oku .cluster-label span p{background-color:transparent;}#mermaid-svg-801XrmzNuMdb9Oku .label text,#mermaid-svg-801XrmzNuMdb9Oku span{fill:#333;color:#333;}#mermaid-svg-801XrmzNuMdb9Oku .node rect,#mermaid-svg-801XrmzNuMdb9Oku .node circle,#mermaid-svg-801XrmzNuMdb9Oku .node ellipse,#mermaid-svg-801XrmzNuMdb9Oku .node polygon,#mermaid-svg-801XrmzNuMdb9Oku .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-801XrmzNuMdb9Oku .rough-node .label text,#mermaid-svg-801XrmzNuMdb9Oku .node .label text,#mermaid-svg-801XrmzNuMdb9Oku .image-shape .label,#mermaid-svg-801XrmzNuMdb9Oku .icon-shape .label{text-anchor:middle;}#mermaid-svg-801XrmzNuMdb9Oku .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-801XrmzNuMdb9Oku .rough-node .label,#mermaid-svg-801XrmzNuMdb9Oku .node .label,#mermaid-svg-801XrmzNuMdb9Oku .image-shape .label,#mermaid-svg-801XrmzNuMdb9Oku .icon-shape .label{text-align:center;}#mermaid-svg-801XrmzNuMdb9Oku .node.clickable{cursor:pointer;}#mermaid-svg-801XrmzNuMdb9Oku .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-801XrmzNuMdb9Oku .arrowheadPath{fill:#333333;}#mermaid-svg-801XrmzNuMdb9Oku .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-801XrmzNuMdb9Oku .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-801XrmzNuMdb9Oku .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-801XrmzNuMdb9Oku .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-801XrmzNuMdb9Oku .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-801XrmzNuMdb9Oku .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-801XrmzNuMdb9Oku .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-801XrmzNuMdb9Oku .cluster text{fill:#333;}#mermaid-svg-801XrmzNuMdb9Oku .cluster span{color:#333;}#mermaid-svg-801XrmzNuMdb9Oku div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-801XrmzNuMdb9Oku .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-801XrmzNuMdb9Oku rect.text{fill:none;stroke-width:0;}#mermaid-svg-801XrmzNuMdb9Oku .icon-shape,#mermaid-svg-801XrmzNuMdb9Oku .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-801XrmzNuMdb9Oku .icon-shape p,#mermaid-svg-801XrmzNuMdb9Oku .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-801XrmzNuMdb9Oku .icon-shape .label rect,#mermaid-svg-801XrmzNuMdb9Oku .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-801XrmzNuMdb9Oku .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-801XrmzNuMdb9Oku .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-801XrmzNuMdb9Oku :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 自定义设计 Token
非 Android 平台
Android 12+
从壁纸提取颜色
dynamicLightColorScheme
dynamicDarkColorScheme
种子色(seedColor)
MaterialKolor 生成色板
Spacing 数据类
staticCompositionLocalOf
CompositionLocalProvider 注入
ColorScheme
MaterialTheme

五、组件状态表达

5.1 密封接口建模状态

在跨平台应用中,数据加载通常涉及加载中、成功、错误、重试等多种状态。推荐使用 密封接口(sealed interface) 对状态建模,而非多个布尔标志:

kotlin 复制代码
@Immutable
sealed interface UiState<out T> {
    data object Loading : UiState<Nothing>
    data class Success<T>(val data: T) : UiState<T>
    data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>
    data class Retrying(val attempt: Int) : UiState<Nothing>
}

密封接口的优势:穷尽性检查 ------when 表达式必须覆盖所有子类型,编译器在添加新状态时强制开发者处理;类型安全 ------Success 携带数据,Error 携带错误信息,不会出现"加载中却有数据"的非法状态组合。

5.2 统一的 LoadingUi 组件

将状态渲染逻辑封装为可复用的泛型组件:

kotlin 复制代码
@Composable
fun <T> LoadingUi(
    state: UiState<T>,
    loading: @Composable () -> Unit = { CircularProgressIndicator() },
    error: @Composable (String) -> Unit = { message ->
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(message, color = MaterialTheme.colorScheme.error)
        }
    },
    content: @Composable (T) -> Unit
) {
    when (state) {
        is UiState.Loading -> loading()
        is UiState.Success -> content(state.data)
        is UiState.Error -> error(state.message)
        is UiState.Retrying -> {
            LinearProgressIndicator()
            Text("正在重试(第 ${state.attempt} 次)...")
        }
    }
}

5.3 组件内置状态:enabled 与 disabled

Material 3 组件内置了 enabled 参数,当设为 false 时组件自动应用禁用态的颜色和交互行为:

kotlin 复制代码
Button(
    onClick = { submit() },
    enabled = formState.isValid && !formState.isSubmitting
) {
    if (formState.isSubmitting) {
        CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
        Spacer(Modifier.width(8.dp))
    }
    Text(if (formState.isSubmitting) "提交中..." else "提交")
}

设计原则 :组件的 enabled 状态应基于业务逻辑(如表单校验是否通过、是否正在提交)自动计算,而非手动管理。加载中的按钮应同时显示进度指示器并禁用点击,防止重复提交。

下面是组件状态流转与 LoadingUi 的处理流程:
#mermaid-svg-Gj2ZUXcdgH3OnMrf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .error-icon{fill:#552222;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .marker.cross{stroke:#333333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf p{margin:0;}#mermaid-svg-Gj2ZUXcdgH3OnMrf defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-Gj2ZUXcdgH3OnMrf g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Gj2ZUXcdgH3OnMrf g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Gj2ZUXcdgH3OnMrf .edgeLabel .label text{fill:#333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .label div .edgeLabel{color:#333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf #statediagram-barbEnd{fill:#333333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .cluster-label,#mermaid-svg-Gj2ZUXcdgH3OnMrf .nodeLabel{color:#131300;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .note-edge{stroke-dasharray:5;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-note text{fill:black;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram-note .nodeLabel{color:black;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagram .edgeLabel{color:red;}#mermaid-svg-Gj2ZUXcdgH3OnMrf #dependencyStart,#mermaid-svg-Gj2ZUXcdgH3OnMrf #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-Gj2ZUXcdgH3OnMrf .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Gj2ZUXcdgH3OnMrf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 数据加载成功
加载失败
用户点击重试
重新发起请求
重试仍失败
Loading
Success
Error
Retrying

六、Material 3 Expressive 主题

6.1 Expressive 主题的状态

Compose Multiplatform 支持 Material 3 库中实验性的 MaterialExpressiveTheme。但需要注意:在 Material 3 1.4.0-beta01 中,所有标记为 ExperimentalMaterial3ExpressiveApi 和 ExperimentalMaterial3ComponentOverrideApi 的公共 API 已被移除。要使用 Expressive 主题,必须显式引入 Alpha 版本的 Material 3 依赖。

Material 3 Expressive 已在 MaterialKolor 5.0.0 中正式稳定,表明 Expressive 方向正在从实验性走向生产就绪。

6.2 使用 Expressive 主题

引入 Alpha 版本依赖:

kotlin 复制代码
implementation("org.jetbrains.compose.material3:material3:1.9.0-alpha04")

配置 Expressive 主题:

kotlin 复制代码
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppTheme(content: @Composable () -> Unit) {
    MaterialExpressiveTheme(
        colorScheme = lightColorScheme(),
        motionScheme = MotionScheme.expressive(),
        shapes = Shapes(),
        typography = Typography(),
        content = content
    )
}

MaterialExpressiveTheme 在标准主题的四个维度(colorScheme、typography、shapes、motionScheme)上提供完整的配置能力。注意事项:生产项目应谨慎评估是否依赖实验性 API,并关注 Material 3 稳定版对 Expressive 特性的整合进度。

6.3 1.5.0-alpha22 中的组件更新

Material 3 1.5.0-alpha22(2026年6月17日发布)带来了若干值得关注的组件更新:

  • TopAppBarScrollBehavior 及相关 API 提升为稳定状态 ,不再需要 @ExperimentalMaterial3ExpressiveApi 标注。
  • Expressive FloatingToolbar API 转为非实验性。
  • TopAppBarDefaults 新增公开的 snapAnimationSpec getter,暴露默认的吸附动画规格。
  • pinnedScrollBehavior 和 enterAlwaysScrollBehavior 更新为接受 ScrollableState,自动处理反转布局和前置滚动内容等边界情况。
  • ExpandedDockedSearchBarWithGap 和 ExpandedFullScreenContainedSearchBar 转为非实验性。

这些更新表明 Material 3 Expressive 组件正在逐步稳定化,生产项目可以开始评估采用。

下面是 Material 3 Expressive 主题的演进路径:
#mermaid-svg-F4jXHDy20WCbdOzy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-F4jXHDy20WCbdOzy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-F4jXHDy20WCbdOzy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-F4jXHDy20WCbdOzy .error-icon{fill:#552222;}#mermaid-svg-F4jXHDy20WCbdOzy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-F4jXHDy20WCbdOzy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-F4jXHDy20WCbdOzy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-F4jXHDy20WCbdOzy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-F4jXHDy20WCbdOzy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-F4jXHDy20WCbdOzy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-F4jXHDy20WCbdOzy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-F4jXHDy20WCbdOzy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-F4jXHDy20WCbdOzy .marker.cross{stroke:#333333;}#mermaid-svg-F4jXHDy20WCbdOzy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-F4jXHDy20WCbdOzy p{margin:0;}#mermaid-svg-F4jXHDy20WCbdOzy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-F4jXHDy20WCbdOzy .cluster-label text{fill:#333;}#mermaid-svg-F4jXHDy20WCbdOzy .cluster-label span{color:#333;}#mermaid-svg-F4jXHDy20WCbdOzy .cluster-label span p{background-color:transparent;}#mermaid-svg-F4jXHDy20WCbdOzy .label text,#mermaid-svg-F4jXHDy20WCbdOzy span{fill:#333;color:#333;}#mermaid-svg-F4jXHDy20WCbdOzy .node rect,#mermaid-svg-F4jXHDy20WCbdOzy .node circle,#mermaid-svg-F4jXHDy20WCbdOzy .node ellipse,#mermaid-svg-F4jXHDy20WCbdOzy .node polygon,#mermaid-svg-F4jXHDy20WCbdOzy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-F4jXHDy20WCbdOzy .rough-node .label text,#mermaid-svg-F4jXHDy20WCbdOzy .node .label text,#mermaid-svg-F4jXHDy20WCbdOzy .image-shape .label,#mermaid-svg-F4jXHDy20WCbdOzy .icon-shape .label{text-anchor:middle;}#mermaid-svg-F4jXHDy20WCbdOzy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-F4jXHDy20WCbdOzy .rough-node .label,#mermaid-svg-F4jXHDy20WCbdOzy .node .label,#mermaid-svg-F4jXHDy20WCbdOzy .image-shape .label,#mermaid-svg-F4jXHDy20WCbdOzy .icon-shape .label{text-align:center;}#mermaid-svg-F4jXHDy20WCbdOzy .node.clickable{cursor:pointer;}#mermaid-svg-F4jXHDy20WCbdOzy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-F4jXHDy20WCbdOzy .arrowheadPath{fill:#333333;}#mermaid-svg-F4jXHDy20WCbdOzy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-F4jXHDy20WCbdOzy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-F4jXHDy20WCbdOzy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F4jXHDy20WCbdOzy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-F4jXHDy20WCbdOzy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F4jXHDy20WCbdOzy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-F4jXHDy20WCbdOzy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-F4jXHDy20WCbdOzy .cluster text{fill:#333;}#mermaid-svg-F4jXHDy20WCbdOzy .cluster span{color:#333;}#mermaid-svg-F4jXHDy20WCbdOzy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-F4jXHDy20WCbdOzy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-F4jXHDy20WCbdOzy rect.text{fill:none;stroke-width:0;}#mermaid-svg-F4jXHDy20WCbdOzy .icon-shape,#mermaid-svg-F4jXHDy20WCbdOzy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F4jXHDy20WCbdOzy .icon-shape p,#mermaid-svg-F4jXHDy20WCbdOzy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-F4jXHDy20WCbdOzy .icon-shape .label rect,#mermaid-svg-F4jXHDy20WCbdOzy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F4jXHDy20WCbdOzy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-F4jXHDy20WCbdOzy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-F4jXHDy20WCbdOzy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Material 3 1.4.0-beta01

实验性 API 被移除
需要 Alpha 版本

1.9.0-alpha04
MaterialKolor 5.0.0

Expressive 稳定
1.5.0-alpha22

组件逐步稳定化
生产项目评估采用

七、跨平台注意事项

组件 API 一致性 :CMP 的 Material 3 组件与 Jetpack Compose 共享同一套 API,组件定义、参数、行为在所有平台上保持一致。在 commonMain 中编写的组件代码无需修改即可运行于 Android、iOS、Desktop 和 Web。

平台差异:动态颜色是 Android 12+ 独占能力,其他平台需回退到静态色板或通过 MaterialKolor 等库从种子色生成。软件键盘的定位、滚动物理效果等在 iOS 上由 CMP 适配,但复杂交互仍需在真机验证。

commonMain 约束 :主题定义中不能直接引用 androidx.compose.material3.dynamicLightColorScheme(Android 专属 API),必须通过 expect/actual 机制下沉到 androidMain 源集。

配套习题(15题)

第一组:Material 3 组件体系(4题)

第1题 Material 3 中,强调层级最高的按钮组件是( )

A. TextButton

B. OutlinedButton

C. Button(Filled Button)

D. IconButton

答案:C

解析 :Button(Filled Button)是高强调的填充按钮。TextButton 为低强调,OutlinedButton 和 FilledTonalButton 为中等强调,IconButton 为低强调的紧凑按钮。

第2题 Material 3 中卡片组件的三种变体不包括( )

A. Card

B. ElevatedCard

C. OutlinedCard

D. FloatingCard

答案:D

解析 :Material 3 提供 Card(填充)、ElevatedCard(带阴影)、OutlinedCard(带边框)三种卡片变体。不存在 FloatingCard 组件。

第3题 Icon 组件的 tint 参数的作用是( )

A. 设置图标的背景色

B. 使图标自动适配当前主题色

C. 设置图标的阴影颜色

D. 控制图标的透明度

答案:B

解析 :Icon 的 tint 参数用于设置图标的着色,通常设置为 MaterialTheme.colorScheme.primary 等语义颜色,使图标自动适配当前主题。

第4题 Image 组件的 contentDescription 参数设为 null 的适用场景是( )

A. 所有图片都必须设置非 null 的描述

B. 纯装饰性图片,不承载信息

C. 图片加载失败时

D. 图片尺寸小于 48dp 时

答案:B

解析 :contentDescription 是辅助功能的必要参数。对于纯装饰性图片(不承载任何信息),可以设为 null,屏幕阅读器会跳过该元素。

第二组:Material 3 主题体系(4题)

第5题 MaterialTheme 的四个核心参数是( )

A. colorScheme、typography、shapes、motionScheme

B. colors、fonts、radii、animations

C. primary、secondary、tertiary、error

D. light、dark、system、dynamic

答案:A

解析 :MaterialTheme 接受 colorScheme(颜色方案)、typography(排版体系)、shapes(形状体系)、motionScheme(动效体系)四个参数,为子树中的所有组件提供默认样式。这四个维度的值通过 CompositionLocalProvider 传播。

第6题 Material 3 中 colorScheme.primary 的正确用途是( )

A. 设置页面的背景色

B. 作为主品牌色,用于高强调操作

C. 设置错误状态的提示颜色

D. 作为所有文本的默认颜色

答案:B

解析 :primary 是主品牌色,用于高强调操作和关键 UI 元素。页面背景使用 background,错误提示使用 error,文本颜色通常使用 onSurface 或 onBackground。

第7题 关于 Material 3 的 Typography,以下说法正确的是( )

A. Typography 只有一种文本样式

B. Typography 定义了从 Display 到 Label 的 15 种文本样式层级

C. Typography 只能使用系统默认字体

D. Typography 不需要自定义,使用默认值即可

答案:B

解析:Material 3 定义了 15 种文本样式,按尺寸层级组织为 Display、Headline、Title、Body、Label 五类,每类各有 Large/Medium/Small 三个尺寸。

第8题 Material 3 的 Shapes 体系中,ShapeKeyTokens.CornerFull 的特殊之处是( )

A. 使用主题中定义的 extraLarge 圆角

B. 使用 RoundedCornerShape(50%) 完全圆角,不受主题影响

C. 只在 Android 平台生效

D. 必须手动指定圆角值

答案:B

解析 :ShapeKeyTokens.CornerFull 使用 RoundedCornerShape(50%)(完全圆角)而非引用 Shapes,因此按钮始终保持药丸形状,无论主题中如何定义形状。

第三组:动态主题与设计 Token(4题)

第9题 在 Compose Multiplatform 中,动态颜色(从壁纸提取主题色)支持哪个平台?( )

A. 所有平台

B. 仅 iOS

C. 仅 Android 12 及以上

D. 仅 Desktop

答案:C

解析 :动态颜色主题仅支持 Android 12 及以上版本。在 CMP 中,动态颜色的实现必须放在 androidMain 源集中,其他平台通过 expect/actual 返回 null 并回退到静态色板。

第10题 在 commonMain 中调用 Android 专属的动态颜色 API 的正确方式是( )

A. 直接导入 androidx.compose.material3.dynamicLightColorScheme 并调用

B. 通过 expect/actual 机制在 commonMain 声明 expect 函数,在 androidMain 提供 actual 实现

C. 在 commonMain 中使用反射调用

D. 动态颜色 API 本身支持所有平台,无需特殊处理

答案:B

解析 :Android 专属 API 不能在 commonMain 中直接使用。正确的做法是在 commonMain 中声明 expect 函数(如 platformDynamicColorScheme),在 androidMain 中提供 actual 实现,其他平台返回 null 作为回退。

第11题 关于 staticCompositionLocalOf 与 compositionLocalOf 的选择,以下说法正确的是( )

A. 两者完全相同,可以互换使用

B. staticCompositionLocalOf 适用于不频繁变化的 Token,读取时不追踪变化

C. compositionLocalOf 适用于不频繁变化的 Token

D. 所有 Token 都应使用 compositionLocalOf

答案:B

解析 :staticCompositionLocalOf 用于不频繁变化的 Token(如间距),读取时不追踪变化,性能更优。compositionLocalOf 用于需要追踪读取的频繁变化值(如主题颜色)。

第12题 MaterialKolor 5.0.0 的核心新增特性是( )

A. 仅支持 Android 平台

B. 支持 Material 3 Expressive,提供 DynamicMaterialExpressiveTheme

C. 移除了对 iOS 的支持

D. 替代了 Material 3 的 MaterialTheme

答案:B

解析 :MaterialKolor 5.0.0 新增了对 Material 3 Expressive 的支持,通过 DynamicMaterialExpressiveTheme 提供从种子色生成 Expressive 动态主题的能力,支持 Compose Multiplatform 1.11.0。

第四组:组件状态与 Expressive 主题(3题)

第13题 使用密封接口建模 UI 状态的核心优势是( )

A. 减少代码量

B. 穷尽性检查和类型安全,避免非法状态组合

C. 提高渲染性能

D. 自动生成状态转换逻辑

答案:B

解析 :密封接口的 when 表达式必须覆盖所有子类型,编译器在添加新状态时强制开发者处理(穷尽性检查)。Success 携带数据、Error 携带错误信息,避免了"加载中却有数据"的非法状态组合。

第14题 Material 3 Expressive 主题新增的核心参数是( )

A. colorScheme

B. motionScheme

C. shapes

D. typography

答案:B

解析 :MaterialExpressiveTheme 在 colorScheme、shapes、typography 之外新增了 motionScheme 参数,用于控制动效方案(如 MotionScheme.expressive())。

第15题 关于 Material 3 Expressive 主题的使用,以下说法正确的是( )

A. Expressive 主题是稳定 API,可直接在生产项目中使用

B. Expressive 主题的实验性 API 在 Material 3 1.4.0-beta01 中已被移除,需要使用 Alpha 版本

C. Expressive 主题只支持 Android 平台

D. Expressive 主题替代了标准的 Material 3 主题,不可同时使用

答案:B

解析 :标记为 ExperimentalMaterial3ExpressiveApi 的公共 API 在 Material 3 1.4.0-beta01 中已被移除,需要使用 Alpha 版本(如 1.9.0-alpha04)才能继续使用。生产项目应谨慎评估是否依赖实验性 API。

本课知识点总结

1. Material 3 组件体系与强调层级

Material 3 为同一功能提供多种视觉变体,按强调层级组织。按钮族从高到低依次为:Button(填充)→ ElevatedButton/FilledTonalButton/OutlinedButton(中等)→ TextButton/IconButton(低)。卡片提供 Card、ElevatedCard、OutlinedCard 三种变体。CMP 1.12.1 使用 Material 3 1.12.0-alpha03(基于 Material3 1.5.0-alpha22),稳定版为 1.4.0。

2. Material 3 主题体系(四个维度)

MaterialTheme 通过 colorScheme、typography、shapes、motionScheme 四个维度定义应用的设计语言。ColorScheme 使用语义角色(primary、surface、error 等)而非具体色值。Typography 定义 15 种文本样式层级。Shapes 定义五种圆角尺寸类别,ShapeKeyTokens.CornerFull 始终使用完全圆角。MotionScheme 提供 standard() 和 expressive() 两种动效方案。

3. 动态主题与自定义设计 Token

动态颜色是 Android 12+ 独占能力,必须放在 androidMain 源集中,通过 expect/actual 机制在 commonMain 中安全调用。非 Android 平台可使用 MaterialKolor 等库从种子色生成完整色板,MaterialKolor 5.0.0 已支持 Material 3 Expressive。Material 3 主题不包含间距参数,生产项目应通过 CompositionLocal(staticCompositionLocalOf 用于稳定 Token)实现间距设计 Token。

4. 组件状态表达

使用密封接口(sealed interface)建模 Loading、Success、Error、Retrying 等状态,获得穷尽性检查和类型安全。将状态渲染封装为泛型 LoadingUi 组件,统一加载、错误、重试的 UI 呈现。组件的 enabled 状态应基于业务逻辑自动计算,加载中的按钮应同时禁用点击并显示进度指示器。

5. Material 3 Expressive 主题与 1.5.0-alpha22 更新

CMP 支持实验性的 MaterialExpressiveTheme,新增 motionScheme 参数。Expressive 主题的实验性 API 在 Material 3 1.4.0-beta01 中已被移除,需使用 Alpha 版本。1.5.0-alpha22 中,TopAppBarScrollBehavior 提升为稳定状态,ExpressiveFloatingToolbar 转为非实验性,TopAppBarDefaults 新增 snapAnimationSpec getter,pinnedScrollBehavior 和 enterAlwaysScrollBehavior 更新为接受 ScrollableState。Expressive 组件正在逐步稳定化。


下一课预告 :第7课将深入列表与惰性布局,包括 LazyColumn、LazyRow、LazyVerticalGrid 的基本用法,key() 的正确使用与列表性能优化,列表项的重组控制与 @Stable/@Immutable 标注,iOS 端惰性布局的滚动性能改进,以及分页加载与无限滚动的实现模式。

相关推荐
FungLeo14 小时前
成为全栈·Flutter App 篇·Markdown 编辑器:编辑、预览与离开保护
flutter·移动开发·markdown编辑器·表单设计·成为全栈·草稿保存
传奇开心果编程15 小时前
【Compose Multiplatform跨端声明式UI实用技术】第3课:Compose Hot Reload 与开发效率工具链
移动开发·编程语言理论·热重载·开发范式·编程框架·声明式ui·效率工具链
FungLeo2 天前
成为全栈·Flutter App 篇·内置 WebView:网页历史、App 返回与外链安全
flutter·网络安全·移动开发·webview·路由管理·成为全栈
传奇开心果编程2 天前
【Compose Multiplatform跨端声明式UI实用技术】第4课:Composable 函数与声明式 UI 思维
移动开发·编程语言理论·开发范式·编程框架·函数式思维·声明式ui·声明式ui思维
传奇开心果编程2 天前
【Compose Multiplatform跨端声明式UI实用技术】第1课:CMP 全景解读——为什么选择声明式跨平台UI
移动开发·编程语言理论·开发范式·编程框架·声明式ui·跨平台声明式ui·逻辑共享与ui共享
FungLeo2 天前
成为全栈·Flutter App 篇·分类、标签与搜索:移动端内容发现
flutter·移动开发·标签·搜索·分类管理·成为全栈
FungLeo3 天前
成为全栈·Flutter App 篇·列表分页与下拉刷新:去重、失败保留和返回位置
flutter·移动开发·下拉刷新·分页加载·列表优化·成为全栈
FungLeo5 天前
成为全栈·Flutter App 篇·go_router 与四 Tab App Shell:保留导航状态、深链和登录回跳
flutter·移动开发·成为全栈·go_router·路由设计·深链接
传奇开心果编程7 天前
【React Native娓娓道来】第7课 表单与用户输入:TextInput、表单校验与键盘处理
移动开发·表单·编程语言理论·开发范式·编程框架·声明式ui·用户输入