Compose 主题与 Material 3
Material 3 主题体系在 Compose 中的落地:MaterialTheme 的颜色/字体/形状三大支柱、lightColorScheme/darkColorScheme、Android 12+ 动态颜色、自定义字体 Typography、Shapes 形状、自定义主题与组件级主题覆盖。
学习目标
- 理解 MaterialTheme 的颜色/字体/形状三大支柱
- 掌握 lightColorScheme/darkColorScheme 与暗色模式切换
- 启用 Android 12+ 动态颜色并适配老版本回退
- 自定义 Typography 接入自定义字体
- 自定义 Shapes 与组件级主题覆盖
学习目标
- 理解
MaterialTheme的颜色/字体/形状三大支柱; - 掌握
lightColorScheme/darkColorScheme与暗色模式切换; - 启用 Android 12+ 动态颜色并适配老版本回退;
- 自定义
Typography接入自定义字体; - 自定义
Shapes与组件级主题覆盖。
Compose 主题与 Material 3
Compose 用 MaterialTheme 统一管理“颜色 + 字体 + 形状”三套设计系统参数。所有 Material 3 组件(Button/Card/TopAppBar…)都从 MaterialTheme 读取这些参数,因此改主题就能全局生效。
依赖配置
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.material3:material3")
// 字体加载用 Accompanist 或 Compose 1.6+ 原生
implementation("androidx.compose.ui:ui-text-googlefonts")
}
MaterialTheme 的三大支柱
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.Typography
import androidx.compose.material3.Shapes
@Composable
fun MyAppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = ..., // ColorScheme
typography = ..., // Typography
shapes = ..., // Shapes
content = content
)
}
子树中任意位置可通过 MaterialTheme.colorScheme.primary、MaterialTheme.typography.bodyLarge、MaterialTheme.shapes.medium 读取当前主题。
ColorScheme:浅色与深色
ColorScheme 定义了一组 Material 3 角色色板:primary/onPrimary/primaryContainer、secondary、tertiary、background/onBackground、surface/onSurface 等。Compose 提供 lightColorScheme() 与 darkColorScheme() 两个构造器:
private val LightColors = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF625B71),
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
onPrimaryContainer = Color(0xFFEADDFF),
secondary = Color(0xFFCCC2DC),
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5),
error = Color(0xFFF2B8B5),
onError = Color(0xFF601410)
)
Material 3 设计原则:
on*是“放在对应颜色上的前景色”,对比度必须达到 WCAG AA。改色时务必同时调整on*,否则文字看不清。
跟随系统暗色模式
import androidx.compose.foundation.isSystemInDarkTheme
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colors,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
应用入口:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
AppNavHost()
}
}
}
}
Material 3 默认组件已包含“暗色模式自动适配”——只要 ColorScheme 提供完整,
Button、Card、TopAppBar都会自动用对应颜色。
动态颜色(Dynamic Color,Android 12+)
Android 12+ 引入“动态颜色”:系统从壁纸提取主色,自动生成主题色板。Compose 通过 dynamicLightColorScheme(context) / dynamicDarkColorScheme(context) 获取:
import android.os.Build
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
context: Context = LocalContext.current,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
要点:
- 仅 Android 12+(API 31+)支持,低版本回退到自定义 ColorScheme;
- 建议让用户在设置里关闭动态颜色(部分品牌 OEM 也有自己的动态颜色实现);
- 动态颜色优先于“品牌色”,如果你的 App 有强品牌识别需求,可以
dynamicColor = false。
Typography:自定义字体
Typography 由 Material 3 字阶(display/headline/title/body/label,每档 Large/Medium/Small)组成。默认用 Roboto,可整体替换:
资源方式
把 .ttf 放到 res/font/:
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.text.googlefonts.GoogleFont.Provider
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.Font
val provider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
queries = listOf("Poppins")
)
val AppFontFamily = FontFamily(
Font(R.font.poppins_regular),
Font(R.font.poppins_medium, FontWeight.Medium),
Font(R.font.poppins_bold, FontWeight.Bold)
)
val AppTypography = Typography(
displayLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 57.sp),
headlineMedium = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 28.sp),
bodyLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp),
bodyMedium = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp),
labelLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp)
)
只覆盖必要的几档,其余用默认值。
@Composable
fun MyAppTheme(content: @Composable () -> Unit) {
MaterialTheme(typography = AppTypography, content = content)
}
自定义字体通常需要 CJK 子集 才能完整覆盖中文。常用做法:西文用 Poppins,中文回退系统字体。可在
TextStyle里写fontFamily = FontFamily(AppFontFamily, FontFamily.Default)。
Shapes:形状定义
Shapes 定义小/中/大三种圆角,组件按尺寸自动选用:
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
val AppShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp)
)
MaterialTheme(shapes = AppShapes, content = content)
Card 默认用 medium,Button 用 small,BottomSheet 用 large。统一形状能让设计语言一致。
自定义主题:完整示例
把以上要素整合成一个完整主题:
private val LightColors = lightColorScheme(
primary = Color(0xFF00696D),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFF6CF6FE),
onPrimaryContainer = Color(0xFF002022),
secondary = Color(0xFF4A6363),
onSecondary = Color(0xFFFFFFFF),
background = Color(0xFFF4FBFA),
onBackground = Color(0xFF161D1D),
surface = Color(0xFFF4FBFA),
onSurface = Color(0xFF161D1D),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF4AD9E2),
onPrimary = Color(0xFF00373A),
primaryContainer = Color(0xFF004F53),
onPrimaryContainer = Color(0xFF6CF6FE),
secondary = Color(0xFFB1CCCB),
background = Color(0xFF0E1514),
surface = Color(0xFF0E1514),
onSurface = Color(0xFFB1CCCB),
error = Color(0xFFFFB4AB)
)
val AppFontFamily = FontFamily(
Font(R.font.poppins_regular),
Font(R.font.poppins_medium, FontWeight.Medium),
Font(R.font.poppins_bold, FontWeight.Bold)
)
val AppTypography = Typography(
bodyLarge = TextStyle(fontFamily = AppFontFamily, fontSize = 16.sp, lineHeight = 24.sp),
headlineLarge = TextStyle(fontFamily = AppFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp)
)
val AppShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(28.dp)
)
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val ctx = LocalContext.current
val scheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = scheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
组件级主题覆盖
Material 3 允许在 MaterialTheme 之外,单独覆盖某类组件的默认样式:
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
@Composable
fun BrandedCard(content: @Composable ColumnScope.() -> Unit) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth()
) {
content()
}
}
更系统化的做法是给整个 App 提供 ButtonColors/CardColors 等的统一对象:
object AppComponents {
@Composable
fun primaryButtonColors() = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
Compose Material 3 暂未提供像 View 体系那样的
MaterialComponents统一覆盖 attribute,更推荐的做法是写一层薄包装(如上)让所有页面调用同一套预设。
状态栏与系统栏适配
主题切换通常还要同步状态栏图标颜色:
import androidx.core.view.WindowCompat
import androidx.compose.ui.graphics.luminance
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val darkTheme = isSystemInDarkTheme()
MyAppTheme(darkTheme) {
val barColor = Color.Transparent
SideEffect {
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightStatusBars = !darkTheme
controller.isAppearanceLightNavigationBars = !darkTheme
}
AppNavHost()
}
}
}
}
SideEffect 在每次重组完成、提交到屏幕之前执行一次,适合同步 Compose 状态到 View 层(如 Window)。
实战:主题切换 + 持久化
把暗色模式选项存到 DataStore/SharedPreferences,启动时恢复:
class ThemeViewModel(private val prefs: SharedPreferences) : ViewModel() {
private val _dark = MutableStateFlow(prefs.getBoolean("dark", false))
val dark: StateFlow<Boolean> = _dark
fun setDark(v: Boolean) {
_dark.value = v
prefs.edit().putBoolean("dark", v).apply()
}
}
@Composable
fun Root(themeVm: ThemeViewModel = viewModel()) {
val dark by themeVm.dark.collectAsStateWithLifecycle()
MyAppTheme(darkTheme = dark, dynamicColor = false) {
Scaffold { padding ->
Column(Modifier.padding(padding)) {
Text("暗色:$dark")
Button(onClick = { themeVm.setDark(!dark) }) { Text("切换") }
}
}
}
}
常见坑与最佳实践
- 忘记包装
MaterialTheme:根 Composable 直接调用Button等会拿到默认主题,与设计不符。务必在setContent { MyAppTheme { ... } }包一层。 - 只改
primary忘记onPrimary:背景变了文字没变,对比度不足。每个*Container都要配套on*Container。 - 动态颜色在低版本直接调用:
dynamicLightColorScheme在 API 低于 31 上抛异常。必须先判SDK_INT。 - 自定义字体没声明混排回退:Poppins 不含中文,单独使用会显示方块。
FontFamily(Font(R.font.poppins), FontFamily.Default)才能正常回退。 Typography只改bodyLarge:其他档仍用 Roboto,与品牌字体不一致。至少覆盖 headline/title/label 这几档常用项。- 系统栏图标颜色没同步:暗色模式下状态栏图标仍是黑色,看不见。要在
SideEffect里更新WindowInsetsControllerCompat。 Shapes圆角过大导致点击区域错觉:large圆角接近全圆时,矩形按钮点击区与视觉区严重不符,影响可达性。- 多套主题用 if/else 嵌套
MaterialTheme:嵌套 MaterialTheme 会让内层取不到外层 shapes/typography。需要局部切换颜色时只重写colorScheme,shapes/typography 透传。 - 在深色模式用纯黑背景:Material 3 推荐“深色但带主色调”的背景(如
#1C1B1F),纯黑会让卡片层次感丢失。 - 状态栏透明却没处理
WindowInsets:内容会被系统栏遮挡。用Modifier.windowInsetsPadding(WindowInsets.statusBars)或Scaffold自动处理。
章节小结
本章覆盖了 Material 3 在 Compose 中的主题实现:MaterialTheme 统一管理 ColorScheme/Typography/Shapes 三大支柱;lightColorScheme/darkColorScheme + isSystemInDarkTheme() 实现跟随系统的暗色模式;Android 12+ 的 dynamicLightColorScheme/dynamicDarkColorScheme 启用动态颜色并保留低版本回退;Typography 与自定义 FontFamily 接入品牌字体;Shapes 统一圆角语言;通过组件级 *Defaults.*Colors 做局部覆盖;SideEffect 同步系统栏外观,让主题切换完整闭环。
下一章预告
下一章是本部分也是全书的最后一章:Compose UI 测试(createComposeRule/onNodeWithText/performClick/assertIsDisplayed)、AndroidView 互操作测试、ComposeView 在 Fragment/Activity 中的使用、迁移策略与性能最佳实践,并给出全书结语与下一步学习建议。