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)
)
我的小技巧: 在定义 Dark 主题时,不要直接把 Light 的颜色取反。我踩过这个坑——取反后的颜色在深色背景下可读性很差。最好用 Material Theme Builder 这个在线工具,它能帮你自动生成一套和谐的颜色方案。

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:最简单的动画

如果你只是想给某个值的变化加个过渡效果,用 animateFloatAsStateanimateColorAsState 这些就够了。它们会自动监听状态变化,然后平滑过渡。

@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))
    }
}
核心要点: animateXxxAsState 返回的是一个 State 对象,Compose 会在每一帧重新计算它的值。你不需要手动管理动画循环,Compose 的协程机制会帮你搞定。

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("我是会滑动的卡片")
            }
        }
    }
}
我曾经踩过的坑: AnimatedVisibility 的 enter/exit 动画不要写得太复杂。有一次我同时用了 slideIn、fadeIn、scaleIn 三个动画,结果在低端手机上出现了明显的掉帧。后来我改成只保留 slideIn + fadeIn,效果反而更流畅。记住,动画不是越多越好。

14.3 自定义动画:当内置 API 不够用时

内置 API 能覆盖 80% 的场景。但剩下的 20%,你需要自己动手。Compose 提供了 AnimatableAnimationTransition 等底层工具,让你可以构建任意复杂的动画。

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 主题与动画的知识结构。你可以把它当作一个「导航图」,遇到具体问题时,先定位到对应的层级。

Compose 主题与动画知识体系 Material3 主题系统 颜色系统(lightColorScheme/darkColorScheme) 字体系统(Typography) 形状系统(Shapes) MaterialTheme 组合 Compose 动画 API animateXxxAsState(简单过渡) Animatable(精细控制) AnimatedVisibility(进出场) 自定义动画(AnimationSpec/Animator) 核心思想:声明式动画 + 状态驱动,告别传统 View 动画的繁琐

14.5 实战建议

最后,给你几个我在实际项目中总结出来的建议:

  • 主题先行: 在写任何 UI 之前,先把主题定义好。这样后续所有组件都会自动统一风格,省去大量调颜色的时间。
  • 动画克制: 动画是锦上添花,不是雪中送炭。每个页面最多用 2-3 个动画,多了反而让用户觉得眼花。
  • 性能监控: 如果你发现动画掉帧,优先检查是不是在动画中做了大量计算。可以用 Modifier.graphicsLayer 代替 Modifier.alpha 等,因为 graphicsLayer 不会触发重组。
  • 测试动画: Compose 的动画可以用 ComposeTestRule 配合 mainClock 来测试。我习惯在测试中把动画时间设为 0,这样测试跑得飞快。
一个小彩蛋: Compose 的 spring() 动画默认的 stiffness 是 Spring.StiffnessMedium。如果你想要更「Q弹」的效果,试试 Spring.StiffnessLow 配合 Spring.DampingRatioLowBouncy。嗯,这个组合我试过,效果非常接近 iOS 的弹性动画。

好了,这一章的内容就到这里。主题和动画是 Compose 里最有趣的部分,也是最能体现「声明式 UI」优势的地方。希望你能动手试试,把代码跑起来,感受一下那种「状态一变,动画自动跟上」的爽快感。