/ JetpackCompose  Android动画  animateAsState  AnimatedVisibility  Transition  Kotlin  Android开发  UI动效 

Jetpack Compose 动画全攻略:从基础到手势驱动的流畅交互实战


封面

一、为什么选择 Jetpack Compose 动画?

在传统 Android 开发中,动画通常依赖 ObjectAnimatorValueAnimatorTransitionManager,代码量大且与视图系统耦合严重。Jetpack Compose 的声明式 UI 范式带来了全新的动画体系——开发者只需描述"状态应该是什么",框架负责计算并执行过渡动画。

  • 声明式:动画与状态绑定,状态变化自动驱动动画

  • 可组合:动画 API 与 Composable 函数天然融合

  • 高性能:基于协程与 Canvas 渲染,避免不必要的重组

  • 可中断:动画可随时响应新的状态变化并平滑过渡

本文将覆盖从入门到进阶的 Compose 动画 API,帮助你在实际项目中快速落地。

二、基础动画 API:animateAsState 系列

animateFloatAsStateanimateDpAsStateanimateColorAsState 等是最简单的动画入口。当目标值改变时,它们会自动执行补间动画:

@Composable
fun AnimatedBox() {
    var expanded by remember { mutableStateOf(false) }
    val size by animateDpAsState(
        targetValue = if (expanded) 200.dp else 80.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "box_size"
    )
    Box(
        modifier = Modifier
            .size(size)
            .background(Color(0xFF6200EE))
            .clickable { expanded = !expanded }
    )
}

通过 animationSpec 参数,你可以灵活选择缓动曲线:

  • spring():弹性动画,自然真实,推荐用于交互反馈

  • tween(durationMillis, easing):时间线动画,精确控制时长与曲线

  • keyframes {}:关键帧动画,支持多段不同速率的过渡

  • repeatable() / infiniteRepeatable():循环动画

三、可见性动画:AnimatedVisibility

AnimatedVisibility 是处理组件显示/隐藏的利器,内置多种进入/退出动效组合:

@Composable
fun NotificationBanner(show: Boolean, message: String) {
    AnimatedVisibility(
        visible = show,
        enter = slideInVertically(
            initialOffsetY = { -it },
            animationSpec = tween(400, easing = EaseOutCubic)
        ) + fadeIn(animationSpec = tween(400)),
        exit = slideOutVertically(
            targetOffsetY = { -it },
            animationSpec = tween(300)
        ) + fadeOut(animationSpec = tween(300))
    ) {
        Surface(
            color = Color(0xFF03DAC6),
            modifier = Modifier.fillMaxWidth().padding(8.dp)
        ) {
            Text(
                text = message,
                modifier = Modifier.padding(16.dp),
                color = Color.Black
            )
        }
    }
}

进入/退出效果可以通过 + 运算符自由组合,常用的有:

  • fadeIn / fadeOut:淡入淡出

  • slideInHorizontally / slideOutHorizontally:水平滑动

  • expandVertically / shrinkVertically:垂直展开/收起

  • scaleIn / scaleOut:缩放

四、多属性协同动画:updateTransition

当多个属性需要同步动画时,updateTransition 是首选方案。它创建一个与状态绑定的 Transition 对象,所有衍生动画共享同一时间轴:

enum class CardState { Collapsed, Expanded }

@Composable
fun AnimatedCard(cardState: CardState) {
    val transition = updateTransition(targetState = cardState, label = "card_transition")

    val cardHeight by transition.animateDp(
        label = "card_height",
        transitionSpec = { spring(stiffness = Spring.StiffnessMedium) }
    ) { state ->
        if (state == CardState.Expanded) 280.dp else 120.dp
    }

    val cornerRadius by transition.animateDp(
        label = "corner_radius",
        transitionSpec = { tween(300) }
    ) { state ->
        if (state == CardState.Expanded) 4.dp else 16.dp
    }

    val backgroundColor by transition.animateColor(
        label = "background_color",
        transitionSpec = { tween(400) }
    ) { state ->
        if (state == CardState.Expanded) Color(0xFF1565C0) else Color(0xFF42A5F5)
    }

    Card(
        modifier = Modifier.fillMaxWidth().height(cardHeight),
        shape = RoundedCornerShape(cornerRadius),
        colors = CardDefaults.cardColors(containerColor = backgroundColor)
    ) { /* 内容 */ }
}

使用 updateTransition 的好处在于:所有动画天然同步,状态中途变化时所有属性会协同过渡,不会出现不一致的中间状态。

五、无限循环动画:rememberInfiniteTransition

对于加载指示器、呼吸灯、脉冲效果等需要持续运行的动画,使用 rememberInfiniteTransition

