/ Jetpack Compose  Android动画  AnimatedVisibility  Transition  协程  Material Design  Kotlin  UI开发 

Jetpack Compose 动画全攻略:从基础到高级的丝滑体验实战


封面

为什么动画对 Android 应用至关重要

在移动端竞争日趋激烈的今天,流畅的动画体验已经成为区分普通应用与优秀应用的关键因素。研究表明,合理的过渡动效不仅能提升用户的感知流畅度,还能有效引导用户注意力,降低认知负担。Jetpack Compose 的动画系统在设计之初就将"声明式"与"可组合"作为核心理念,让动画开发变得前所未有的简单。

传统 View 体系中,开发者需要手动管理 ObjectAnimator、ValueAnimator、AnimatorSet 等对象的生命周期,代码冗长且容易出现内存泄漏。Compose 彻底改变了这一局面——动画状态与 UI 状态合为一体,框架负责处理所有底层插值计算。

AnimatedVisibility:最常用的显隐动画

AnimatedVisibility 是 Compose 中最直观的动画组件,用于控制内容的显示与隐藏。它内置了 EnterTransition 和 ExitTransition 两套动画配置,支持淡入淡出、滑动、缩放等多种效果的自由组合。

@Composable
fun ExpandableCard(content: String) {
    var expanded by remember { mutableStateOf(false) }

    Column(modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .clickable { expanded = !expanded }
    ) {
        Text(text = "点击展开/收起", style = MaterialTheme.typography.titleMedium)

        AnimatedVisibility(
            visible = expanded,
            enter = fadeIn() + expandVertically(expandFrom = Alignment.Top),
            exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top)
        ) {
            Text(
                text = content,
                modifier = Modifier.padding(top = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}

上面的例子展示了几个关键点:

  • enter/exit 参数支持组合:用 + 运算符叠加多个过渡效果

  • 方向感知expandFromshrinkTowards 让动画方向与内容逻辑一致

  • 自动管理生命周期:visible 状态切换时,Compose 自动播放对应动画

animateContentSize:自适应尺寸变化

当容器尺寸随内容变化时,animateContentSize 修饰符能让这一过程平滑过渡,无需任何额外配置。

@Composable
fun ExpandableText(text: String, maxLines: Int = 3) {
    var isExpanded by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .animateContentSize(
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioMediumBouncy,
                    stiffness = Spring.StiffnessLow
                )
            )
            .clickable { isExpanded = !isExpanded }
    ) {
        Text(
            text = text,
            maxLines = if (isExpanded) Int.MAX_VALUE else maxLines,
            overflow = if (isExpanded) TextOverflow.Clip else TextOverflow.Ellipsis
        )
        Text(
            text = if (isExpanded) "收起" else "展开更多",
            color = MaterialTheme.colorScheme.primary,
            style = MaterialTheme.typography.labelSmall
        )
    }
}

animationSpec 参数让你精细控制动画曲线。Spring(弹簧动画)是 Compose 的默认动画规格,它模拟物理弹簧的运动规律,比传统的贝塞尔曲线更自然。常用参数:

  • dampingRatio:阻尼系数,值越小弹性越强

  • stiffness:劲度系数,值越大动画越快

Transition API:多属性协同动画

当需要同时为多个属性(颜色、尺寸、透明度等)添加动画,并保证它们同步播放时,updateTransition 是最佳选择。

enum class CardState { Collapsed, Expanded }

@Composable
fun AnimatedCard(initialState: CardState = CardState.Collapsed) {
    var currentState by remember { mutableStateOf(initialState) }
    val transition = updateTransition(currentState, label = "card_transition")

    val cardHeight by transition.animateDp(
        transitionSpec = { spring(stiffness = Spring.StiffnessMedium) },
        label = "card_height"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 80.dp
            CardState.Expanded -> 240.dp
        }
    }

    val cardColor by transition.animateColor(
        transitionSpec = { tween(durationMillis = 300) },
        label = "card_color"
    ) { state ->
        when (state) {
            CardState.Collapsed -> MaterialTheme.colorScheme.surface
            CardState.Expanded -> MaterialTheme.colorScheme.primaryContainer
        }
    }

    val iconRotation by transition.animateFloat(
        transitionSpec = { tween(durationMillis = 300) },
        label = "icon_rotation"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 0f
            CardState.Expanded -> 180f
        }
    }

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(cardHeight)
            .clickable {
                currentState = if (currentState == CardState.Collapsed)
                    CardState.Expanded else CardState.Collapsed
            },
        colors = CardDefaults.cardColors(containerColor = cardColor)
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("多属性协同动画", Modifier.weight(1f))
            Icon(
                imageVector = Icons.Default.ExpandMore,
                contentDescription = null,
                modifier = Modifier.rotate(iconRotation)
            )
        }
    }
}

