高级动画:Animatable 与 AnimationSpec,手势动画(拖拽、缩放),自定义动画

动画是让应用“活”起来的关键。Jetpack Compose 的动画系统,说实话,比传统 View 体系要优雅得多。你不需要再写一堆 AnimationListener、Interpolator,也不用担心内存泄漏。今天我们就来聊聊几个高级玩法:AnimatableAnimationSpec,还有手势动画和自定义动画。

我个人习惯把 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 修饰符。它提供了 detectDragGesturesdetectTransformGestures 等便捷函数。

拖拽

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 限制范围,不然用户能把图片缩到看不见,或者撑爆屏幕。

核心思路:手势动画的本质是“手势输入 → 状态更新 → 重组渲染”。你不需要手动管理动画循环,Compose 的 recomposition 机制会自动处理。

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。但效果非常生动。

如果你需要更精细的控制,比如每一帧都做计算,可以用 Animatableanimate 方法,传入一个 Animation 对象:

val animatable = remember { Animatable(0f) }

LaunchedEffect(Unit) {
    animatable.animate(
        Animation(0f, 100f, 1000, EaseInOutCubic)
    )
}

说实话,这种用法我很少用。大多数场景下,animateTo 配合 AnimationSpec 已经足够了。

5. 知识体系总览

下面这张图帮你理清本章的核心脉络:

Compose 高级动画知识体系 高级动画 Animatable 值容器,自动过渡 支持 Float/Color/Offset AnimationSpec tween / spring / keyframes repeatable / infiniteRepeatable 手势动画 拖拽 / 缩放 / 旋转 pointerInput + detect* 自定义动画 组合 Animatable + AnimationSpec 或直接使用 Animation 类

说白了,高级动画就是这三板斧:Animatable 存值,AnimationSpec 定节奏,手势做输入。自定义动画不过是把这三者灵活组合。

我在做项目时,经常把拖拽和缩放组合起来,做一个可拖拽、可缩放的图片查看器。核心代码不超过 50 行,但用户体验提升巨大。这就是 Compose 动画的魅力——用最少的代码,实现最流畅的交互。

最后提醒一句:动画虽好,但别过度。每个动画都应该有明确的目的——引导用户注意力、反馈操作结果、或者提升视觉愉悦感。为了动画而动画,反而会让用户觉得花哨、不专业。

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