Jetpack Compose 从入门到精通(四):Material Design 3 组件与主题系统

143 阅读15分钟

深入理解 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 有三大核心变化:

特性MD2MD3
颜色系统主色/辅色/强调色基于色度的动态配色
组件风格圆角较小更大的圆角,更柔和
个性化固定配色支持动态取色

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 组件默认形状

组件默认形状说明
Buttonsmall (8.dp)标准按钮
Cardmedium (12.dp)卡片容器
DialogextraLarge (28.dp)对话框
TextFieldsmall (8.dp)输入框
FloatingActionButtonlarge (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 组件与主题(当前)✅
  • 第五篇:动画与交互
  • 第六篇:架构与工程化
  • 第七篇:高级特性与实战

如果这篇文章对你有帮助,欢迎 点赞收藏关注!有任何问题可以在评论区留言。