Jetpack Compose 从入门到精通(五):动画与交互

130 阅读9分钟

深入理解 Compose 动画系统架构,掌握属性动画、过渡动画、手势处理的完整实现方案,以及自定义动画的设计思路。


前言

动画是提升用户体验的重要手段。好的动画能让界面更流畅、更自然,帮助用户理解界面变化。

Compose 的动画系统相比传统 View 系统有质的飞跃:

  • 声明式:用代码描述动画目标状态,系统自动处理过渡
  • 可组合:动画可以像组件一样组合使用
  • 高性能:基于底层渲染优化,60fps 流畅运行

本篇文章将深入讲解:

  • Compose 动画系统的架构设计
  • 属性动画与过渡动画的实现原理
  • 手势检测与处理的完整方案
  • 自定义动画的设计思路

一、Compose 动画系统架构

1.1 动画系统的层次结构

Compose 动画系统分为三层:

┌─────────────────────────────────────────────────────────────────┐
│                    Compose 动画系统架构                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  高层 API( declarative )                                │   │
│  │  ├── animate*AsState(属性动画)                          │   │
│  │  ├── AnimatedVisibility(可见性动画)                     │   │
│  │  ├── AnimatedContent(内容切换动画)                      │   │
│  │  └── Crossfade(淡入淡出)                                │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              ↓                                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  中层 API( imperative )                                 │   │
│  │  ├── Animatable(可控动画)                               │   │
│  │  ├── animateDecay(衰减动画)                             │   │
│  │  └── Transition(状态过渡)                               │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              ↓                                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  底层 API(底层控制)                                     │   │
│  │  ├── Animation(动画核心)                                │   │
│  │  ├── AnimationVector(动画向量)                          │   │
│  │  └── AnimationSpec(动画规格)                            │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

1.2 动画的核心概念

1.2.1 AnimationSpec:动画规格

AnimationSpec 定义了动画的执行方式:

// 1. tween:补间动画,指定时长和缓动曲线
val tweenSpec = tween<Float>(
    durationMillis = 300,
    easing = FastOutSlowInEasing
)

// 2. spring:弹簧动画,物理模拟
val springSpec = spring<Float>(
    dampingRatio = Spring.DampingRatioMediumBouncy,
    stiffness = Spring.StiffnessLow
)

// 3. keyframes:关键帧动画
val keyframesSpec = keyframes<Float> {
    durationMillis = 900
    0f at 0
    0.4f at 300
    0.4f at 600
    1f at 900
}

// 4. repeatable:重复动画
val repeatableSpec = repeatable<Float>(
    iterations = 3,
    animation = tween(100),
    repeatMode = RepeatMode.Reverse
)

// 5. infiniteRepeatable:无限重复
val infiniteSpec = infiniteRepeatable<Float>(
    animation = tween(1000),
    repeatMode = RepeatMode.Reverse
)

1.2.2 Easing:缓动曲线

缓动曲线决定了动画的速度变化:

┌─────────────────────────────────────────────────────────────────┐
│                      缓动曲线对比                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Linear(线性)                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                    匀速运动                               │   │
│  │  速度: ████████████████████████████████████████████████  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  FastOutSlowIn(先快后慢)                                      │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                    自然减速                               │   │
│  │  速度: ████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  FastOutLinearIn(先快后匀速)                                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                    快速启动                               │   │
│  │  速度: █████████████████████████████████████░░░░░░░░░░░  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  LinearOutSlowIn(先匀速后慢)                                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                    缓慢停止                               │   │
│  │  速度: ░░░░░░░░░░░░░░░░░░░░████████████████████████████  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Spring(弹簧)                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                    物理模拟                               │   │
│  │  速度: ████████████░░░███░░░██░░░█░░░█░░                 │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

二、属性动画(animate*AsState)

2.1 什么是属性动画?

属性动画是最常用的动画 API,用于将某个属性的变化过程动画化。

2.2 animate*AsState 系列函数

// 1. 动画化 Float 值
val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    animationSpec = tween(300)
)

// 2. 动画化 Dp 值
val size by animateDpAsState(
    targetValue = if (expanded) 200.dp else 100.dp,
    animationSpec = spring()
)

// 3. 动画化 Offset
val offset by animateOffsetAsState(
    targetValue = if (moved) Offset(100f, 100f) else Offset.Zero,
    animationSpec = tween(500)
)

// 4. 动画化 Color
val color by animateColorAsState(
    targetValue = if (selected) Color.Blue else Color.Gray,
    animationSpec = tween(200)
)

// 5. 动画化任何类型(需要指定 TwoWayConverter)
val rect by animateValueAsState(
    targetValue = if (expanded) Rect(0f, 0f, 200f, 200f) else Rect(0f, 0f, 100f, 100f),
    typeConverter = Rect.VectorConverter,
    animationSpec = tween(300)
)

2.3 实战:可展开卡片

@Composable
fun ExpandableCard(
    title: String,
    content: String
) {
    var expanded by remember { mutableStateOf(false) }
    
    // 动画化高度
    val cardHeight by animateDpAsState(
        targetValue = if (expanded) 200.dp else 80.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "cardHeight"
    )
    
    // 动画化旋转角度
    val rotation by animateFloatAsState(
        targetValue = if (expanded) 180f else 0f,
        animationSpec = tween(300),
        label = "rotation"
    )
    
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(cardHeight)
            .clickable { expanded = !expanded },
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant
        )
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = title,
                    style = MaterialTheme.typography.titleMedium
                )
                
                Icon(
                    imageVector = Icons.Default.ExpandMore,
                    contentDescription = if (expanded) "收起" else "展开",
                    modifier = Modifier.rotate(rotation)
                )
            }
            
            AnimatedVisibility(visible = expanded) {
                Text(
                    text = content,
                    style = MaterialTheme.typography.bodyMedium,
                    modifier = Modifier.padding(top = 16.dp)
                )
            }
        }
    }
}

2.4 animate*AsState 的源码解析

// animateFloatAsState 简化实现
@Composable
fun animateFloatAsState(
    targetValue: Float,
    animationSpec: AnimationSpec<Float> = defaultAnimationSpec,
    label: String = "FloatAnimation"
): State<Float> {
    // 创建 Animatable 保存动画状态
    val animatable = remember { Animatable(targetValue) }
    
    // 当 targetValue 变化时启动动画
    LaunchedEffect(targetValue) {
        animatable.animateTo(
            targetValue = targetValue,
            animationSpec = animationSpec
        )
    }
    
    return animatable.asState()
}

执行流程

1. 首次组合
   ├── 创建 Animatable,初始值为 targetValue
   ├── 返回当前值
   
2. targetValue 变化
   ├── LaunchedEffect 检测到变化
   ├── 启动动画,从当前值过渡到新值
   ├── 每帧更新 Animatable 的值
   ├── 触发重组,UI 更新
   
3. 动画完成
   ├── Animatable 的值稳定在 targetValue
   ├── 停止更新

三、过渡动画

3.1 AnimatedVisibility:可见性动画

AnimatedVisibility 用于控制组件的显示/隐藏动画。

3.1.1 基本用法

var visible by remember { mutableStateOf(true) }

AnimatedVisibility(
    visible = visible,
    enter = fadeIn() + expandVertically(),
    exit = fadeOut() + shrinkVertically()
) {
    Text("Hello, World!")
}

3.1.2 内置动画效果

// 进入动画
fadeIn(animationSpec = tween(300))           // 淡入
slideInHorizontally { it }                   // 水平滑入
slideInVertically { it }                     // 垂直滑入
expandIn(expandFrom = Alignment.TopStart)    // 扩展进入
expandHorizontally()                         // 水平扩展
expandVertically()                           // 垂直扩展
scaleIn(initialScale = 0.5f)                 // 缩放进入

// 退出动画
fadeOut(animationSpec = tween(300))          // 淡出
slideOutHorizontally { it }                  // 水平滑出
slideOutVertically { it }                    // 垂直滑出
shrinkOut(shrinkTowards = Alignment.TopStart)// 收缩退出
shrinkHorizontally()                         // 水平收缩
shrinkVertically()                           // 垂直收缩
scaleOut(targetScale = 0.5f)                 // 缩放退出

// 组合动画
enter = fadeIn() + slideInVertically()       // 淡入 + 上滑
exit = fadeOut() + slideOutVertically()      // 淡出 + 下滑

3.1.3 实战:列表项删除动画

@Composable
fun DeletableListItem(
    item: String,
    onDelete: () -> Unit
) {
    var visible by remember { mutableStateOf(true) }
    
    AnimatedVisibility(
        visible = visible,
        enter = fadeIn() + expandVertically(),
        exit = fadeOut() + shrinkVertically(
            animationSpec = tween(300),
            shrinkTowards = Alignment.Top
        )
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp)
        ) {
            Row(
                modifier = Modifier.padding(16.dp),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(item)
                IconButton(
                    onClick = {
                        visible = false  // 触发动画
                        // 动画完成后真正删除
                        Handler(Looper.getMainLooper()).postDelayed({
                            onDelete()
                        }, 300)
                    }
                ) {
                    Icon(Icons.Default.Delete, contentDescription = "删除")
                }
            }
        }
    }
}

3.2 AnimatedContent:内容切换动画

AnimatedContent 用于在内容变化时播放过渡动画。

3.2.1 基本用法

var count by remember { mutableStateOf(0) }

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // 进入动画
        (slideInVertically { it } + fadeIn())
            .togetherWith(
                // 退出动画
                slideOutVertically { -it } + fadeOut()
            )
    },
    label = "count"
) { targetCount ->
    Text(
        text = "$targetCount",
        style = MaterialTheme.typography.headlineLarge
    )
}

3.2.2 实战:页面切换动画

sealed class Screen {
    data object Home : Screen()
    data object Profile : Screen()
    data object Settings : Screen()
}

@Composable
fun AppNavigation() {
    var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
    
    AnimatedContent(
        targetState = currentScreen,
        transitionSpec = {
            // 根据导航方向选择动画
            val direction = if (targetState > initialState) 1 else -1
            
            (slideInHorizontally { it * direction } + fadeIn())
                .togetherWith(
                    slideOutHorizontally { -it * direction } + fadeOut()
                )
        },
        label = "screen"
    ) { screen ->
        when (screen) {
            is Screen.Home -> HomeScreen()
            is Screen.Profile -> ProfileScreen()
            is Screen.Settings -> SettingsScreen()
        }
    }
}

3.3 Crossfade:淡入淡出

Crossfade 用于两个内容之间的交叉淡入淡出。

var currentPage by remember { mutableStateOf("A") }

Crossfade(
    targetState = currentPage,
    animationSpec = tween(500),
    label = "page"
) { page ->
    when (page) {
        "A" -> PageA()
        "B" -> PageB()
        "C" -> PageC()
    }
}

四、Animatable:可控动画

4.1 什么是 Animatable?

Animatable 是一个可动画化的值容器,提供了更细粒度的动画控制:

  • 停止动画
  • 跳转目标值
  • 快照当前值

4.2 Animatable 的基本用法

@Composable
fun DraggableBox() {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    
    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.toInt(), 0) }
            .size(100.dp)
            .background(Color.Blue)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDrag = { change, dragAmount ->
                        change.consume()
                        scope.launch {
                            offsetX.snapTo(offsetX.value + dragAmount.x)
                        }
                    },
                    onDragEnd = {
                        scope.launch {
                            // 弹回原点
                            offsetX.animateTo(
                                targetValue = 0f,
                                animationSpec = spring()
                            )
                        }
                    }
                )
            }
    )
}

4.3 Animatable 的方法

val animatable = remember { Animatable(0f) }

// 1. animateTo:动画到目标值
animatable.animateTo(100f, tween(300))

// 2. snapTo:立即跳转到目标值(无动画)
animatable.snapTo(100f)

// 3. stop:停止当前动画
animatable.stop()

// 4. 获取当前值
val currentValue = animatable.value

五、手势处理

5.1 点击手势

// 1. clickable:基础点击
Box(
    modifier = Modifier.clickable { /* 点击处理 */ }
)

// 2. combinedClickable:组合点击(单击、双击、长按)
Box(
    modifier = Modifier.combinedClickable(
        onClick = { /* 单击 */ },
        onDoubleClick = { /* 双击 */ },
        onLongClick = { /* 长按 */ }
    )
)

// 3. indication:自定义涟漪效果
Box(
    modifier = Modifier.clickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = ripple(color = Color.Red),
        onClick = { }
    )
)

5.2 拖动手势

@Composable
fun DraggableCard() {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }
    
    Card(
        modifier = Modifier
            .offset { IntOffset(offsetX.toInt(), offsetY.toInt()) }
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consume()
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                }
            }
    ) {
        Text("拖动我")
    }
}

5.3 滑动手势

@Composable
fun SwipeableCard(
    onDismiss: () -> Unit
) {
    val scope = rememberCoroutineScope()
    val offsetX = remember { Animatable(0f) }
    
    // 滑动阈值
    val dismissThreshold = 300f
    
    Card(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.toInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > dismissThreshold) {
                                // 滑过阈值,执行删除
                                offsetX.animateTo(
                                    if (offsetX.value > 0) 1000f else -1000f
                                )
                                onDismiss()
                            } else {
                                // 未滑过阈值,弹回
                                offsetX.animateTo(0f, spring())
                            }
                        }
                    },
                    onHorizontalDrag = { change, dragAmount ->
                        change.consume()
                        scope.launch {
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
    ) {
        Text("左右滑动")
    }
}

5.4 缩放手势

@Composable
fun ZoomableImage(
    painter: Painter
) {
    var scale by remember { mutableStateOf(1f) }
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }
    
    Image(
        painter = painter,
        contentDescription = null,
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                translationX = offsetX
                translationY = offsetY
            }
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, _ ->
                    scale = (scale * zoom).coerceIn(1f, 5f)
                    offsetX += pan.x
                    offsetY += pan.y
                }
            }
    )
}

5.5 嵌套滚动

@Composable
fun NestedScrollDemo() {
    val scrollState = rememberScrollState()
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .nestedScroll(
                connection = remember {
                    object : NestedScrollConnection {
                        override fun onPreScroll(
                            available: Offset,
                            source: NestedScrollSource
                        ): Offset {
                            // 在子组件滚动前处理
                            return Offset.Zero
                        }
                        
                        override fun onPostScroll(
                            consumed: Offset,
                            available: Offset,
                            source: NestedScrollSource
                        ): Offset {
                            // 在子组件滚动后处理
                            return Offset.Zero
                        }
                    }
                }
            )
            .verticalScroll(scrollState)
    ) {
        // 内容
    }
}

六、自定义动画

6.1 无限循环动画