Transition API 的核心优势:

  • 语义清晰:每个动画属性都有独立的 label,便于 Android Studio 动画预览

  • 天然同步:所有属性在同一个 transition 下自动协调

  • 状态驱动:通过枚举状态机管理动画逻辑,代码可维护性极高

infiniteTransition:循环动画与加载态

加载状态、心跳效果、呼吸灯等需要无限循环的场景,使用 rememberInfiniteTransition 最为合适。

@Composable
fun PulsingDot(color: Color = MaterialTheme.colorScheme.primary) {
    val infiniteTransition = rememberInfiniteTransition(label = "pulsing")

    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(800, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )

    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.4f,
        targetValue = 1.0f,
        animationSpec = infiniteRepeatable(
            animation = tween(800, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "alpha"
    )

    Box(
        modifier = Modifier
            .size(24.dp)
            .scale(scale)
            .alpha(alpha)
            .background(color, CircleShape)
    )
}

@Composable
fun ShimmerEffect() {
    val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
    val shimmerOffset by infiniteTransition.animateFloat(
        initialValue = -300f,
        targetValue = 300f,
        animationSpec = infiniteRepeatable(
            animation = tween(1200, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ),
        label = "shimmer_offset"
    )

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(80.dp)
            .background(
                brush = Brush.linearGradient(
                    colors = listOf(
                        Color.LightGray.copy(alpha = 0.6f),
                        Color.White.copy(alpha = 0.9f),
                        Color.LightGray.copy(alpha = 0.6f)
                    ),
                    start = Offset(shimmerOffset, 0f),
                    end = Offset(shimmerOffset + 300f, 0f)
                ),
                shape = RoundedCornerShape(8.dp)
            )
    )
}

手势驱动动画:Animatable 与 Drag

真正丝滑的动画往往需要与手势紧密结合。Animatable 提供了命令式的动画控制能力,配合 pointerInput 可以实现拖拽跟随、回弹等交互效果。

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

    Card(
        modifier = Modifier
            .size(120.dp)
            .offset { IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt()) }
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragEnd = {
                        scope.launch {
                            // 使用弹簧动画回弹到原点
                            launch { offsetX.animateTo(0f, spring(dampingRatio = 0.5f)) }
                            launch { offsetY.animateTo(0f, spring(dampingRatio = 0.5f)) }
                        }
                    }
                ) { change, dragAmount ->
                    change.consume()
                    scope.launch {
                        offsetX.snapTo(offsetX.value + dragAmount.x)
                        offsetY.snapTo(offsetY.value + dragAmount.y)
                    }
                }
            }
    ) {
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            Text("拖我试试")
        }
    }
}

Animatable 与 Transition/AnimatedVisibility 的核心区别在于:它是命令式 API,通过协程的 suspend 函数直接控制动画的播放、停止和中断,更适合需要精细时序控制的场景。

动画性能优化最佳实践

动画虽好,但如果实现不当会严重影响帧率。以下是 Compose 动画开发中必须掌握的性能优化要点:

  • 优先使用 Modifier 层动画graphicsLayeralphascaleoffset 等修饰符的动画在 RenderThread 上执行,不触发重组,性能最佳

  • 避免在动画中触发布局:尺寸变化(如 animateContentSize)会触发重新测量布局,成本较高,应谨慎使用

  • 使用 derivedStateOf 减少重组:当动画值需要经过复杂计算才能用于 UI 时,用 derivedStateOf 缓存中间结果

  • 为 Transition 添加 label:便于 Android Studio Layout Inspector 中的动画调试工具识别

  • 在低端设备上降级:通过 LocalConfiguration 检测设备性能,在低配设备上禁用复杂动画

// 性能友好的透明度动画 - 在 RenderThread 执行
Box(
    modifier = Modifier.graphicsLayer {
        alpha = animatedAlpha
        scaleX = animatedScale
        scaleY = animatedScale
    }
)

// 避免在频繁更新的动画中读取复杂状态
val displayText by remember {
    derivedStateOf {
        "进度: ${(progress * 100).toInt()}%"
    }
}

掌握这些动画技巧,你的 Compose 应用将具备令用户眼前一亮的流畅体验。动画不只是"好看",更是产品品质的直接体现。从今天起,让每一个状态切换都成为一次愉悦的视觉旅程。

发布评论

热门评论区: