深入理解 Material Design 3 的设计哲学,掌握主题定制、深色模式、动态取色的完整实现方案。
前言
在前三篇中,我们掌握了 Compose 的核心机制。现在让我们进入实战阶段——使用 Material Design 3 构建美观、一致的界面。
Material Design 3 不仅是组件库,更是一套完整的设计系统。理解它的设计哲学,才能做出优雅的应用。
本篇文章将深入讲解:
- Material Design 3 的设计哲学与核心概念
- MaterialTheme 的源码实现与定制方法
- ColorScheme、Typography、Shape 三大系统
- 深色模式的完整实现方案
- 动态取色(Dynamic Color)的原理与应用
- 自定义组件的设计思路
一、Material Design 3 设计哲学
1.1 什么是 Material Design?
Material Design 是 Google 推出的设计系统,它基于物理世界的隐喻:
- 纸张(Paper):界面元素像纸张一样有厚度、有阴影
- 墨水(Ink):内容像墨水一样印在纸张上
- 光线(Light):阴影反映光源位置,创造层次感
1.2 Material Design 3 的核心变化
相比 Material Design 2,MD3 有三大核心变化:
| 特性 | MD2 | MD3 |
|---|---|---|
| 颜色系统 | 主色/辅色/强调色 | 基于色度的动态配色 |
| 组件风格 | 圆角较小 | 更大的圆角,更柔和 |
| 个性化 | 固定配色 | 支持动态取色 |
1.3 MD3 的设计原则
1.3.1 个性化(Personalization)
MD3 强调应用的个性化表达,通过颜色、字体、形状传达品牌特性。
1.3.2 适应性(Adaptability)
界面应适应不同的设备、环境和用户需求:
- 响应式布局
- 深色模式
- 动态取色
1.3.3 层次结构(Hierarchy)
通过颜色、阴影、大小区分信息层级:
- 主色用于主要操作
- 表面色用于背景
- 强调色用于高亮
二、MaterialTheme 源码解析
2.1 MaterialTheme 的结构
MaterialTheme 是 MD3 的主题容器,它通过 CompositionLocal 向下传递主题配置:
@Composable
fun MaterialTheme(
colorScheme: ColorScheme = MaterialTheme.colorScheme,
typography: Typography = MaterialTheme.typography,
shapes: Shapes = MaterialTheme.shapes,
content: @Composable () -> Unit
) {
val rememberedColorScheme = remember { colorScheme }
val rememberedTypography = remember { typography }
val rememberedShapes = remember { shapes }
CompositionLocalProvider(
LocalColorScheme provides rememberedColorScheme,
LocalTypography provides rememberedTypography,
LocalShapes provides rememberedShapes
) {
content()
}
}
2.2 MaterialTheme 的组成
┌─────────────────────────────────────────────────────────────────┐
│ MaterialTheme 结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ColorScheme(颜色系统) │ │
│ │ ├── primary/primaryContainer │ │
│ │ ├── secondary/secondaryContainer │ │
│ │ ├── surface/surfaceVariant │ │
│ │ ├── background │ │
│ │ ├── error/errorContainer │ │
│ │ └── onPrimary/onSecondary/onSurface... │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Typography(字体系统) │ │
│ │ ├── displayLarge/displayMedium/displaySmall │ │
│ │ ├── headlineLarge/headlineMedium/headlineSmall │ │
│ │ ├── titleLarge/titleMedium/titleSmall │ │
│ │ ├── bodyLarge/bodyMedium/bodySmall │ │
│ │ └── labelLarge/labelMedium/labelSmall │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Shapes(形状系统) │ │
│ │ ├── extraSmall/Small/Medium/Large/extraLarge │ │
│ │ └── 用于 Card、Button、Dialog 等组件 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
2.3 访问 MaterialTheme 的值
@Composable
fun MyComponent() {
// 获取颜色
val colorScheme = MaterialTheme.colorScheme
val primaryColor = colorScheme.primary
val surfaceColor = colorScheme.surface
// 获取字体
val typography = MaterialTheme.typography
val titleStyle = typography.titleLarge
val bodyStyle = typography.bodyMedium
// 获取形状
val shapes = MaterialTheme.shapes
val cardShape = shapes.medium
}
三、ColorScheme 颜色系统深度解析
3.1 为什么需要 ColorScheme?
传统的颜色管理存在以下问题:
- 颜色分散在代码各处,难以维护
- 深色模式需要手动替换每个颜色
- 品牌色变化时需要全局搜索替换
ColorScheme 的解决方案:
- 集中定义所有颜色
- 语义化命名(primary、surface、error 等)
- 自动支持深色模式
3.2 ColorScheme 的颜色角色
MD3 定义了 28 个颜色角色,每个都有明确的语义:
┌─────────────────────────────────────────────────────────────────┐
│ ColorScheme 颜色角色 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 主要颜色(Primary)- 主要操作、突出显示 │
│ ├── primary: 主色(按钮、选中状态) │
│ ├── onPrimary: 主色上的文字/图标 │
│ ├── primaryContainer: 主色容器(选中项背景) │
│ └── onPrimaryContainer: 主色容器上的文字 │
│ │
│ 次要颜色(Secondary)- 次要操作、筛选器 │
│ ├── secondary: 次要色 │
│ ├── onSecondary: 次要色上的文字 │
│ ├── secondaryContainer: 次要色容器 │
│ └── onSecondaryContainer: 次要色容器上的文字 │
│ │
│ 第三颜色(Tertiary)- 对比强调 │
│ ├── tertiary: 第三色 │
│ ├── onTertiary: 第三色上的文字 │
│ ├── tertiaryContainer: 第三色容器 │
│ └── onTertiaryContainer: 第三色容器上的文字 │
│ │
│ 表面颜色(Surface)- 背景、卡片 │
│ ├── surface: 最底层背景 │
│ ├── onSurface: 表面上的文字 │
│ ├── surfaceVariant: 变体表面(区分层次) │
│ ├── onSurfaceVariant: 变体表面上的文字 │
│ ├── surfaceTint: 表面色调(用于 elevation) │
│ └── inverseSurface/inverseOnSurface: 反色表面 │
│ │
│ 背景颜色(Background)- 页面背景 │
│ ├── background: 页面背景 │
│ └── onBackground: 背景上的文字 │
│ │
│ 错误颜色(Error)- 错误状态 │
│ ├── error: 错误色 │
│ ├── onError: 错误色上的文字 │
│ ├── errorContainer: 错误色容器 │
│ └── onErrorContainer: 错误色容器上的文字 │
│ │
│ 轮廓颜色(Outline)- 边框、分割线 │
│ ├── outline: 轮廓色 │
│ └── outlineVariant: 变体轮廓色 │
│ │
│ 阴影颜色(Scrim/Shadow) │
│ ├── scrim: 遮罩层颜色 │
│ └── shadow: 阴影颜色 │
│ │
└─────────────────────────────────────────────────────────────────┘
3.3 颜色角色的使用规范
// ✅ 正确使用颜色角色
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("确认")
}
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "内容",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// ❌ 错误:硬编码颜色
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF6750A4) // 硬编码
)
) {
Text("确认")
}
3.4 创建自定义 ColorScheme
// 浅色主题
val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF625B71),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF7D5260),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFFD8E4),
onTertiaryContainer = Color(0xFF31111D),
error = Color(0xFFB3261E),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCAC4D0),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF313033),
inverseOnSurface = Color(0xFFF4EFF4),
inversePrimary = Color(0xFFD0BCFF),
surfaceTint = Color(0xFF6750A4)
)
// 深色主题
val DarkColorScheme = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
onPrimaryContainer = Color(0xFFEADDFF),
// ... 其他颜色
)
3.5 颜色生成工具
Google 提供了 Material Theme Builder 工具,可以自动生成完整的 ColorScheme:
// 基于种子色生成 ColorScheme
fun generateColorScheme(seedColor: Color, isDark: Boolean): ColorScheme {
return if (isDark) {
darkColorScheme(
primary = seedColor,
// ... 工具会自动计算其他颜色
)
} else {
lightColorScheme(
primary = seedColor,
// ...
)
}
}
四、Typography 字体系统
4.1 为什么需要 Typography?
字体是界面设计的重要组成部分,Typography 系统提供了:
- 一致的字体大小和字重
- 语义化的字体角色
- 自动适配不同屏幕尺寸
4.2 MD3 的字体角色
MD3 定义了 15 个字体角色,分为 5 大类:
┌─────────────────────────────────────────────────────────────────┐
│ Typography 字体角色 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Display(展示)- 最大的文字,用于品牌展示 │
│ ├── displayLarge: 57sp / Regular │
│ ├── displayMedium: 45sp / Regular │
│ └── displaySmall: 36sp / Regular │
│ │
│ Headline(标题)- 页面标题、模块标题 │
│ ├── headlineLarge: 32sp / Regular │
│ ├── headlineMedium: 28sp / Regular │
│ └── headlineSmall: 24sp / Regular │
│ │
│ Title(标题)- 卡片标题、列表项标题 │
│ ├── titleLarge: 22sp / Medium │
│ ├── titleMedium: 16sp / Medium │
│ └── titleSmall: 14sp / Medium │
│ │
│ Body(正文)- 主要内容、段落 │
│ ├── bodyLarge: 16sp / Regular │
│ ├── bodyMedium: 14sp / Regular │
│ └── bodySmall: 12sp / Regular │
│ │
│ Label(标签)- 按钮、输入框标签、小字 │
│ ├── labelLarge: 14sp / Medium │
│ ├── labelMedium: 12sp / Medium │
│ └── labelSmall: 11sp / Medium │
│ │
└─────────────────────────────────────────────────────────────────┘
4.3 创建自定义 Typography
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
// ... 其他字体角色
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
)
)
4.4 使用自定义字体
// 加载字体资源
val InterFontFamily = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold)
)
// 应用到 Typography
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 57.sp
),
// ...
)
4.5 字体使用规范
// ✅ 正确使用字体角色
Text(
text = "页面标题",
style = MaterialTheme.typography.headlineMedium
)
Text(
text = "卡片标题",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "正文内容",
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = { }) {
Text(
text = "按钮",
style = MaterialTheme.typography.labelLarge
)
}
// ❌ 错误:硬编码字体大小
Text(
text = "标题",
fontSize = 24.sp // 硬编码
)
五、Shape 形状系统
5.1 Shape 的作用
Shape 系统定义了组件的圆角大小,影响界面的整体风格:
- 大圆角:柔和、友好
- 小圆角:专业、严肃
- 无圆角:硬朗、现代
5.2 MD3 的形状角色
val Shapes = Shapes(
extraSmall = RoundedCornerShape(4.dp), // 小标签、芯片
small = RoundedCornerShape(8.dp), // 按钮、输入框
medium = RoundedCornerShape(12.dp), // 卡片
large = RoundedCornerShape(16.dp), // 对话框
extraLarge = RoundedCornerShape(28.dp) // 底部 Sheet
)
5.3 组件默认形状
| 组件 | 默认形状 | 说明 |
|---|---|---|
| Button | small (8.dp) | 标准按钮 |
| Card | medium (12.dp) | 卡片容器 |
| Dialog | extraLarge (28.dp) | 对话框 |
| TextField | small (8.dp) | 输入框 |
| FloatingActionButton | large (16.dp) | 浮动按钮 |
5.4 自定义组件形状
// 覆盖组件默认形状
Button(
onClick = { },
shape = MaterialTheme.shapes.medium // 使用 medium 而不是默认 small
) {
Text("圆角按钮")
}
// 使用 CutCornerShape 创建切割角
val CutShapes = Shapes(
medium = CutCornerShape(
topStart = 12.dp,
topEnd = 0.dp,
bottomEnd = 12.dp,
bottomStart = 0.dp
)
)
六、深色模式完整实现
6.1 为什么需要深色模式?
- 用户体验:在低光环境下更舒适的阅读体验
- 设备续航:OLED 屏幕显示黑色时更省电
- 个性化:满足用户的审美偏好
6.2 深色模式的设计原则
6.2.1 颜色反转规则
┌─────────────────────────────────────────────────────────────────┐
│ 深色模式颜色映射 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 浅色模式 深色模式 │
│ ───────────────────────────────────────────────────────────── │
│ background (#FFFBFE) → background (#1C1B1F) │
│ surface (#FFFBFE) → surface (#1C1B1F) │
│ onSurface (#1C1B1F) → onSurface (#E6E1E5) │
│ primary (#6750A4) → primary (#D0BCFF) │
│ onPrimary (#FFFFFF) → onPrimary (#381E72) │
│ │
│ 核心原则:降低亮度,保持对比度 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.2.2 Elevation 在深色模式中的表现
在深色模式下,elevation 不使用阴影,而是使用表面变亮来表示:
// 深色模式下,elevation 越高,surface 越亮
Card(
modifier = Modifier.height(48.dp),
colors = CardDefaults.elevatedCardColors()
) {
// 内容
}
6.3 实现深色模式
6.3.1 定义深色 ColorScheme
// 深色主题颜色
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5)
)
// 浅色主题颜色
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F)
)
6.3.2 检测系统主题
@Composable
fun MyApp(
darkTheme: Boolean = isSystemInDarkTheme() // 检测系统主题
) {
val colorScheme = if (darkTheme) {
DarkColorScheme
} else {
LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}
6.3.3 主题切换功能
// 使用 DataStore 保存用户主题偏好
class ThemePreference(private val dataStore: DataStore<Preferences>) {
val themeFlow: Flow<ThemeMode> = dataStore.data
.map { preferences ->
when (preferences[THEME_KEY]) {
"light" -> ThemeMode.LIGHT
"dark" -> ThemeMode.DARK
else -> ThemeMode.SYSTEM
}
}
suspend fun setTheme(theme: ThemeMode) {
dataStore.edit { preferences ->
preferences[THEME_KEY] = when (theme) {
ThemeMode.LIGHT -> "light"
ThemeMode.DARK -> "dark"
ThemeMode.SYSTEM -> "system"
}
}
}
companion object {
val THEME_KEY = stringPreferencesKey("theme")
}
}
enum class ThemeMode {
LIGHT, DARK, SYSTEM
}
// 在应用中使用
@Composable
fun MyApp(
themePreference: ThemePreference
) {
val themeMode by themePreference.themeFlow.collectAsState(ThemeMode.SYSTEM)
val systemDarkTheme = isSystemInDarkTheme()
val darkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> systemDarkTheme
}
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
content = content
)
}
// 主题设置界面
@Composable
fun ThemeSettings(
themePreference: ThemePreference
) {
val themeMode by themePreference.themeFlow.collectAsState(ThemeMode.SYSTEM)
val scope = rememberCoroutineScope()
Column {
Text("主题设置", style = MaterialTheme.typography.titleLarge)
ThemeOption(
title = "跟随系统",
selected = themeMode == ThemeMode.SYSTEM,
onClick = { scope.launch { themePreference.setTheme(ThemeMode.SYSTEM) } }
)
ThemeOption(
title = "浅色模式",
selected = themeMode == ThemeMode.LIGHT,
onClick = { scope.launch { themePreference.setTheme(ThemeMode.LIGHT) } }
)
ThemeOption(
title = "深色模式",
selected = themeMode == ThemeMode.DARK,
onClick = { scope.launch { themePreference.setTheme(ThemeMode.DARK) } }
)
}
}
@Composable
fun ThemeOption(
title: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(title)
if (selected) {
Icon(Icons.Default.Check, contentDescription = null)
}
}
}
6.4 深色模式最佳实践
6.4.1 避免纯黑色
// ❌ 错误:使用纯黑色
val DarkBackground = Color(0xFF000000)
// ✅ 正确:使用深灰色
val DarkBackground = Color(0xFF1C1B1F) // 推荐
6.4.2 保持对比度
// ✅ 确保文字对比度符合 WCAG 标准
// 深色模式下,onSurface 应该是浅色
val onSurfaceDark = Color(0xFFE6E1E5) // 对比度 > 4.5:1
6.4.3 图片适配
// 为深色模式提供不同的图片资源
Image(
painter = painterResource(
if (isSystemInDarkTheme()) {
R.drawable.logo_dark
} else {
R.drawable.logo_light
}
),
contentDescription = null
)
七、动态取色(Dynamic Color)
7.1 什么是动态取色?
动态取色是 Android 12+ 引入的功能,应用可以根据用户设置的壁纸自动生成配色方案。
7.2 动态取色的原理
┌─────────────────────────────────────────────────────────────────┐
│ 动态取色流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 提取壁纸主色调 │
│ ├── 使用 quantizer 算法提取主要颜色 │
│ └── 筛选出符合无障碍标准的颜色 │
│ ↓ │
│ 2. 生成色板(Tonal Palette) │
│ ├── 基于主色生成不同色调的变体 │
│ └── 创建 Primary、Secondary、Tertiary、Neutral 色板 │
│ ↓ │
│ 3. 映射到 ColorScheme │
│ ├── primary = 主色板的 40 色调 │
│ ├── primaryContainer = 主色板的 90 色调 │
│ └── ... │
│ ↓ │
│ 4. 应用到应用 │
│ └── MaterialTheme 使用动态 ColorScheme │
│ │
└─────────────────────────────────────────────────────────────────┘
7.3 实现动态取色
@Composable
fun MyApp(
dynamicColor: Boolean = true
) {
val darkTheme = isSystemInDarkTheme()
val colorScheme = when {
// Android 12+ 支持动态取色
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
}
// 使用预定义主题
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}
7.4 动态取色最佳实践
7.4.1 提供关闭选项
// 让用户选择是否使用动态取色
class ColorPreference(private val dataStore: DataStore<Preferences>) {
val dynamicColorFlow: Flow<Boolean> = dataStore.data
.map { it[DYNAMIC_COLOR_KEY] ?: true }
suspend fun setDynamicColor(enabled: Boolean) {
dataStore.edit { it[DYNAMIC_COLOR_KEY] = enabled }
}
companion object {
val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
}
}
7.4.2 备用方案
// 当动态取色不可用时,使用品牌色
val colorScheme = if (dynamicColor && supportsDynamicColor()) {
dynamicLightColorScheme(context)
} else {
// 使用应用的品牌色
lightColorScheme(
primary = BrandPrimary,
secondary = BrandSecondary
)
}
八、Material 组件深度解析
8.1 Button 组件
8.1.1 Button 的类型
// 1. FilledButton(填充按钮)- 主要操作
Button(onClick = { }) {
Text("确认")
}
// 2. ElevatedButton(凸起按钮)- 需要强调的操作
ElevatedButton(onClick = { }) {
Text("重要操作")
}
// 3. OutlinedButton(描边按钮)- 次要操作
OutlinedButton(onClick = { }) {
Text("取消")
}
// 4. TextButton(文本按钮)- 最低优先级操作
TextButton(onClick = { }) {
Text("了解更多")
}
// 5. IconButton(图标按钮)
IconButton(onClick = { }) {
Icon(Icons.Default.Add, contentDescription = "添加")
}
// 6. FloatingActionButton(浮动按钮)
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.Add, contentDescription = "添加")
}
8.1.2 Button 的设计规范
┌─────────────────────────────────────────────────────────────────┐
│ Button 使用规范 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 按钮层级(从高到低): │
│ │
│ 1. FilledButton │
│ ├── 用途:最重要的操作 │
│ ├── 数量:每屏最多 1 个 │
│ └── 位置:底部或操作区域 │
│ │
│ 2. ElevatedButton │
│ ├── 用途:需要强调的操作 │
│ └── 场景:在背景复杂的区域 │
│ │
│ 3. OutlinedButton │
│ ├── 用途:次要操作 │
│ ├── 数量:可以有多个 │
│ └── 场景:与 FilledButton 配合使用 │
│ │
│ 4. TextButton │
│ ├── 用途:最低优先级操作 │
│ └── 场景:工具栏、对话框 │
│ │
└─────────────────────────────────────────────────────────────────┘
8.1.3 自定义 Button
// 自定义颜色
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Text("自定义按钮")
}
// 带图标的按钮
Button(onClick = { }) {
Icon(Icons.Default.Send, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("发送")
}
8.2 Card 组件
8.2.1 Card 的类型
// 1. 普通 Card
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { }
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("卡片标题", style = MaterialTheme.typography.titleLarge)
Text("卡片内容", style = MaterialTheme.typography.bodyMedium)
}
}
// 2. ElevatedCard(带阴影)
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 6.dp
)
) {
// 内容
}
// 3. OutlinedCard(带边框)
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
// 内容
}
8.2.2 Card 的设计规范
// 卡片内容结构
@Composable
fun ArticleCard(article: Article) {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { }
) {
Column {
// 图片(可选)
AsyncImage(
model = article.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(194.dp),
contentScale = ContentScale.Crop
)
// 内容区域
Column(modifier = Modifier.padding(16.dp)) {
// 标题
Text(
text = article.title,
style = MaterialTheme.typography.titleLarge
)
// 副标题(可选)
Text(
text = article.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 操作按钮
Row(
modifier = Modifier.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { }) {
Text("阅读更多")
}
}
}
}
}
}
8.3 TextField 组件
8.3.1 TextField 的类型
// 1. OutlinedTextField(描边样式)
var text by remember { mutableStateOf("") }
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("用户名") },
placeholder = { Text("请输入用户名") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
trailingIcon = {
if (text.isNotEmpty()) {
IconButton(onClick = { text = "" }) {
Icon(Icons.Default.Clear, contentDescription = "清除")
}
}
},
isError = text.length > 20,
supportingText = {
if (text.length > 20) {
Text("用户名不能超过 20 个字符")
}
}
)
// 2. TextField(填充样式)
TextField(
value = text,
onValueChange = { text = it },
label = { Text("密码") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password
)
)
8.3.2 表单验证
@Composable
fun LoginForm() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val emailError = if (email.isNotEmpty() && !email.isValidEmail()) {
"请输入有效的邮箱地址"
} else null
val passwordError = if (password.isNotEmpty() && password.length < 6) {
"密码不能少于 6 位"
} else null
Column {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("邮箱") },
isError = emailError != null,
supportingText = emailError?.let { { Text(it) } }
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
visualTransformation = PasswordVisualTransformation(),
isError = passwordError != null,
supportingText = passwordError?.let { { Text(it) } }
)
Button(
onClick = { /* 登录 */ },
enabled = emailError == null && passwordError == null
) {
Text("登录")
}
}
}
8.4 Dialog 组件
8.4.1 AlertDialog
@Composable
fun DeleteConfirmDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("确认删除") },
text = { Text("确定要删除这个项目吗?此操作无法撤销。") },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("删除")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
8.4.2 自定义 Dialog
@Composable
fun CustomDialog(
onDismiss: () -> Unit,
content: @Composable () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.extraLarge
) {
content()
}
}
}
8.5 Scaffold 布局
@Composable
fun MyScreen() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("标题") },
navigationIcon = {
IconButton(onClick = { }) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
},
actions = {
IconButton(onClick = { }) {
Icon(Icons.Default.Search, contentDescription = "搜索")
}
}
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = true,
onClick = { },
icon = { Icon(Icons.Default.Home, contentDescription = null) },
label = { Text("首页") }
)
NavigationBarItem(
selected = false,
onClick = { },
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("我的") }
)
}
},
floatingActionButton = {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.Add, contentDescription = "添加")
}
}
) { innerPadding ->
// 内容区域
LazyColumn(
modifier = Modifier.padding(innerPadding)
) {
// 列表内容
}
}
}
九、自定义组件设计思路
9.1 组件设计原则
9.1.1 单一职责
一个组件只做一件事:
// ✅ 好的设计:只负责显示用户信息
@Composable
fun UserAvatar(user: User, size: Dp = 48.dp) {
AsyncImage(
model = user.avatarUrl,
contentDescription = user.name,
modifier = Modifier
.size(size)
.clip(CircleShape)
)
}
// ❌ 坏的设计:既显示头像又处理点击跳转
@Composable
fun UserAvatarBad(user: User, onNavigate: () -> Unit) {
AsyncImage(
model = user.avatarUrl,
contentDescription = user.name,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.clickable { onNavigate() } // 不应该在这里处理导航
)
}
9.1.2 可配置性
提供合理的自定义选项:
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier, // 允许外部传入 Modifier
enabled: Boolean = true,
icon: ImageVector? = null
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled
) {
icon?.let {
Icon(it, contentDescription = null)
Spacer(Modifier.width(8.dp))
}
Text(text)
}
}
9.1.3 状态提升
将状态提升到父组件:
// ✅ 好的设计:无状态组件
@Composable
fun ExpandableCard(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
title: @Composable () -> Unit,
content: @Composable () -> Unit
) {
Card {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onExpandedChange(!expanded) }
.padding(16.dp)
) {
title()
Spacer(Modifier.weight(1f))
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "收起" else "展开"
)
}
AnimatedVisibility(visible = expanded) {
content()
}
}
}
}
// 使用
@Composable
fun MyScreen() {
var expanded by remember { mutableStateOf(false) }
ExpandableCard(
expanded = expanded,
onExpandedChange = { expanded = it },
title = { Text("标题") },
content = { Text("展开的内容") }
)
}
9.2 实战:自定义 Chip 组件
/**
* 自定义 Chip 组件
*
* 设计思路:
* 1. 支持选中/未选中两种状态
* 2. 可配置颜色、形状、尺寸
* 3. 支持图标和删除按钮
*/
@Composable
fun CustomChip(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
colors: SelectableChipColors = ChipDefaults.selectableChipColors(),
shape: Shape = MaterialTheme.shapes.small
) {
val containerColor by colors.containerColor(enabled, selected)
val labelColor by colors.labelColor(enabled, selected)
Surface(
modifier = modifier,
shape = shape,
color = containerColor,
onClick = onClick,
enabled = enabled
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
leadingIcon?.let { icon ->
Box(Modifier.size(18.dp)) { icon() }
Spacer(Modifier.width(8.dp))
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = labelColor
)
onDelete?.let { onDeleteClick ->
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.Close,
contentDescription = "删除",
modifier = Modifier
.size(18.dp)
.clickable(onClick = onDeleteClick),
tint = labelColor
)
}
}
}
}
// 使用示例
@Composable
fun ChipDemo() {
var selected by remember { mutableStateOf(false) }
FlowRow(horizontalGap = 8.dp, verticalGap = 8.dp) {
// 普通 Chip
CustomChip(
text = "标签 1",
selected = false,
onClick = { }
)
// 选中状态
CustomChip(
text = "已选中",
selected = true,
onClick = { selected = !selected }
)
// 带图标的 Chip
CustomChip(
text = "带图标",
selected = false,
onClick = { },
leadingIcon = {
Icon(Icons.Default.Check, contentDescription = null)
}
)
// 可删除的 Chip
CustomChip(
text = "可删除",
selected = false,
onClick = { },
onDelete = { /* 删除逻辑 */ }
)
}
}
十、本篇小结
今天我们深入探讨了 Material Design 3 的组件与主题系统:
Material Design 3 设计哲学
- 理解了 MD3 的核心变化和设计原则
- 掌握了个性化、适应性、层次结构三大特性
MaterialTheme 系统
- 深入理解了 ColorScheme、Typography、Shape 三大系统
- 掌握了主题定制的完整方法
深色模式
- 理解了颜色反转规则和 elevation 处理
- 掌握了完整实现方案和最佳实践
动态取色
- 理解了动态取色的原理
- 掌握了实现方法和备用方案
Material 组件
- 深入理解了 Button、Card、TextField、Dialog 等组件
- 掌握了组件使用规范和自定义方法
自定义组件
- 理解了组件设计原则(单一职责、可配置性、状态提升)
- 掌握了自定义组件的完整设计思路
下篇预告
第五篇:动画与交互 将深入讲解:
- Compose 动画系统架构
- 属性动画与过渡动画
- 手势处理与触摸交互
- 自定义动画的设计思路
敬请期待!
参考资源
📌 系列文章导航
- 第一篇:初识 Compose ✅
- 第二篇:核心基石 ✅
- 第三篇:状态管理 ✅
- 第四篇:Material 组件与主题(当前)✅
- 第五篇:动画与交互
- 第六篇:架构与工程化
- 第七篇:高级特性与实战
如果这篇文章对你有帮助,欢迎 点赞、收藏、关注!有任何问题可以在评论区留言。