深入理解 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 组件与主题 ✅
- 第五篇:动画与交互(当前)✅
- 第六篇:架构与工程化
- 第七篇:高级特性与实战
如果这篇文章对你有帮助,欢迎 点赞、收藏、关注!有任何问题可以在评论区留言。