/ Android  Jetpack Compose  动画  SharedElement  Spring动画  AnimatedContent  性能优化  Material Design 

Jetpack Compose高级动画实战:从物理动画到共享元素过渡


封面

Jetpack Compose 的动画系统是 Android UI 开发中最令人兴奋的特性之一。随着 Compose 逐渐成为 Android 开发的主流方案,掌握其高级动画技术已成为提升应用品质的关键。本文将带你深入了解 Compose 动画系统的进阶用法,从状态过渡到物理动画,全面提升你的动画开发能力。

一、AnimatedContent:优雅处理内容切换

AnimatedContent 是 Compose 中处理内容切换动画的利器。与简单的 AnimatedVisibility 相比,它能够在不同内容之间实现平滑的过渡效果,特别适合处理数据加载状态、Tab 切换等场景。

基本用法示例:

@Composable
fun ContentSwitcher(targetState: UiState) {
    AnimatedContent(
        targetState = targetState,
        transitionSpec = {
            when {
                targetState is UiState.Success && initialState is UiState.Loading ->
                    fadeIn(tween(300)) + slideInVertically { it / 2 } togetherWith
                    fadeOut(tween(150))
                else ->
                    fadeIn(tween(200)) togetherWith fadeOut(tween(200))
            }
        },
        label = "ContentTransition"
    ) { state ->
        when (state) {
            is UiState.Loading -> LoadingScreen()
            is UiState.Success -> SuccessContent(state.data)
            is UiState.Error -> ErrorScreen(state.message)
        }
    }
}

关键技巧:

  • 使用 transitionSpec 针对不同状态切换定制不同动画

  • togetherWith(原 with)用于组合进入和退出动画

  • 始终设置 label 参数,便于 Animation Inspector 调试

  • 利用 SizeTransform 控制内容大小变化的动画方式

二、共享元素过渡:跨页面的视觉连续性

Compose 1.7 正式引入了稳定的共享元素过渡 API(SharedTransitionLayout),让列表到详情的过渡动画变得前所未有的简单。

@Composable
fun ListScreen(
    items: List,
    onItemClick: (Item) -> Unit,
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
    LazyColumn {
        items(items) { item ->
            with(sharedTransitionScope) {
                Row(
                    modifier = Modifier
                        .clickable { onItemClick(item) }
                        .sharedBounds(
                            rememberSharedContentState(key = "container-${item.id}"),
                            animatedVisibilityScope = animatedVisibilityScope,
                            resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds()
                        )
                ) {
                    Image(
                        painter = rememberAsyncImagePainter(item.imageUrl),
                        contentDescription = null,
                        modifier = Modifier.sharedElement(
                            rememberSharedContentState(key = "image-${item.id}"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                    )
                    Text(
                        text = item.title,
                        modifier = Modifier.sharedBounds(
                            rememberSharedContentState(key = "title-${item.id}"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                    )
                }
            }
        }
    }
}

实践要点:

  • sharedElement 用于完全相同的元素(如图片),sharedBounds 用于可变大小的容器

  • 使用 NavHost 时,通过 rememberAnimatedNavController 传递动画作用域

  • 为复杂列表设置 skipToLookaheadSize 避免布局抖动

  • 对文本使用 OverlayClip 防止过渡期间内容溢出

三、基于物理的动画:Spring 与 Decay

物理动画能让 UI 交互感觉更加自然真实。Compose 提供了 spring()exponentialDecay() 两种主要的物理动画规格。

// Spring 动画 - 模拟弹簧物理
val springSpec = spring(
    dampingRatio = Spring.DampingRatioMediumBouncy,  // 弹性系数
    stiffness = Spring.StiffnessMedium,              // 刚度
    visibilityThreshold = 0.001f
)

// 在手势释放后使用 spring 恢复
@Composable
fun DraggableCard() {
    val offsetX = remember { Animatable(0f) }
    
    Box(
        modifier = Modifier.draggable(
            orientation = Orientation.Horizontal,
            state = rememberDraggableState { delta ->
                LaunchedEffect(delta) {
                    offsetX.snapTo(offsetX.value + delta)
                }
            },
            onDragStopped = { velocity ->
                launch {
                    // 根据速度决定是回弹还是飞出
                    if (abs(offsetX.value) > 200f) {
                        offsetX.animateTo(
                            targetValue = if (offsetX.value > 0) 1000f else -1000f,
                            animationSpec = tween(300, easing = FastOutLinearInEasing)
                        )
                    } else {
                        offsetX.animateTo(
                            targetValue = 0f,
                            animationSpec = spring(
                                dampingRatio = Spring.DampingRatioMediumBouncy,
                                stiffness = Spring.StiffnessMedium
                            ),
                            initialVelocity = velocity
                        )
                    }
                }
            }
        )
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
    )
}

Spring 参数调优建议:

  • DampingRatioNoBouncy (1.0):用于功能性动画,无弹跳

  • DampingRatioLowBouncy (0.75):轻微弹跳,适合卡片展开

  • DampingRatioMediumBouncy (0.5):明显弹跳,适合游戏类交互

  • DampingRatioHighBouncy (0.2):强烈弹跳,谨慎使用

四、自定义 Easing 与 AnimationSpec

标准的缓动函数往往无法满足特定设计需求,Compose 支持通过 CubicBezierEasing 自定义缓动曲线,以及通过 KeyframesSpec 创建关键帧动画。

// 自定义贝塞尔缓动
val emphasizedEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)  // Material You emphasized
val emphasizedDecelerateEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)

// 关键帧动画 - 精确控制动画时间线
val bounceSpec = keyframes{
    durationMillis = 1000
    0f at 0 with FastOutSlowInEasing
    1.2f at 600 with FastOutSlowInEasing  // 超出目标值产生弹跳效果
    0.9f at 750
    1.05f at 850
    1.0f at 1000
}

// 组合多个动画规格
@Composable
fun AnimatedButton(onClick: () -> Unit) {
    val scale = remember { Animatable(1f) }
    val coroutineScope = rememberCoroutineScope()
    
    Box(
        modifier = Modifier
            .scale(scale.value)
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null
            ) {
                coroutineScope.launch {
                    scale.animateTo(0.9f, tween(100))
                    scale.animateTo(1f, spring(
                        dampingRatio = Spring.DampingRatioMediumBouncy
                    ))
                }
                onClick()
            }
    )
}

KeyframesSpec 进阶技巧:

  • 使用 at infix 函数指定关键帧时间点

  • 使用 with infix 函数为每段指定缓动

  • atFraction 用于按比例而非绝对时间定义关键帧

  • 结合 repeatable 创建循环关键帧动画

五、Transition API:多属性协同动画

updateTransition 允许你将多个相关动画属性绑定到同一个状态机,确保它们同步变化,避免各自独立动画导致的视觉不协调。

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatedBox() {
    var currentState by remember { mutableStateOf(BoxState.Collapsed) }
    val transition = updateTransition(currentState, label = "BoxTransition")
    
    val size by transition.animateDp(
        transitionSpec = {
            when {
                BoxState.Collapsed isTransitioningTo BoxState.Expanded ->
                    spring(stiffness = Spring.StiffnessMediumLow)
                else -> tween(200)
            }
        },
        label = "BoxSize"
    ) { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 300.dp
        }
    }
    
    val color by transition.animateColor(
        transitionSpec = { tween(300) },
        label = "BoxColor"
    ) { state ->
        when (state) {
            BoxState.Collapsed -> MaterialTheme.colorScheme.primaryContainer
            BoxState.Expanded -> MaterialTheme.colorScheme.tertiaryContainer
        }
    }
    
    val cornerRadius by transition.animateDp(
        transitionSpec = { tween(300) },
        label = "CornerRadius"
    ) { state ->
        when (state) {
            BoxState.Collapsed -> 50.dp
            BoxState.Expanded -> 16.dp
        }
    }
    
    Box(
        modifier = Modifier
            .size(size)
            .background(color, RoundedCornerShape(cornerRadius))
            .clickable {
                currentState = when (currentState) {
                    BoxState.Collapsed -> BoxState.Expanded
                    BoxState.Expanded -> BoxState.Collapsed
                }
            }
    )
}

六、Infinite Transition:持续循环动画

加载指示器、脉冲效果、背景渐变等需要持续播放的动画,使用 rememberInfiniteTransition 是最优雅的方案。

@Composable
fun PulsingDot() {
    val infiniteTransition = rememberInfiniteTransition(label = "PulseTransition")
    
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(800, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "PulseScale"
    )
    
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.4f,
        targetValue = 1.0f,
        animationSpec = infiniteRepeatable(
            animation = tween(800, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "PulseAlpha"
    )
    
    Box(
        modifier = Modifier
            .size(20.dp)
            .scale(scale)
            .alpha(alpha)
            .background(MaterialTheme.colorScheme.primary, CircleShape)
    )
}

// Shimmer 骨架屏效果
@Composable
fun ShimmerEffect(modifier: Modifier = Modifier) {
    val infiniteTransition = rememberInfiniteTransition(label = "Shimmer")
    val shimmerTranslate by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(1200, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ),
        label = "ShimmerTranslate"
    )
    
    val shimmerBrush = Brush.linearGradient(
        colors = listOf(
            Color.LightGray.copy(alpha = 0.6f),
            Color.LightGray.copy(alpha = 0.2f),
            Color.LightGray.copy(alpha = 0.6f)
        ),
        start = Offset(shimmerTranslate - 300f, 0f),
        end = Offset(shimmerTranslate, 0f)
    )
    
    Box(modifier = modifier.background(shimmerBrush))
}

七、动画性能优化:避免重组陷阱

动画性能问题往往源于不必要的重组(Recomposition)。以下是几个关键的优化策略:

使用 derivedStateOf 减少重组:

// ❌ 错误:每次滚动都触发重组
@Composable
fun HeaderWithAnimation(listState: LazyListState) {
    val showHeader = listState.firstVisibleItemIndex > 0
    AnimatedVisibility(visible = showHeader) { Header() }
}

// ✅ 正确:通过 derivedStateOf 控制重组粒度
@Composable
fun HeaderWithAnimation(listState: LazyListState) {
    val showHeader by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }
    AnimatedVisibility(visible = showHeader) { Header() }
}

使用 graphicsLayer 实现 GPU 加速:

// ✅ graphicsLayer 修改在 Drawing 阶段处理,不触发布局重组
Box(
    modifier = Modifier.graphicsLayer {
        alpha = animatedAlpha
        scaleX = animatedScale
        scaleY = animatedScale
        translationY = animatedOffset
    }
)

其他优化建议:

  • 优先使用 Modifier.graphicsLayer 替代直接修改 alphascale

  • 将动画读取操作(如 animatable.value)下移到 graphicsLayerDrawModifier 的 lambda 中,避免触发父组件重组

  • 使用 Layout 阶段的 Modifier.layout 处理位置动画,而非 Modifier.offset

  • 对长列表中的动画元素,考虑使用 key() 稳定重组标识

  • 通过 Android Studio 的 Layout Inspector 和 Compose Animation Preview 分析动画性能

八、实战案例:打造流畅的底部导航栏动画

综合运用上述技术,实现一个带有指示器滑动、图标缩放和标签淡入淡出效果的底部导航栏:

@Composable
fun AnimatedBottomNavigation(
    items: List,
    selectedIndex: Int,
    onItemSelected: (Int) -> Unit
) {
    val indicatorOffset = remember { Animatable(0f) }
    val itemWidth = remember { mutableStateOf(0f) }
    
    LaunchedEffect(selectedIndex, itemWidth.value) {
        indicatorOffset.animateTo(
            targetValue = selectedIndex * itemWidth.value,
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioNoBouncy,
                stiffness = Spring.StiffnessMedium
            )
        )
    }
    
    Box {
        // 滑动指示器
        Box(
            modifier = Modifier
                .width(itemWidth.value.dp)
                .height(3.dp)
                .offset(x = indicatorOffset.value.dp)
                .background(
                    MaterialTheme.colorScheme.primary,
                    RoundedCornerShape(bottomStart = 2.dp, bottomEnd = 2.dp)
                )
                .align(Alignment.TopStart)
        )
        
        Row(modifier = Modifier.fillMaxWidth()) {
            items.forEachIndexed { index, item ->
                val isSelected = index == selectedIndex
                val transition = updateTransition(isSelected, label = "NavItem$index")
                
                val iconScale by transition.animateFloat(
                    transitionSpec = { spring(stiffness = Spring.StiffnessMedium) },
                    label = "IconScale"
                ) { selected -> if (selected) 1.1f else 1.0f }
                
                val labelAlpha by transition.animateFloat(
                    transitionSpec = { tween(200) },
                    label = "LabelAlpha"
                ) { selected -> if (selected) 1f else 0f }
                
                Column(
                    modifier = Modifier
                        .weight(1f)
                        .onGloballyPositioned { itemWidth.value = it.size.width.toFloat() }
                        .clickable { onItemSelected(index) },
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Icon(
                        imageVector = if (isSelected) item.selectedIcon else item.unselectedIcon,
                        contentDescription = item.label,
                        modifier = Modifier.scale(iconScale)
                    )
                    Text(
                        text = item.label,
                        modifier = Modifier.alpha(labelAlpha),
                        style = MaterialTheme.typography.labelSmall
                    )
                }
            }
        }
    }
}

通过以上八个方面的深入学习,你应该已经掌握了 Jetpack Compose 动画系统的进阶用法。动画不仅仅是视觉效果,更是提升用户体验、传达应用状态的重要手段。建议在实际项目中从简单的 AnimatedVisibility 开始,逐步引入更复杂的动画技术,始终以用户体验为导向,避免为动画而动画。

发布评论

热门评论区: