15、Compose动画与手势:animate*AsState与Animatable、过渡动画、手势处理、自定义动画Spec

动画和手势,是让App“活”起来的关键。很多开发者觉得Compose的动画体系很复杂,其实不然。我个人习惯把Compose动画拆成三层来看:状态驱动的声明式动画底层动画控制、以及过渡动画。今天我们就一层层剥开它。

核心观点:Compose动画本质上是“状态变化”的视觉映射。你不需要手动控制开始/结束,只需要告诉系统“目标状态是什么”,剩下的交给动画框架。

Compose动画与手势知识体系 animate*AsState 声明式动画(简单场景) Animatable 底层动画控制(复杂场景) 过渡动画 AnimatedVisibility/Content 手势处理 clickable / draggable / transformable 自定义动画Spec SpringSpec / TweenSpec / Keyframes

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是更好的选择。它本质上是一个可观察的值持有者,你可以随时animateTosnapTo

@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)
        }
    }
}

你想想看,如果用传统方式实现这个效果,得写多少代码?AnimatedVisibilityenterexit的动画组合起来,而且支持+运算符叠加多个动画。我个人习惯把常用的动画组合封装成扩展函数,比如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里的scaleoffsetrotation映射到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”优势的地方。多动手试试,你会发现很多组合玩法。


公众号:蓝海资料掘金营,微信deep3321