@Composable
fun PulsingHeartIcon() {
    val infiniteTransition = rememberInfiniteTransition(label = "heart_pulse")
    val scale by infiniteTransition.animateFloat(
        initialValue = 1f,
        targetValue = 1.3f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = EaseInOutSine),
            repeatMode = RepeatMode.Reverse
        ),
        label = "heart_scale"
    )
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.7f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = EaseInOutSine),
            repeatMode = RepeatMode.Reverse
        ),
        label = "heart_alpha"
    )
    Icon(
        imageVector = Icons.Filled.Favorite,
        contentDescription = "Heart",
        tint = Color.Red.copy(alpha = alpha),
        modifier = Modifier.scale(scale).size(48.dp)
    )
}

@Composable
fun ShimmerEffect(modifier: Modifier = Modifier) {
    val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
    val shimmerOffset by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(1200, easing = LinearEasing)
        ),
        label = "shimmer_offset"
    )
    val brush = Brush.linearGradient(
        colors = listOf(Color(0xFFE0E0E0), Color(0xFFF5F5F5), Color(0xFFE0E0E0)),
        start = Offset(shimmerOffset * 1000f - 500f, 0f),
        end = Offset(shimmerOffset * 1000f, 0f)
    )
    Box(modifier = modifier.background(brush))
}

六、手势驱动动画:Animatable 与低级 API

当动画需要与手势直接交互(如拖拽弹回、滑动删除),Animatable 提供了最底层的控制能力:

@Composable
fun DraggableCard() {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > 200f) {
                                // 滑出屏幕
                                offsetX.animateTo(
                                    targetValue = if (offsetX.value > 0) 1000f else -1000f,
                                    animationSpec = tween(300)
                                )
                            } else {
                                // 弹回原位
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy
                                    )
                                )
                            }
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        scope.launch {
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
            .size(300.dp, 200.dp)
            .background(Color(0xFF6200EE), RoundedCornerShape(16.dp))
    )
}

AnimatablesnapTo 用于直接跳转(跟手),animateTo 用于弹性过渡(松手后)。两者结合可以实现完美的物理感交互。

七、性能优化:避免不必要的重组

Compose 动画的一个常见陷阱是触发过多重组,影响帧率。以下是关键优化技巧:

  • 使用 graphicsLayer 代替 offset/scale/alpha ModifiergraphicsLayer 修改在 Drawing 阶段执行,跳过 Layout 阶段,性能更好

  • Modifier 键:稳定化引用:避免在每次重组时创建新的 lambda,使用 remember 缓存

  • 状态读取下沉:将 animatedValue 读取操作放在尽可能底层的 Composable 内,减少重组范围

  • 避免在动画中触发快照写入:不要在 animationSpec 的过程中修改 State,容易造成动画循环

// ❌ 每次重组都会触发 Layout 计算
Box(modifier = Modifier.offset(x = animatedOffsetX.dp))

// ✅ 只在 Drawing 阶段修改,跳过 Layout
Box(modifier = Modifier.graphicsLayer {
    translationX = animatedOffsetX
})

八、实战案例:底部 Sheet 弹出动画

将上述知识融合,实现一个带有自定义动画的底部 Sheet 组件:

@Composable
fun AnimatedBottomSheet(
    visible: Boolean,
    onDismiss: () -> Unit,
    content: @Composable () -> Unit
) {
    val transition = updateTransition(targetState = visible, label = "sheet")
    val offsetY by transition.animateFloat(
        label = "sheet_offset",
        transitionSpec = {
            if (targetState) spring(dampingRatio = 0.8f, stiffness = 400f)
            else tween(250, easing = EaseInCubic)
        }
    ) { if (it) 0f else 1f }

    val scrimAlpha by transition.animateFloat(
        label = "scrim_alpha",
        transitionSpec = { tween(300) }
    ) { if (it) 0.5f else 0f }

    if (transition.currentState || transition.targetState) {
        Box(modifier = Modifier.fillMaxSize()) {
            // 遮罩层
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Black.copy(alpha = scrimAlpha))
                    .clickable { onDismiss() }
            )
            // Sheet 主体
            Surface(
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .fillMaxWidth()
                    .graphicsLayer { translationY = offsetY * 600f },
                shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
                shadowElevation = 16.dp
            ) {
                content()
            }
        }
    }
}

这个实现通过 updateTransition 同步驱动遮罩透明度和 Sheet 位移,进入和退出使用不同的 animationSpec,体验自然流畅。

九、总结与最佳实践

Jetpack Compose 的动画体系经过精心设计,从简单的属性动画到复杂的手势交互都有对应方案。选择建议:

  • 单属性简单动画 → animateXxxAsState

  • 组件显隐 → AnimatedVisibility

  • 多属性同步 → updateTransition

  • 无限循环 → rememberInfiniteTransition

  • 手势交互 → Animatable

  • 性能敏感 → 优先用 graphicsLayer,状态读取下沉

掌握这套体系后,你会发现在 Compose 中实现任何复杂动效都变得直观而愉快。建议结合 Android Studio 的 Animation Preview 工具进行调试,可视化地观察动画曲线和时序。祝你打造出令用户惊艳的动效体验!

发布评论

热门评论区: