动画进阶:Animatable、AnimationSpec、Transition、无限循环动画、手势动画

动画是让应用「活起来」的关键。Compose 的动画系统,说实话,比传统 View 系统要优雅得多。你不需要再写一堆 AnimationListener、Interpolator 的模板代码。Compose 把动画抽象成了状态驱动的声明式 API,用起来非常顺手。

我个人习惯把 Compose 动画分成两类:属性动画过渡动画。前者控制单个值的变化,后者控制多个值或整个 UI 树的切换。今天我们就从最底层的 Animatable 开始,一路聊到手势动画。

核心知识点一览

  • Animatable:细粒度控制单个动画值
  • AnimationSpec:定义动画的节奏和曲线
  • Transition:管理多个动画的协调变化
  • 无限循环动画:让动画永不停止
  • 手势动画:结合拖拽、缩放等交互
Compose 动画进阶体系 动画核心层 Animatable 单值动画控制器 AnimationSpec 动画规格定义 Transition 多值过渡管理 animateTo / animateAsState tween / spring / keyframes updateTransition / AnimatedContent 无限循环动画 手势动画 组合应用

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 的阻尼比,调一调数值,你就能感受到「物理感」是怎么来的了。

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