14、高级动画:AnimatedVisibility、AnimatedContent、Crossfade

动画这东西,说实话,是 UI 开发的灵魂。没有动画的界面,就像一杯白开水——解渴,但没滋味。Compose 的声明式动画,跟传统 View 系统比起来,简直是降维打击。你不需要再跟 Animator、AnimationSet 这些老古董打交道了。

今天咱们聊三个高级动画 API:AnimatedVisibilityAnimatedContentCrossfade。这三个家伙,基本覆盖了日常开发中 80% 的动画场景。我个人习惯把它们叫做「动画三剑客」。

核心要点:这三个 API 都是基于 Compose 的声明式思想设计的。你只需要描述「状态变化后的 UI 应该是什么样」,动画的中间过程,框架帮你搞定。

Compose 高级动画三剑客 动画三剑客 AnimatedVisibility AnimatedContent Crossfade 特性 • 控制组件出现/消失 • 支持多种入场/退场效果 • 可自定义动画规格 • 适合列表项、弹窗等 特性 • 内容切换时带动画 • 基于目标状态变换 • 支持 SizeTransform • 适合 Tab 切换、轮播 特性 • 两个内容间淡入淡出 • 适合图片切换、主题切换 • 可指定动画规格 • 简单直接,性能好

14.1 AnimatedVisibility:让组件优雅地出现和消失

先聊聊 AnimatedVisibility。这玩意儿,说白了就是给组件的显示/隐藏加个过渡动画。你想想看,一个列表项突然冒出来或者突然消失,用户会不会觉得突兀?

我记得有一次做电商项目,购物车列表的删除操作,一开始直接调用了 removeAt,结果测试妹子反馈说「东西突然就没了,我怀疑是不是点错了」。后来加上 AnimatedVisibility,用户体验瞬间提升了一个档次。

@Composable
fun AnimatedVisibilityDemo() {
    var visible by remember { mutableStateOf(true) }

    Column(modifier = Modifier.padding(16.dp)) {
        Button(onClick = { visible = !visible }) {
            Text(if (visible) "隐藏" else "显示")
        }

        Spacer(modifier = Modifier.height(8.dp))

        AnimatedVisibility(
            visible = visible,
            enter = slideInVertically() + fadeIn(),
            exit = slideOutVertically() + fadeOut()
        ) {
            Card(
                modifier = Modifier.fillMaxWidth().height(100.dp),
                colors = CardDefaults.cardColors(containerColor = Color(0xFFE3F2FD))
            ) {
                Text(
                    text = "我是一个会滑入滑出的卡片",
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

小技巧:enterexit 参数支持组合多个动画效果。用 + 运算符连接即可。我个人习惯用 slideInVertically() + fadeIn() 的组合,既有滑动又有淡入,视觉上很自然。

这里有个坑,我曾经踩过——AnimatedVisibility 默认会占用布局空间,即使组件不可见。如果你希望组件完全消失(不占位),需要配合 Modifier.animateContentSize() 或者使用 AnimatedVisibility(visible, enter, exit, initiallyVisible = false) 来控制初始状态。

14.2 AnimatedContent:内容切换的优雅方案

接下来是 AnimatedContent。这个 API 解决的是「同一个位置,不同内容之间的切换动画」。比如 Tab 切换、轮播图、状态机切换等场景。

为什么说它强大?因为它不仅仅是淡入淡出,而是可以根据目标状态,自动计算过渡动画。你想想看,如果两个内容的尺寸不一样,它还能自动处理尺寸变化。

@Composable
fun AnimatedContentDemo() {
    var page by remember { mutableIntStateOf(0) }
    val pages = listOf("首页", "发现", "我的")

    Column(modifier = Modifier.padding(16.dp)) {
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            pages.forEachIndexed { index, title ->
                FilterChip(
                    selected = page == index,
                    onClick = { page = index },
                    label = { Text(title) }
                )
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        AnimatedContent(
            targetState = page,
            transitionSpec = {
                if (targetState > initialState) {
                    slideInHorizontally { width -> width } + fadeIn() togetherWith
                    slideOutHorizontally { width -> -width } + fadeOut()
                } else {
                    slideInHorizontally { width -> -width } + fadeIn() togetherWith
                    slideOutHorizontally { width -> width } + fadeOut()
                }
            },
            label = "page_transition"
        ) { currentPage ->
            // 根据 currentPage 渲染不同内容
            when (currentPage) {
                0 -> PageContent("首页内容", Color(0xFFE3F2FD))
                1 -> PageContent("发现内容", Color(0xFFF3E5F5))
                2 -> PageContent("我的内容", Color(0xFFE8F5E9))
            }
        }
    }
}

@Composable
fun PageContent(text: String, bgColor: Color) {
    Card(
        modifier = Modifier.fillMaxWidth().height(200.dp),
        colors = CardDefaults.cardColors(containerColor = bgColor)
    ) {
        Box(contentAlignment = Alignment.Center) {
            Text(text = text, fontSize = 20.sp)
        }
    }
}

注意:AnimatedContenttransitionSpec 中,togetherWith 是关键。它把入场动画和退场动画「绑定」在一起,让新旧内容同时运动。如果你用 using,则是串行执行,效果完全不同。

嗯,这里要特别说一下 SizeTransform。如果你切换的内容尺寸不同,AnimatedContent 默认会裁剪内容。想要平滑过渡尺寸?加上 sizeTransform = SizeTransform(clip = false) 即可。我在做个人中心页面时,头像和列表切换就用了这个,效果很丝滑。

14.3 Crossfade:最简单的淡入淡出

最后聊聊 Crossfade。这玩意儿,说白了就是 AnimatedContent 的简化版。它只做一件事:在两个内容之间做淡入淡出。

什么时候用?当你不需要滑动、缩放等复杂效果,只需要一个简单的交叉淡入淡出时。比如图片切换、主题切换、加载状态切换等。

@Composable
fun CrossfadeDemo() {
    var isDark by remember { mutableStateOf(false) }

    Column(modifier = Modifier.padding(16.dp)) {
        Button(onClick = { isDark = !isDark }) {
            Text("切换主题")
        }

        Spacer(modifier = Modifier.height(16.dp))

        Crossfade(
            targetState = isDark,
            animationSpec = tween(durationMillis = 500),
            label = "theme_crossfade"
        ) { darkMode ->
            Card(
                modifier = Modifier.fillMaxWidth().height(150.dp),
                colors = CardDefaults.cardColors(
                    containerColor = if (darkMode) Color.DarkGray else Color.White
                )
            ) {
                Box(contentAlignment = Alignment.Center) {
                    Text(
                        text = if (darkMode) "深色模式" else "浅色模式",
                        color = if (darkMode) Color.White else Color.Black,
                        fontSize = 18.sp
                    )
                }
            }
        }
    }
}

对比总结:

API 适用场景 动画类型 复杂度
AnimatedVisibility 组件出现/消失 入场/退场
AnimatedContent 内容切换(Tab、轮播) 自定义过渡
Crossfade 简单淡入淡出 交叉淡入淡出 极低

我曾经在图片加载库中用过 Crossfade。当图片从占位图切换到实际图片时,用 Crossfade 包裹,效果比直接替换好太多。用户几乎感觉不到「闪一下」的问题。

性能建议:这三个 API 都是 Compose 内置的,性能经过优化。但要注意,不要在 AnimatedVisibilityAnimatedContent 内部放太多重型组件。动画过程中,Compose 会频繁重组。如果内容太重,掉帧是必然的。

好了,关于这三个高级动画 API,咱们就聊到这儿。记住一个原则:能用声明式动画解决的问题,就别手写 Animator。Compose 的动画系统,说白了就是让你用更少的代码,做更好的效果。

公众号:蓝海资料掘金营,微信deep3321