[Android 从零到一] Compose 动画实战:从 AnimatedVisibility 到复杂转场与手势联动

为什么 Compose 动画值得单独写

在传统 View 体系下,Android 动画分散在 ObjectAnimator、TransitionManager、MotionLayout 三条线;而 Compose 用声明式 API 统一了全部动画场景:

  • 组件显隐动画(AnimatedVisibility)
  • 值动画(animateFloatAsState / animateDpAsState)
  • 布局变化动画(AnimatedContent)
  • 手势联动动画(Animatable + draggable)
  • 多属性协同动画(updateTransition)

本文聚焦 Compose 动画的工程化实战:从 API 选型、性能优化到复杂场景落地。

AnimatedVisibility:组件显隐的标准方案

最常用的动画场景:对话框弹出、Toast 浮现、列表项删除------都可以用 AnimatedVisibility 解决。

基础用法

kotlin 复制代码
@Composable
fun ToastDemo() {
    var visible by remember { mutableStateOf(false) }

    Column {
        Button(onClick = { visible = !visible }) {
            Text("Toggle Toast")
        }

        AnimatedVisibility(visible = visible) {
            Text(
                "这是一条消息",
                modifier = Modifier
                    .background(Color.Black.copy(alpha = 0.8f))
                    .padding(16.dp),
                color = Color.White
            )
        }
    }
}

自定义进入/退出动画

kotlin 复制代码
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
    exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
) {
    Text("从上方滑入")
}

列表项删除动画

kotlin 复制代码
@Composable
fun DeleteAnimationDemo() {
    var items by remember { mutableStateOf(listOf("A", "B", "C")) }

    LazyColumn {
        items(items, key = { it }) { item ->
            AnimatedVisibility(
                visible = true,
                exit = shrinkVertically() + fadeOut()
            ) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Text(item)
                    Button(onClick = { items = items - item }) {
                        Text("删除")
                    }
                }
            }
        }
    }
}

animateXxxAsState:值动画的声明式封装

当你需要让某个值从 A 平滑过渡到 B(颜色、尺寸、透明度),用 animateXxxAsState

尺寸动画

kotlin 复制代码
@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }
    val size by animateDpAsState(
        targetValue = if (expanded) 200.dp else 100.dp,
        label = "card_size"
    )

    Box(
        modifier = Modifier
            .size(size)
            .background(Color.Blue)
            .clickable { expanded = !expanded }
    )
}

颜色动画

kotlin 复制代码
@Composable
fun ColorButton() {
    var selected by remember { mutableStateOf(false) }
    val bgColor by animateColorAsState(
        targetValue = if (selected) Color.Blue else Color.Gray,
        label = "bg_color"
    )

    Button(
        onClick = { selected = !selected },
        colors = ButtonDefaults.buttonColors(containerColor = bgColor)
    ) {
        Text("点我变色")
    }
}

自定义动画曲线

kotlin 复制代码
val offset by animateDpAsState(
    targetValue = if (visible) 0.dp else 100.dp,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioMediumBouncy,
        stiffness = Spring.StiffnessLow
    ),
    label = "offset"
)

AnimatedContent:布局切换的完整动画

当内容本身变化时(如 Tab 切换、状态切换),AnimatedContent 会自动处理退场 + 入场动画。

Tab 切换动画

kotlin 复制代码
@Composable
fun TabSwitcher() {
    var selectedTab by remember { mutableStateOf(0) }

    Column {
        Row {
            TextButton(onClick = { selectedTab = 0 }) { Text("首页") }
            TextButton(onClick = { selectedTab = 1 }) { Text("我的") }
        }

        AnimatedContent(
            targetState = selectedTab,
            transitionSpec = {
                slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
            },
            label = "tab_switch"
        ) { tab ->
            when (tab) {
                0 -> Text("首页内容", Modifier.fillMaxSize())
                1 -> Text("我的内容", Modifier.fillMaxSize())
            }
        }
    }
}

手势联动动画:Animatable + draggable

当动画需要跟随手指拖拽时,Animatable + Modifier.draggable 是标准组合。

可拖拽的卡片

kotlin 复制代码
@Composable
fun DraggableCard() {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .size(100.dp)
            .background(Color.Red)
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    scope.launch { offsetX.snapTo(offsetX.value + delta) }
                },
                onDragStopped = {
                    scope.launch {
                        offsetX.animateTo(
                            targetValue = 0f,
                            animationSpec = spring()
                        )
                    }
                }
            )
    )
}

updateTransition:多属性协同动画

当一个状态变化需要同时驱动多个动画(颜色 + 尺寸 + 透明度),用 updateTransition

kotlin 复制代码
@Composable
fun MultiPropertyButton() {
    var pressed by remember { mutableStateOf(false) }
    val transition = updateTransition(targetState = pressed, label = "button")

    val scale by transition.animateFloat(label = "scale") { state ->
        if (state) 0.9f else 1f
    }
    val alpha by transition.animateFloat(label = "alpha") { state ->
        if (state) 0.7f else 1f
    }

    Box(
        modifier = Modifier
            .size(100.dp)
            .scale(scale)
            .alpha(alpha)
            .background(Color.Blue)
            .clickable { pressed = !pressed }
    )
}

常见坑点与性能优化

坑点 表现 解决方案
动画过程中频繁重组 卡顿、掉帧 Modifier.graphicsLayer 替代 Modifier.offset / Modifier.scale
AnimatedVisibility 内容未被回收 内存泄漏 确保 visible = false 时内容被完全移除
手势动画抖动 拖拽不跟手 Animatable.snapTo 而非 animateTo
复杂动画无法中断 用户操作被阻塞 所有协程动画用 launch 启动,而非 async

性能优化:graphicsLayer

kotlin 复制代码
// ❌ 每帧触发重组
Box(Modifier.offset { IntOffset(x.roundToInt(), 0) })

// ✅ 在合成层完成,不触发重组
Box(Modifier.graphicsLayer { translationX = x })

实战:下拉刷新动画

kotlin 复制代码
@Composable
fun PullToRefresh(onRefresh: () -> Unit, content: @Composable () -> Unit) {
    val pullOffset = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    var refreshing by remember { mutableStateOf(false) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .draggable(
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    if (!refreshing && delta > 0) {
                        scope.launch { pullOffset.snapTo((pullOffset.value + delta).coerceAtMost(200f)) }
                    }
                },
                onDragStopped = {
                    if (pullOffset.value > 100f) {
                        refreshing = true
                        onRefresh()
                        scope.launch {
                            pullOffset.animateTo(50f)
                            kotlinx.coroutines.delay(1000) // 模拟加载
                            refreshing = false
                            pullOffset.animateTo(0f)
                        }
                    } else {
                        scope.launch { pullOffset.animateTo(0f) }
                    }
                }
            )
    ) {
        Column(Modifier.offset { IntOffset(0, pullOffset.value.roundToInt()) }) {
            if (pullOffset.value > 0) {
                Box(
                    Modifier
                        .fillMaxWidth()
                        .height((pullOffset.value / 2).dp),
                    contentAlignment = Alignment.Center
                ) {
                    if (refreshing) {
                        CircularProgressIndicator(Modifier.size(24.dp))
                    } else {
                        Text("下拉刷新", fontSize = 12.sp)
                    }
                }
            }
            content()
        }
    }
}

总结

  • 显隐动画 → AnimatedVisibility
  • 值动画 → animateXxxAsState
  • 布局切换 → AnimatedContent
  • 手势联动 → Animatable + draggable
  • 多属性协同 → updateTransition
  • 性能优化 → graphicsLayer 替代 offset/scale

Compose 动画的核心思路是状态驱动:只需声明目标状态,框架自动处理过渡。这种模式让动画代码更清晰、更可维护。

相关推荐
Knight_AL1 小时前
Lombok @Builder 踩坑:build() 前后对象类型不一样
android·java·开发语言
大黄说说2 小时前
Android 本地存储深度对比:SharedPreferences、MMKV、Room 数据库怎么选?
android·数据库
阿巴斯甜3 小时前
adb push .../AdapterService.apk 和 .../区别:
android
恋猫de小郭3 小时前
Flutter 的另外一种形态?社区 DartNative 要来了。
android·前端·flutter
我命由我123453 小时前
Android Drawable - gradient
android·java·java-ee·kotlin·android studio·android-studio·android runtime
Kapaseker3 小时前
AI 时代,读源码还有用吗?从 Kotlin 集合排序说起
android·kotlin
️学习的小王5 小时前
二级MySQL PHP综合应用题完整考点+历年真题风格练习题
android·mysql·php
YB137516 小时前
jetpack compose 副作用 SideEffect
android·kotlin
CHB18 小时前
uni-app x 蒸汽模式 性能测试基准报告 Benchmark【Android版】
android·ios·uni-app