@Composable
fun PulsingDot() {
    val infiniteTransition = rememberInfiniteTransition(label = "pulse")
    
    val scale by infiniteTransition.animateFloat(
        initialValue = 1f,
        targetValue = 1.5f,
        animationSpec = infiniteRepeatable(
            animation = tween(1000),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )
    
    val alpha by infiniteTransition.animateFloat(
        initialValue = 1f,
        targetValue = 0.5f,
        animationSpec = infiniteRepeatable(
            animation = tween(1000),
            repeatMode = RepeatMode.Reverse
        ),
        label = "alpha"
    )
    
    Box(
        modifier = Modifier
            .size(20.dp)
            .scale(scale)
            .alpha(alpha)
            .background(Color.Red, CircleShape)
    )
}

6.2 关键帧动画

@Composable
fun BouncingBall() {
    val ballY by animateFloatAsState(
        targetValue = 300f,
        animationSpec = keyframes {
            durationMillis = 1000
            0f at 0 with FastOutSlowInEasing
            300f at 300 with LinearEasing
            150f at 500 with FastOutSlowInEasing
            300f at 700 with LinearEasing
            225f at 850 with FastOutSlowInEasing
            300f at 1000
        },
        label = "bounce"
    )
    
    Box(
        modifier = Modifier
            .offset(y = ballY.dp)
            .size(50.dp)
            .background(Color.Blue, CircleShape)
    )
}

6.3 手势驱动的动画

@Composable
fun SwipeToDismissItem(
    content: @Composable () -> Unit,
    onDismiss: () -> Unit
) {
    val scope = rememberCoroutineScope()
    val offsetX = remember { Animatable(0f) }
    
    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.toInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    scope.launch {
                        offsetX.snapTo(offsetX.value + delta)
                    }
                },
                onDragStopped = {
                    scope.launch {
                        if (abs(offsetX.value) > 200f) {
                            offsetX.animateTo(if (offsetX.value > 0) 1000f else -1000f)
                            onDismiss()
                        } else {
                            offsetX.animateTo(0f, spring())
                        }
                    }
                }
            )
    ) {
        content()
    }
}

七、动画性能优化

7.1 动画性能原则

┌─────────────────────────────────────────────────────────────────┐
│                    动画性能优化原则                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ✅ 推荐                                                         │
│  ├── 使用 graphicsLayer 进行变换(scale、rotation、alpha)       │
│  ├── 使用 offset 进行位置移动                                    │
│  ├── 避免动画化尺寸变化(width、height)                         │
│  └── 使用 derivedStateOf 减少不必要的动画计算                    │
│                                                                 │
│  ❌ 避免                                                         │
│  ├── 动画化复杂布局(ConstraintLayout、自定义 Layout)           │
│  ├── 在动画中读取/写入大量状态                                   │
│  └── 同时运行过多的动画                                          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

7.2 使用 graphicsLayer 优化

// ✅ 推荐:使用 graphicsLayer 进行变换
Box(
    modifier = Modifier
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
            rotationZ = rotation
            alpha = alpha
            translationX = offsetX
            translationY = offsetY
        }
)

// ❌ 避免:直接修改尺寸
Box(
    modifier = Modifier
        .size(size)  // 这会触发重新测量和布局
)

7.3 动画调试

// 使用 label 标识动画,便于调试
val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    label = "fadeAlpha"  // 在 Android Studio 动画检查器中显示
)

八、本篇小结

今天我们深入探讨了 Compose 的动画与交互系统:

动画系统架构

  • 理解了高层、中层、底层 API 的分层设计
  • 掌握了 AnimationSpec 和 Easing 的使用

属性动画

  • 掌握了 animate*AsState 系列函数
  • 理解了 Animatable 的底层实现

过渡动画

  • 掌握了 AnimatedVisibility、AnimatedContent、Crossfade
  • 学会了组合动画效果

手势处理

  • 掌握了点击、拖动、滑动、缩放手势
  • 理解了嵌套滚动的处理

自定义动画

  • 学会了无限循环动画、关键帧动画
  • 掌握了手势驱动的动画

性能优化

  • 理解了动画性能原则
  • 学会了使用 graphicsLayer 优化

下篇预告

第六篇:架构与工程化 将深入讲解:

  • Navigation 组件与 Compose 集成
  • Hilt 依赖注入在 Compose 中的应用
  • 副作用 API 详解
  • Compose 性能优化实战

敬请期待!


📌 系列文章导航

  • 第一篇:初识 Compose ✅
  • 第二篇:核心基石 ✅
  • 第三篇:状态管理 ✅
  • 第四篇:Material 组件与主题 ✅
  • 第五篇:动画与交互(当前)✅
  • 第六篇:架构与工程化
  • 第七篇:高级特性与实战

如果这篇文章对你有帮助,欢迎 点赞收藏关注!有任何问题可以在评论区留言。