Compose动画基础:从声明式视角重新理解动画
说实话,我第一次接触Compose动画时,内心是有点抗拒的。毕竟我在View体系里摸爬滚打了六七年,什么AnimationSet、Animator、PropertyValuesHolder都玩得滚瓜烂熟。突然让我换个思路?嗯,确实需要一点适应期。
但当我真正理解了Compose动画的设计哲学后,我不得不承认——这玩意儿,真香。
Compose动画的核心思想:状态驱动
在View时代,我们做动画的思路是:创建一个动画对象,告诉它从哪到哪,然后启动它。说白了,动画是一个独立运行的东西,它跟UI状态是分离的。
Compose完全反过来了。它的核心逻辑是:动画只是状态变化的一种平滑过渡方式。你不需要手动启动或停止动画,你只需要改变状态,Compose会自动帮你完成中间过程。
我举个例子你就明白了。在View里,你想让一个按钮透明度从0变到1,你得写:
// View时代的写法
val anim = ObjectAnimator.ofFloat(button, "alpha", 0f, 1f)
anim.duration = 300
anim.start()
但在Compose里,你只需要:
// Compose的写法
var visible by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(300)
)
看到区别了吗?visible变了,alpha自动跟着变。你不需要关心动画什么时候开始、什么时候结束——Compose帮你管了。
核心理解:Compose动画不是"启动一个动画",而是"让状态变化变得平滑"。这个思维转变,是学好Compose动画的第一步。
animate*AsState系列函数:最常用的动画工具
我个人习惯把animate*AsState系列函数称为"懒人动画API"。为什么?因为它的使用门槛极低,你几乎不需要了解动画原理就能上手。
这个系列包含几个常用函数:
| 函数名 | 作用 | 返回值类型 |
|---|---|---|
| animateFloatAsState | 对Float值做动画 | State<Float> |
| animateDpAsState | 对Dp值做动画 | State<Dp> |
| animateColorAsState | 对Color值做动画 | State<Color> |
| animateSizeAsState | 对Size值做动画 | State<IntSize> |
| animateOffsetAsState | 对Offset值做动画 | State<Offset> |
| animateBoundsAsState | 对Rect值做动画 | State<Rect> |
它们的用法高度一致:传入一个targetValue,当这个值变化时,返回的State会从旧值平滑过渡到新值。
来看一个完整的例子:
@Composable
fun AnimatedButton() {
var isExpanded by remember { mutableStateOf(false) }
// 宽度从100.dp到200.dp
val width by animateDpAsState(
targetValue = if (isExpanded) 200.dp else 100.dp,
animationSpec = tween(
durationMillis = 500,
easing = FastOutSlowInEasing
)
)
// 背景色从蓝色到红色
val bgColor by animateColorAsState(
targetValue = if (isExpanded) Color.Red else Color.Blue,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
Button(
onClick = { isExpanded = !isExpanded },
modifier = Modifier
.width(width)
.height(50.dp)
.background(bgColor, shape = RoundedCornerShape(8.dp))
) {
Text(if (isExpanded) "收起" else "展开")
}
}
这段代码里,我同时用了animateDpAsState和animateColorAsState。你想想看,在View里要实现这种多属性联动动画,你得写多少代码?但在Compose里,就这么几行。
我的经验:刚开始用animate*AsState时,我犯过一个低级错误——在targetValue里传了一个每次重组都会变化的新对象。比如animateColorAsState(targetValue = Color(0xFF0000)),每次重组都new了一个Color,导致动画永远停在初始值。记住:targetValue必须是一个稳定的引用,或者用remember缓存起来。
Animatable:更精细的动画控制
animate*AsState虽然好用,但它有个限制:你只能设置目标值,不能干预动画过程。如果你想手动控制动画的播放、暂停、取消,或者想获取动画的瞬时速度,那就得用Animatable了。
我把它比作"手动挡"——操作更复杂,但控制力更强。
@Composable
fun DraggableCircle() {
val offsetX = remember { Animatable(0f) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
// 手动控制动画目标值
launch {
offsetX.animateTo(
targetValue = offsetX.value + dragAmount.x,
animationSpec = tween(100)
)
}
}
}
) {
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.size(50.dp)
.background(Color.Green, CircleShape)
)
}
}
看到launch了吗?Animatable.animateTo是一个挂起函数,这意味着你可以在协程里精确控制动画的启动时机。这在处理手势、拖拽等交互场景时特别有用。
我曾经在一个项目中需要实现"拖拽松手后回弹"的效果。用animate*AsState死活调不好,换成Animatable配合spring动画规格,两行代码就搞定了。
注意:Animatable的animateTo必须在协程作用域内调用。如果你在LaunchedEffect或rememberCoroutineScope之外调用,会编译报错。嗯,这个坑我踩过不止一次。
AnimatableAsState:两者之间的桥梁
你可能会问:有没有一种方式,既能像animate*AsState那样声明式使用,又能像Animatable那样精细控制?
有。AnimatableAsState就是干这个的。不过说实话,这个API在官方文档里存在感不高,我平时用得也不多。但它的设计思路很有意思——它把Animatable包装成了一个可观察的状态。
@Composable
fun AnimatableAsStateExample() {
val animatable = remember { Animatable(0f) }
val animatedValue by animatable.asState()
// 现在animatedValue会随着animatable的变化而更新
// 你可以同时使用animatable.animateTo()精细控制
// 也可以像State一样读取animatedValue
}
我个人觉得,这个API更适合那些"既要又要"的场景——既想享受声明式的便利,又不想放弃手动控制的能力。不过大多数情况下,animate*AsState已经够用了。
知识体系总览
为了帮你理清思路,我画了一张图:
如何选择?我的建议
说了这么多,到底该用哪个?我总结了一个简单的选择标准:
- 场景一:UI状态变化后,你希望某个属性平滑过渡。比如按钮展开/收起、颜色切换、大小变化。→ 用
animate*AsState - 场景二:你需要响应手势、拖拽、滑动,或者需要精确控制动画的启停。比如可拖拽的卡片、跟随手指移动的控件。→ 用
Animatable - 场景三:你既想声明式监听,又想在特定时机手动干预。→ 用
AnimatableAsState
我个人80%的场景都用animate*AsState搞定了。剩下的20%,比如实现一个"拖拽松手后回弹"的列表项,才需要Animatable出场。
一个小技巧:如果你不确定该用哪个,先从animate*AsState开始。等发现它满足不了需求时,再换成Animatable。这两个API的切换成本很低,因为它们的底层机制是相通的。
好了,这一章的内容就到这里。Compose动画的入门其实不难,关键是要转变思维——从"控制动画"变成"描述状态"。一旦你接受了这个设定,后面的高级用法学起来就会顺畅很多。
公众号:蓝海资料掘金营,微信deep3321