15、Compose动画与手势:animate*AsState与Animatable、过渡动画、手势处理、自定义动画Spec
动画和手势,是让App“活”起来的关键。很多开发者觉得Compose的动画体系很复杂,其实不然。我个人习惯把Compose动画拆成三层来看:状态驱动的声明式动画、底层动画控制、以及过渡动画。今天我们就一层层剥开它。
核心观点:Compose动画本质上是“状态变化”的视觉映射。你不需要手动控制开始/结束,只需要告诉系统“目标状态是什么”,剩下的交给动画框架。
15.1 animate*AsState:最常用的声明式动画
如果你只需要让某个值从A变到B,用animate*AsState就对了。它是最简单、最不容易出错的动画方式。
// 基础用法:让透明度变化
@Composable
fun FadeInDemo() {
var visible by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(durationMillis = 500)
)
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer(alpha = alpha)
.clickable { visible = !visible }
.background(Color.Blue)
)
}
嗯,这里要注意:animate*AsState返回的是一个持续更新的状态值,Compose会帮你处理好所有中间帧。我在项目中遇到过一个问题——有人把animationSpec写成了每次重组都创建新对象,导致动画重启。记住,用remember把它包起来。
我的习惯:简单动画用animate*AsState,需要精细控制(比如循环动画、手势驱动的动画)才用Animatable。别上来就上复杂方案。
15.2 Animatable:更底层的动画控制
当你需要手动控制动画的启停、方向、或者做链式动画时,Animatable是更好的选择。它本质上是一个可观察的值持有者,你可以随时animateTo或snapTo。
@Composable
fun AnimatableDemo() {
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
// 链式动画:先放大再缩小
animatable.animateTo(1f, animationSpec = tween(500))
animatable.animateTo(0.5f, animationSpec = spring(dampingRatio = 0.3f))
}
Box(
modifier = Modifier
.size(100.dp)
.scale(animatable.value)
.background(Color.Red)
)
}
我曾经在做一个拖拽卡片效果时,用Animatable配合手势实现了“松手回弹”的效果。核心思路就是:手势结束时,根据当前速度判断是归位还是飞走。用animateTo配合spring,效果非常自然。
避坑指南:我曾经在LaunchedEffect里直接调用animatable.animateTo,但忘了处理协程取消。结果页面退出时动画还在跑,导致内存泄漏。记得用isActive检查协程状态,或者直接用snapTo做清理。
15.3 过渡动画:AnimatedVisibility与AnimatedContent
这两个API专门处理“内容出现/消失”和“内容切换”的场景。说白了,就是帮你省掉写if-else加动画的麻烦。
AnimatedVisibility
@Composable
fun VisibilityDemo() {
var visible by remember { mutableStateOf(true) }
Column {
Button(onClick = { visible = !visible }) {
Text("切换")
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Text("我是会动的内容", fontSize = 24.sp)
}
}
}
你想想看,如果用传统方式实现这个效果,得写多少代码?AnimatedVisibility把enter和exit的动画组合起来,而且支持+运算符叠加多个动画。我个人习惯把常用的动画组合封装成扩展函数,比如fun AnimatedEnterTransition.slideFade()。
AnimatedContent
当内容切换时,AnimatedContent可以让你定义“旧内容怎么走,新内容怎么来”。
@Composable
fun ContentDemo() {
var state by remember { mutableStateOf("A") }
AnimatedContent(
targetState = state,
transitionSpec = {
if (targetState == "B") {
slideInHorizontally() + fadeIn() togetherWith
slideOutHorizontally() + fadeOut()
} else {
slideInHorizontally() + fadeIn() togetherWith
slideOutHorizontally() + fadeOut()
}
}
) { currentState ->
when (currentState) {
"A" -> Text("页面A")
"B" -> Text("页面B")
}
}
}
这里有个细节:transitionSpec里的togetherWith关键字,用于组合进入和退出的动画。我记得第一次用的时候,忘了加togetherWith,结果进入和退出动画各跑各的,看起来特别奇怪。
15.4 手势处理:clickable / draggable / transformable
Compose的手势处理,核心思想是声明式手势。你不需要监听各种回调,只需要告诉组件“这个手势应该产生什么效果”。
| 手势修饰符 | 适用场景 | 返回数据 |
|---|---|---|
clickable |
点击、长按 | 无(通过回调通知) |
draggable |
单方向拖拽 | Offset(偏移量) |
transformable |
缩放、旋转、平移 | TransformState |
// draggable 示例:水平滑动删除
@Composable
fun SwipeToDelete() {
var offsetX by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
offsetX += delta
}
)
.background(Color.Gray)
.size(200.dp, 60.dp)
) {
Text("滑动删除", modifier = Modifier.align(Alignment.Center))
}
}
我在项目中用transformable做过图片查看器。核心逻辑是:把TransformState里的scale、offset、rotation映射到graphicsLayer上。嗯,这里要注意边界处理——不能让用户把图片缩放到看不见。
小技巧:手势和动画经常配合使用。比如拖拽结束后,用Animatable做回弹动画。我习惯在onDragEnd回调里启动animateTo,这样手感特别顺滑。
15.5 自定义动画Spec
Compose内置了三种动画Spec:TweenSpec(线性插值)、SpringSpec(弹簧效果)、KeyframesSpec(关键帧)。但有时候你需要更特殊的曲线,比如“先快后慢再快”的缓动效果。
// 自定义Easing
val customEasing = Easing { fraction ->
// 自定义曲线:先快后慢
if (fraction < 0.5f) {
2f * fraction * fraction
} else {
-1f + (4f - 2f * fraction) * fraction
}
}
val customSpec = tween(
durationMillis = 1000,
easing = customEasing
)
你还可以实现自己的DurationBasedAnimationSpec。我记得有一次需要做一个“心跳”效果——不是简单的缩放,而是带节奏的脉冲。用KeyframesSpec配合自定义Easing,完美复现了设计稿的效果。
核心原则:自定义Spec不是为了炫技,而是为了解决“标准动画曲线无法满足”的问题。能用spring解决的,就别自己写Easing。
好了,这一章的内容就到这里。动画和手势是Compose里最有趣的部分,也是最能体现“声明式UI”优势的地方。多动手试试,你会发现很多组合玩法。