14、Compose主题与动画:Material3主题、Compose动画API、自定义动画
说实话,Compose 的动画和主题系统,是我从传统 View 体系迁移过来时,最让我「眼前一亮」的部分。以前写 View 动画,动不动就 XML 文件、AnimationSet、插值器,代码量不小,效果还经常差强人意。到了 Compose 这里,动画变成了一种声明式的状态驱动——你只需要告诉它「我要从 A 变到 B」,剩下的它自己搞定。
这一章,我们就来聊聊 Material3 的主题定制,以及 Compose 里那些好用到哭的动画 API。嗯,我会把我踩过的坑也一并交代清楚。
14.1 Material3 主题:不只是换颜色
Material3 的主题系统,说白了就是一套「设计令牌」(Design Token)。你不需要去改每个组件的样式,只需要定义几个核心颜色,整个 App 的 Button、Card、TextField 都会自动适配。
我个人习惯,在项目里先定义一个 Theme.kt 文件,把颜色、字体、形状都放在一起管理。
14.1.1 颜色系统
Material3 的颜色基于「动态配色」理念。它把颜色分成了几类:Primary、Secondary、Tertiary,以及对应的 Surface、Background、Error 等。你想想看,以前我们是不是经常为了一个按钮的 disabled 状态,单独写一个颜色?现在不用了,系统会自动根据 Primary 颜色计算出 onPrimary、primaryContainer 等变体。
// 定义自定义颜色
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
secondary = Color(0xFF625B71),
surface = Color(0xFFFFFBFE),
background = Color(0xFFFFFBFE)
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
primaryContainer = Color(0xFF4F378B),
secondary = Color(0xFFCCC2DC),
surface = Color(0xFF1C1B1F),
background = Color(0xFF1C1B1F)
)
14.1.2 字体与形状
除了颜色,Material3 还允许你定制字体和形状。字体用 Typography 对象,形状用 Shapes 对象。我在项目中遇到过一个问题:设计师给的字体字号和 Material3 默认的 bodyLarge、titleMedium 对不上。后来我直接重写了整个 Typography 对象,把设计师的尺寸映射到 Material3 的语义化名称上。
val AppTypography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
)
)
val AppShapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp)
)
14.1.3 应用主题
最后,用 MaterialTheme 包裹你的 Compose 内容。它会自动根据系统暗色模式切换 Light/Dark 方案。
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
14.2 Compose 动画 API:从入门到真香
Compose 的动画 API 分三个层次:底层 API、高级 API、以及一些开箱即用的动画组件。我建议你从高级 API 入手,遇到复杂需求再往下挖。
14.2.1 animateXxxAsState:最简单的动画
如果你只是想给某个值的变化加个过渡效果,用 animateFloatAsState、animateColorAsState 这些就够了。它们会自动监听状态变化,然后平滑过渡。
@Composable
fun FadeInButton() {
var visible by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(durationMillis = 500)
)
Button(onClick = { visible = !visible }) {
Text("切换可见性")
}
if (alpha > 0.01f) {
Text("我是渐变的文字", modifier = Modifier.alpha(alpha))
}
}
14.2.2 Animatable:更精细的控制
当你需要更精细的控制,比如监听动画的每一帧、或者做「弹跳」效果时,Animatable 就派上用场了。我记得有一次做下拉刷新动画,需要让一个图标在「旋转」和「弹回」之间切换,用 animateFloatAsState 搞不定,换成 Animatable 就轻松多了。
@Composable
fun BouncingCircle() {
val scale = remember { Animatable(1f) }
LaunchedEffect(Unit) {
scale.animateTo(
targetValue = 1.5f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
scale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer(scaleX = scale.value, scaleY = scale.value)
.background(Color.Blue, CircleShape)
)
}
14.2.3 AnimatedVisibility:进场出场动画
这个 API 我几乎每个项目都会用。它让你控制一个 Composable 出现和消失时的动画效果。你可以指定 enter 和 exit 的过渡方式。
@Composable
fun AnimatedCard() {
var visible by remember { mutableStateOf(true) }
Column {
Button(onClick = { visible = !visible }) {
Text(if (visible) "隐藏" else "显示")
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Card(modifier = Modifier.padding(16.dp)) {
Text("我是会滑动的卡片")
}
}
}
}
14.3 自定义动画:当内置 API 不够用时
内置 API 能覆盖 80% 的场景。但剩下的 20%,你需要自己动手。Compose 提供了 Animatable、Animation、Transition 等底层工具,让你可以构建任意复杂的动画。
14.3.1 使用 AnimationSpec 自定义动画曲线
你可以通过 AnimationSpec 来定义动画的时长、插值器、重复次数等。比如做一个「呼吸灯」效果:
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier
.size(80.dp)
.alpha(alpha)
.background(Color.Red, CircleShape)
)
14.3.2 自定义 Animator:像写游戏一样写动画
如果你需要完全控制动画的每一帧,可以用 Animatable 配合 LaunchedEffect 手动驱动。我曾经做过一个「加载中」的动画,需要让三个点依次弹跳,用这个方案就非常灵活。
@Composable
fun LoadingDots() {
val dots = listOf(
remember { Animatable(1f) },
remember { Animatable(1f) },
remember { Animatable(1f) }
)
LaunchedEffect(Unit) {
while (true) {
for (dot in dots) {
dot.animateTo(1.5f, tween(200))
dot.animateTo(1f, tween(200))
delay(100)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
dots.forEach { dot ->
Box(
modifier = Modifier
.size(20.dp)
.graphicsLayer(scaleX = dot.value, scaleY = dot.value)
.background(Color.Gray, CircleShape)
)
}
}
}
14.4 知识体系总览
下面这张图,是我自己梳理的 Compose 主题与动画的知识结构。你可以把它当作一个「导航图」,遇到具体问题时,先定位到对应的层级。
14.5 实战建议
最后,给你几个我在实际项目中总结出来的建议:
- 主题先行: 在写任何 UI 之前,先把主题定义好。这样后续所有组件都会自动统一风格,省去大量调颜色的时间。
- 动画克制: 动画是锦上添花,不是雪中送炭。每个页面最多用 2-3 个动画,多了反而让用户觉得眼花。
- 性能监控: 如果你发现动画掉帧,优先检查是不是在动画中做了大量计算。可以用
Modifier.graphicsLayer代替Modifier.alpha等,因为 graphicsLayer 不会触发重组。 - 测试动画: Compose 的动画可以用
ComposeTestRule配合mainClock来测试。我习惯在测试中把动画时间设为 0,这样测试跑得飞快。
spring() 动画默认的 stiffness 是 Spring.StiffnessMedium。如果你想要更「Q弹」的效果,试试 Spring.StiffnessLow 配合 Spring.DampingRatioLowBouncy。嗯,这个组合我试过,效果非常接近 iOS 的弹性动画。
好了,这一章的内容就到这里。主题和动画是 Compose 里最有趣的部分,也是最能体现「声明式 UI」优势的地方。希望你能动手试试,把代码跑起来,感受一下那种「状态一变,动画自动跟上」的爽快感。