高级动画:Animatable 与 AnimationSpec,手势动画(拖拽、缩放),自定义动画
动画是让应用“活”起来的关键。Jetpack Compose 的动画系统,说实话,比传统 View 体系要优雅得多。你不需要再写一堆 AnimationListener、Interpolator,也不用担心内存泄漏。今天我们就来聊聊几个高级玩法:Animatable、AnimationSpec,还有手势动画和自定义动画。
我个人习惯把 Compose 动画分成三层:底层是 Animatable,中层是各种 AnimationSpec,上层是手势驱动。咱们一层层往下扒。
1. Animatable:动画的“原子”
Animatable 是 Compose 动画的最小单元。它就像一个可以平滑变化的值容器。你给它一个目标值,它会自动从当前值过渡过去。
@Composable
fun AnimatableExample() {
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatable.animateTo(100f, animationSpec = tween(1000))
}
Text("当前值: ${animatable.value}")
}
嗯,这里要注意:Animatable 必须在 remember 里创建,否则重组时会丢失状态。我在项目中遇到过一个问题——忘了加 remember,结果动画每次重组都从头开始,页面卡得像幻灯片。
Animatable 支持的类型很丰富:Float、Color、Dp、Offset、Rect 等等。你甚至可以给自定义类型写一个 TwoWayConverter,让它也能动起来。
Animatable,或者直接用 animate*AsState 系列函数。后者更简洁,但前者更灵活。
2. AnimationSpec:动画的“节奏”
AnimationSpec 定义了动画怎么动——是匀速、加速、还是弹跳?Compose 内置了几种常用的规格:
| 规格 | 说明 | 适用场景 |
|---|---|---|
tween |
补间动画,可设置时长、延迟、缓动曲线 | 大多数 UI 过渡 |
spring |
弹簧动画,有弹性和阻尼效果 | 列表滑动、拖拽回弹 |
keyframes |
关键帧动画,可以定义多个时间点的值 | 复杂路径动画 |
repeatable |
重复执行某个动画规格 | 加载指示器、呼吸灯 |
infiniteRepeatable |
无限重复 | 旋转、脉冲 |
举个例子,弹簧动画的效果非常自然:
val offset = remember { Animatable(0f) }
LaunchedEffect(Unit) {
offset.animateTo(
targetValue = 300f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
你想想看,如果用手写插值器实现这种弹跳效果,得算半天公式。Compose 一行搞定。
spring 动画,结果列表快速滑动时,每个 item 都在弹跳,视觉上非常混乱。后来我改成 tween 加短时长,效果反而更好。弹簧动画虽好,但别滥用。
3. 手势动画:拖拽与缩放
手势动画的核心是 pointerInput 修饰符。它提供了 detectDragGestures、detectTransformGestures 等便捷函数。
拖拽
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
.size(100.dp)
.background(Color.Blue)
)
这里有个细节:change.consume() 必须调用,否则事件会继续传递,可能引发冲突。我在做一个图片查看器时,忘了调用 consume,结果拖拽和点击事件同时触发,用户点一下图片就飞出去了。
缩放
var scale by remember { mutableStateOf(1f) }
Box(
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 3f)
}
}
.size(100.dp)
.background(Color.Red)
)
缩放时记得用 coerceIn 限制范围,不然用户能把图片缩到看不见,或者撑爆屏幕。
4. 自定义动画:造你自己的轮子
当内置动画满足不了需求时,你可以用 Animatable + AnimationSpec 组合出任意效果。甚至可以用 Animation 类直接控制每一帧。
举个例子,实现一个“心跳”效果:
@Composable
fun HeartBeatAnimation() {
val scale = remember { Animatable(1f) }
LaunchedEffect(Unit) {
while (true) {
scale.animateTo(1.2f, tween(300, easing = FastOutSlowInEasing))
scale.animateTo(1f, tween(300, easing = FastOutSlowInEasing))
delay(500)
}
}
Box(
modifier = Modifier
.graphicsLayer { scaleX = scale.value; scaleY = scale.value }
.size(80.dp)
.background(Color.Red, shape = CircleShape)
)
}
你看,其实就是两个 animateTo 加一个 delay。但效果非常生动。
如果你需要更精细的控制,比如每一帧都做计算,可以用 Animatable 的 animate 方法,传入一个 Animation 对象:
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatable.animate(
Animation(0f, 100f, 1000, EaseInOutCubic)
)
}
说实话,这种用法我很少用。大多数场景下,animateTo 配合 AnimationSpec 已经足够了。
5. 知识体系总览
下面这张图帮你理清本章的核心脉络:
说白了,高级动画就是这三板斧:Animatable 存值,AnimationSpec 定节奏,手势做输入。自定义动画不过是把这三者灵活组合。
我在做项目时,经常把拖拽和缩放组合起来,做一个可拖拽、可缩放的图片查看器。核心代码不超过 50 行,但用户体验提升巨大。这就是 Compose 动画的魅力——用最少的代码,实现最流畅的交互。