动画进阶:Animatable、AnimationSpec、Transition、无限循环动画、手势动画
动画是让应用「活起来」的关键。Compose 的动画系统,说实话,比传统 View 系统要优雅得多。你不需要再写一堆 AnimationListener、Interpolator 的模板代码。Compose 把动画抽象成了状态驱动的声明式 API,用起来非常顺手。
我个人习惯把 Compose 动画分成两类:属性动画和过渡动画。前者控制单个值的变化,后者控制多个值或整个 UI 树的切换。今天我们就从最底层的 Animatable 开始,一路聊到手势动画。
核心知识点一览
- Animatable:细粒度控制单个动画值
- AnimationSpec:定义动画的节奏和曲线
- Transition:管理多个动画的协调变化
- 无限循环动画:让动画永不停止
- 手势动画:结合拖拽、缩放等交互
1. Animatable:最底层的动画引擎
Animatable 是 Compose 动画的「原子操作」。它直接管理一个动画值,你可以手动控制它从 A 变到 B。我刚开始用的时候觉得它有点啰嗦,后来发现它其实是最灵活的工具。
// 基础用法
val progress = remember { Animatable(0f) }
// 触发动画
LaunchedEffect(Unit) {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(1000)
)
}
// 在 UI 中使用
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer {
scaleX = progress.value
scaleY = progress.value
}
.background(Color.Blue)
)
为什么说它灵活?因为你可以随时打断、反转、甚至用协程控制它。我在项目中做过一个加载动画,需要根据网络状态动态调整旋转速度——用 Animatable 配合协程取消,几行代码就搞定了。
小技巧:Animatable 的 snapTo() 方法可以瞬间跳转到某个值,适合做「重置」操作。比如用户拖拽后松手,直接 snap 回原点。
2. AnimationSpec:定义动画的「性格」
AnimationSpec 决定了动画的节奏。Compose 内置了几种常用的规格,我列个表方便你对比:
| 规格 | 特点 | 适用场景 |
|---|---|---|
| tween | 固定时长,可配 easing | 大多数 UI 动画 |
| spring | 物理弹性,时长由阻尼决定 | 列表滑动、拖拽回弹 |
| keyframes | 自定义关键帧,精确控制 | 复杂路径动画 |
| repeatable | 重复执行某个规格 | 闪烁、呼吸灯效果 |
我个人最常用的是 spring。为什么?因为它自然。你想想看,现实世界中的物体运动都是有惯性和弹性的。tween 虽然可控,但总感觉有点「假」。spring 的阻尼比和刚度系数调好了,效果非常丝滑。
// spring 示例
val offset = remember { Animatable(0f) }
LaunchedEffect(Unit) {
offset.animateTo(
targetValue = 100f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
注意:spring 动画没有固定时长。如果 stiffness 设得太低,动画可能持续好几秒。我曾经在列表 item 里用了低 stiffness 的 spring,结果滑动时动画一直没停,卡得不行。后来改成了 tween + 短时长,问题解决。
3. Transition:管理多个动画的「交响乐」
当你有多个属性需要同时变化时,用多个 Animatable 会变得很乱。这时候 Transition 就派上用场了。它像一个指挥家,协调所有动画的进度。
// 定义状态
enum class BoxState { Collapsed, Expanded }
// 创建 Transition
val transition = updateTransition(
targetState = currentState,
label = "boxTransition"
)
// 定义动画值
val size by transition.animateDp(label = "size") { state ->
when (state) {
BoxState.Collapsed -> 50.dp
BoxState.Expanded -> 200.dp
}
}
val color by transition.animateColor(label = "color") { state ->
when (state) {
BoxState.Collapsed -> Color.Gray
BoxState.Expanded -> Color.Red
}
}
// 使用
Box(
modifier = Modifier
.size(size)
.background(color)
)
Transition 的好处是:所有动画共享同一个进度。你切换状态时,size 和 color 会同步变化,不会出现一个变完了另一个才开始的情况。嗯,这在做展开/折叠动画时特别有用。
4. 无限循环动画
有些动画需要一直转下去,比如加载指示器、呼吸灯。Compose 提供了 rememberInfiniteTransition 来实现。
val infiniteTransition = rememberInfiniteTransition(label = "loading")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "rotation"
)
// 旋转的加载图标
Image(
painter = painterResource(R.drawable.ic_loading),
contentDescription = "loading",
modifier = Modifier
.size(48.dp)
.rotate(rotation)
)
这里有个坑:infiniteRepeatable 默认是 RepeatMode.Restart,也就是每次从头开始。如果你想要来回摆动(比如呼吸灯),用 RepeatMode.Reverse。我之前做心跳动画时忘了改这个,结果图标一直「咔咔」地跳回起点,看起来像抽风。
5. 手势动画:让用户「摸」到动画
手势动画是进阶中的进阶。它把用户交互和动画引擎结合起来。最典型的例子就是拖拽后回弹、缩放图片等。
// 拖拽 + 回弹
@Composable
fun DraggableBox() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
val animatableX = remember { Animatable(0f) }
val animatableY = remember { Animatable(0f) }
Box(
modifier = Modifier
.size(100.dp)
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Cyan)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
},
onDragEnd = {
// 松手后回弹
scope.launch {
animatableX.animateTo(0f)
animatableY.animateTo(0f)
}
}
)
}
)
}
你可能会问:为什么不用 animateOffsetAsState?因为手势动画需要实时响应手指移动,而 animateAsState 有延迟。我习惯的做法是:手势期间用 mutableStateOf 直接更新,手势结束后用 Animatable 做回弹。这样既保证了响应速度,又有平滑的结束动画。
经验之谈:手势动画中别忘了 change.consume()。如果不消费事件,手势会穿透到下层组件,导致奇怪的交互冲突。我曾经因为这个 bug 排查了半天,最后发现是忘了 consume。
6. 组合应用:一个完整的例子
最后,我把今天讲的知识点串起来,做一个可拖拽、松手回弹、带无限旋转的卡片。你感受一下:
@Composable
fun FancyCard() {
var isExpanded by remember { mutableStateOf(false) }
val transition = updateTransition(targetState = isExpanded, label = "card")
val size by transition.animateDp(label = "size") {
if (it) 200.dp else 100.dp
}
val infiniteTransition = rememberInfiniteTransition(label = "spin")
val rotation by infiniteTransition.animateFloat(
0f, 360f,
infiniteRepeatable(tween(2000, easing = LinearEasing)),
label = "rotation"
)
var offsetX by remember { mutableStateOf(0f) }
val animX = remember { Animatable(0f) }
Box(
modifier = Modifier
.size(size)
.rotate(rotation)
.offset { IntOffset(offsetX.roundToInt(), 0) }
.background(Color.Magenta)
.clickable { isExpanded = !isExpanded }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
scope.launch { animX.animateTo(0f) }
},
onHorizontalDrag = { _, dragAmount ->
offsetX += dragAmount
}
)
}
)
}
这个例子包含了:Transition 控制大小、无限循环控制旋转、手势控制水平拖拽、松手回弹。说实话,在传统 View 系统里要实现同样的效果,代码量至少翻三倍。
总结一下:
- Animatable 是底层引擎,适合精细控制
- AnimationSpec 定义动画风格,spring 最自然
- Transition 管理多值同步变化
- 无限循环用 rememberInfiniteTransition
- 手势动画要区分「实时响应」和「结束动画」
动画这东西,光看文档是不够的。我建议你打开 Android Studio,把上面的代码挨个敲一遍,改改参数看看效果。尤其是 spring 的阻尼比,调一调数值,你就能感受到「物理感」是怎么来的了。