Jetpack Compose 动画

状态转移动画

在 Jetpack Compose 中,animateXxxAsState 是一组用于状态变化的动画 API。animateXxxAsState的具体实现其实就是Animatable。它们能够在状态发生改变时自动插值,以平滑地过渡到新的状态。这类 API 是 Jetpack Compose 实现动画的简便方式之一,下边就是它的源码:

kotlin 复制代码
@Composable
fun animateDpAsState(
    targetValue: Dp,
    animationSpec: AnimationSpec<Dp> = dpDefaultSpring,// 定制动画的通用特征,例如速度曲线、动画时长等
    label: String = "DpAnimation",
    finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
    return animateValueAsState(
        targetValue,
        Dp.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = finishedListener
    )
}

animateXxxAsState 的初始值由其 绑定的targetValue 或 状态(State)变量的当前值 决定,targetValue 是必须的,因为它定义了动画的目标状态:

  • a. 第一次调用时的初始值

它会将当前targetValue的目标值视为动画的初始值,可以认为第一次初始化的时候,targetValue就是它的初始值,没有必要在从其他地方给初始值了,这也解释了为什么不需要给animateXxxAsState 设置初始值。

  • b. 后续更新

动画的目标值会自动更新为传入的 targetValue,并从之前动画结束的值平滑过渡到新的目标值。

以下是常用的 animateXxxAsState 函数,以及它们的用法和示例:

animateFloatAsState

用于在浮点数(Float)值之间实现平滑过渡。

js 复制代码
val alpha: Float by animateFloatAsState(
    targetValue = if (isVisible) 1f else 0f,
    animationSpec = tween(durationMillis = 300)  // 300 毫秒内完成动画
)

在这个示例中,根据 isVisible 的状态切换 alpha 值,该动画会在透明度从 0 到 1 或从 1 到 0 的时候自动平滑过渡。

animateDpAsState

用于 Dp 类型值之间的过渡,例如控件的大小、边距等。

js 复制代码
val size: Dp by animateDpAsState(
    targetValue = if (isExpanded) 100.dp else 50.dp
)
Box(
    Modifier
        .size(size)
        .background(Color.Blue)
)

此代码会在 isExpanded 状态变化时平滑地调整 Box 的大小。

animateColorAsState

用于颜色(Color)之间的平滑过渡,可以用于背景色、前景色等。

kotlin 复制代码
val backgroundColor: Color by animateColorAsState(
    targetValue = if (isError) Color.Red else Color.Green
)
Box(
    Modifier
        .size(100.dp)
        .background(backgroundColor)
)

在这个示例中,当 isError 状态改变时,Box 的背景色会在红色和绿色之间平滑切换。

animateIntAsState

用于整数(Int)值之间的平滑过渡,常用于大小、边距等需要整数的属性。

kotlin 复制代码
val borderThickness: Int by animateIntAsState(
    targetValue = if (isSelected) 4 else 1
)
Box(
    Modifier
        .size(100.dp)
        .border(width = borderThickness.dp, color = Color.Black)
)

当 isSelected 状态切换时,边框的厚度会在 1dp 和 4dp 之间平滑过渡。

animateOffsetAsState

用于 Offset 类型的平滑移动,用于实现类似"移动控件"之类的效果。

kotlin 复制代码
val offset: Offset by animateOffsetAsState(
    targetValue = if (isMoved) Offset(100f, 100f) else Offset(0f, 0f)
)
Box(
    Modifier
        .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
        .size(50.dp)
        .background(Color.Magenta)
)

当 isMoved 状态变化时,Box 会平滑地从 (0,0) 移动到 (100,100)。

优点和缺点

优点

  • 简单易用:便于为单一属性添加动画,API 设计简洁。
  • 状态驱动:自动根据状态变化驱动动画,减少代码量。
  • 内置插值:提供常用的动画效果,无需复杂配置。

缺点

  • 控制力度有限:不支持多阶段动画、动画中断或反向播放。
  • 性能问题:高频状态更新或大量动画时,性能可能受限。
  • 单一属性限制:仅适用于单属性动画,多属性同步动画需用其他 API。

为什么animateXxxAsState 返回的是 State<T>,而不是 MutableState<T>?

animateXxxAsState 返回的是 State<T>,而不是 MutableState<T>,这是因为它们在设计上有不同的使用场景和功能。让我们来看看 State 和 MutableState 的区别:

State 和 MutableState 的定义

  • State<T> :表示一个只读的状态对象,它允许在 Compose 中观察状态变化,但不能直接修改它的值。State 是一个接口,用于暴露给外部不可变的状态。
  • MutableState<T> :继承自 State<T>,它是一个可变的状态对象,是State的子接口可以直接通过 .value 属性对其进行修改。MutableState 是 Compose 用于管理可变状态的核心接口。

animateXxxAsState 的设计

animateXxxAsState 返回 State<T> 的原因是它用于生成一个"只读的"动画状态。这个动画状态在内部根据动画的进度逐渐更新,但它不希望外部直接修改这个状态的值,而只是用于观察和消费。

使用场景

  • State<T> :适用于只需要读取而不需要修改的场景,通常是从某些只读的计算或状态来源获取值时。
  • MutableState<T> :适用于需要读写操作的场景,通常是内部状态需要通过事件触发来修改的情况。

示例

假设有一个动画状态:

kotlin 复制代码
val animatedAlpha: State<Float> = animateFloatAsState(targetValue = if (isVisible) 1f else 0f)

在这个例子中,可以通过 animatedAlpha.value 读取动画的进度,但无法直接通过 animatedAlpha.value = newValue 修改它,因为 animatedAlpha 是 State<Float> 而不是 MutableState<Float>。

这种设计意图是为了确保动画状态只能通过动画进度进行修改,而不能被外部干扰或直接控制。

总结

  • 目标值就是下一次动画的初始值。
  • animateXxxAsState 函数非常适合实现状态变化的平滑过渡。
  • 这些函数会根据状态变化,自动触发对应的动画,并在状态切换时提供平滑过渡。
  • 可以结合 tween、spring 等 AnimationSpec 定义动画效果的细节,如动画时长、速度曲线等。

相比于传统的属性动画,Compose的动画有两个优点,一个是利用声明式架构,完成了通过属性控制对象的动画,二不需要抓着对象做动画;另一个就是利用 animateXxxAsState 将动画的初始值和目标值两种工作合为一体,进一步简化了写法。

流程定制型动画

上一节说到的animateXxxAsState在动画定制方面很受限制,它更多的是定制动画的通用特征,例如速度曲线、动画时长等,而不是以最细的细节来定制。animateXxxAsState的具体实现其实就是Animatable,为了更加简便的使用,它是Animatable的一种具体场景的实现,animateXxxAsState抛弃了设置初始值等的能力,为什么呢?因为它不需要这些能力,animateXxxAsState针对的是状态切换的动画做了专门的拓展,而状态切换或者状态转移,这种是不需要动画的初始值的。例如状态A到状态B,状态A就是初始值,所以不需要提供。

Animatable 是 Jetpack Compose 中用于控制更复杂动画的类,特别适合需要精细控制动画状态的场景。它允许直接控制动画的启动、暂停、停止以及反向等,提供了更高的灵活性。以下是 Animatable 的基本用法和核心功能。

创建 Animatable 对象

通过 remember 创建 Animatable,并指定初始值。例如:

kotlin 复制代码
val animatable = remember { Animatable(0f) }

启动动画与停止

Animatable 提供了多种控制方法:

  • snapTo:直接跳变到目标值,不做过渡,适用于需要即时响应的场景。

    kotlin 复制代码
    val animValue = remember { Animatable(0f) }
    
    // 直接将 animValue 的值设置为 100f
    LaunchedEffect(Unit) {
        animValue.snapTo(100f)
    }
  • animateTo:适合已知起点和终点的平滑过渡动画,完全受动画规格控制(如 tween、spring 等)。

    kotlin 复制代码
     LaunchedEffect(key1 = true) {
         animatable.animateTo(
             targetValue = 1f,
             animationSpec = tween(durationMillis = 1000)
         )
     }
  • animateDecay:模仿真实物理运动(如惯性滑动),不需要明确目标,而是根据初始速度和衰减算法计算最 终位置,常用于 fling 效果。animateDecay 中只能使用 decaySpec,因为它专门设计为物理衰减动画,要求通过 decaySpec 来描述和计算衰减过程。其他类型的动画规格(比如 tween 或 spring)则用于其他动画 API,而不适用于 animateDecay。

    kotlin 复制代码
    // 当组件进入组合后启动动画 
    LaunchedEffect(Unit) {
        // animateDecay 根据给定的初始速度启动动画
        offsetX.animateDecay(
            initialVelocity = 2000f,
            animationSpec = decaySpec ) 
       }
  • stop() :停止当前动画。

    kotlin 复制代码
    animatable.stop()

使用 animateTo、snapTo 或 animateDecay 启动动画,并通过协程来控制动画执行,这里注意,我们使用的是LaunchedEffect,LaunchedEffect 的一个重要特性就是它不会在每次重组时都被重新启动,而只在其依赖的 key(或 keys)发生变化时才会重新执行。下面详细说明这一点:

  • 唯一性与依赖性

    LaunchedEffect 内部启动的协程会在第一次进入组合(composition)时执行,此时传入的 key 会被保存。如果组合重新执行,但传入的 key 没有变化,则 LaunchedEffect 内部的协程不会再次启动。这有助于避免在重组过程中重复执行副作用,从而提升性能和确保状态的一致性。

  • key 的作用

    你可以把 LaunchedEffect 看作是与特定 key 相关联的"副作用"容器。如果 key 改变了,Compose 认为依赖发生了变化,会取消旧的协程并启动新的协程。例如:

    kotlin 复制代码
    @Composable
    fun Example(key: Int) {
        LaunchedEffect(key) {
            // 只有当 key 改变时,这里的代码才会重新启动
            println("LaunchedEffect with key $key executed")
        }
    }
  • 无 key 的情况

    如果没有显式地传入 key(例如使用 LaunchedEffect(Unit) 或者使用固定值),那么在组合重组过程中,只要组合项仍然存在,LaunchedEffect 内部启动的协程就不会被重复调用。这就保证了你在挂载组件后,只会启动一次你所需要的副作用逻辑。

访问动画值

Animatable 的 .value 属性始终提供当前动画进度,可实时访问以更新 UI。

kotlin 复制代码
Box(modifier = Modifier.size(100.dp).offset(x = animatable.value.dp))

设置初始速度

Animatable 支持在动画开始时设置初始速度,用于实现连续动画或模拟物理运动效果。

kotlin 复制代码
animatable.animateTo(
    targetValue = 1f,
    initialVelocity = 500f  // 设置初始速度
)

示例:点击移动动画

kotlin 复制代码
val offsetX = remember { Animatable(0f) }

Box(
    Modifier
        .size(100.dp)
        .clickable {
            // 点击时移动到新的随机位置
            coroutineScope.launch {
                offsetX.animateTo(Random.nextFloat() * 500f)
            }
        }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
)

总结

Animatable 非常适合需要精确控制动画的场景,适用范围广泛,尤其在手势和物理效果动画中很有优势。如果是基于状态转移的需求,更推荐使用animateXxxAsState,它是针对的是状态切换的动画而设计的,不需要设置初始值。在使用时,能使用animateXxxAsState就尽量使用这个。

Compose 动画之矢量化

在 Jetpack Compose 的动画系统中, "矢量化" (Vectorization)是一个核心概念,指的是将某种类型(如单个 Float、Color、Offset,乃至自定义数据类等)的动画插值,统一抽象成多维数值向量(AnimationVector)来进行计算和过渡。在代码层面上,所有的 AnimationSpec 最终都会被转换为对应的 VectorizedAnimationSpec,也就是我们通常所说的"矢量化"的过程。

一、什么是矢量化?

  1. 矢量(AnimationVector)

    • 在 Compose 动画框架中,AnimationVector 代表了多个数值维度的集合。常见的有 AnimationVector1D、AnimationVector2D、AnimationVector3D、AnimationVector4D 等,分别用于表示 1 到 4 个数值维度。

    • 为什么只提供到 4 维?大多数常见的 UI 动画场景都可以用不超过 4 个维度的数值来描述。例如:

      • 单一 Float 值:AnimationVector1D(1 维)
      • Offset 或 Size(包含 x 和 y): AnimationVector2D(2 维)
      • Color(R、G、B、A 四个通道):AnimationVector4D(4 维)
  2. 矢量化思想

    • 把原本需要动画过渡的复杂类型(如 Offset、Color、自定义数据类等),在动画过程中转为对应的 AnimationVector,然后在向量空间里完成插值或物理模拟,最终再转换回原类型。
    • 这样就能保证所有动画都遵循统一的数值插值或弹簧模型,而无需为每种复杂类型分别编写动画算法。

二、为何说两个点就对应 4 维,颜色也是 4 维?

  • 两个点对应 4 维

    例如,如果你要同时动画过渡两个点:(x1​,y1​)→(x2​,y2​)

    如果把它们合并成一个整体去插值,就相当于要同时插值x1​,y1​,x2​,y2​ 这四个数值,因此就形成了 AnimationVector4D。

  • 颜色对应 4 维

    颜色通常可以用 RGBA 通道表示(红、绿、蓝、透明度)。这就意味着想要插值一个 Color,需要同时插值这 4 个分量: (R,G,B,A), 这也就对应了 AnimationVector4D。

换句话说,矢量维数 = 需要同时插值的数值分量。正是因为我们可以把各种类型分解为若干数值分量,所以能够通用地使用 AnimationVector 进行插值运算。


三、AnimationSpec 与 VectorizedAnimationSpec 的关系

  1. AnimationSpec<T>

    • 这是一个泛型接口,Compose 针对各种基础类型(如 Float、Dp、Color)都有内置的实现(TweenSpec、SpringSpec、KeyframesSpec 等)。
    • 当我们使用 animateFloatAsState、animateColorAsState 等函数时,实际上就是在使用 AnimationSpec<Float> 或 AnimationSpec<Color> 等。
  2. VectorizedAnimationSpec

    • 每一个 AnimationSpec 最终都会被"矢量化",也就是在内部会用一个 VectorizedAnimationSpec 来执行真正的插值计算。

    • 这个过程依赖一个 TwoWayConverter,用来告诉系统如何在你的泛型类型(T)与 AnimationVector(比如 1D、2D、4D 等)之间相互转换。

    • 例如:

      kotlin 复制代码
      val MyDataConverter = TwoWayConverter<MyData, AnimationVector2D>(
          convertToVector = { value -> AnimationVector2D(value.x, value.y) },
          convertFromVector = { vector -> MyData(vector.v1, vector.v2) }
      )
      
      val myDataAnimationSpec = tween<MyData>(durationMillis = 1000)
          .vectorize(MyDataConverter)

这里的 vectorize(MyDataConverter) 就把一个针对 MyData 的 AnimationSpec,转成了内部真正执行动画插值的 VectorizedAnimationSpec。


四、矢量化带来的好处

  1. 统一插值逻辑
    无论是单个 Float,还是更复杂的 Color、Offset、自定义类,最终都可以在向量空间中用同样的 Tween、Spring、Keyframes 等方式计算,这样大大简化了动画框架的设计与使用难度。
  2. 更灵活的自定义
    我们可以自定义任何数据类,只要提供好针对该数据类的 TwoWayConverter,就能够无缝地使用 Compose 提供的各种 AnimationSpec 来做动画。
    这使得动画系统具备了很高的扩展性,可以满足各种各样的 UI 动画需求。
  3. 只需关注类型转换
    对开发者而言,重点就是如何把"某个自定义类型"拆分为若干 Float,然后在动画完成后合并回来。而动画的插值本身,可以交给 AnimationSpec(内部的 VectorizedAnimationSpec)去完成。

五、compose动画与安卓中的属性动画的对比

"矢量化"(Vectorization)是一种将任意复杂类型拆分为数值向量(AnimationVector)以便统一进行插值计算的技术,而安卓传统属性动画(Property Animation)则是一种命令式的机制,主要通过操作 View 的属性来实现动画效果。下面从多个层面详细比较它们的区别

  • 开发体验不同:

    • Compose 的矢量化让动画开发更具声明性,状态变化直接驱动动画,统一转换机制使得各种数据类型的动画处理逻辑一致且易扩展。
    • Android 属性动画则更倾向于命令式风格,需要显式地启动和管理 Animator,且在处理非 View 属性或自定义类型动画时需要额外实现评估器,整体灵活性与一致性较差。
  • 底层实现差异:

    • Compose 依赖于将所有动画参数"矢量化"成 AnimationVector,再统一计算插值;
    • Android 属性动画则基于对 View 属性的反射调用和分散的 TypeEvaluator 机制,两者在内部实现和抽象上存在明显不同。

全方位对比:

维度 Compose 的矢量化 Android 属性动画
编程范式 声明式、数据驱动,状态变化自动触发动画更新 命令式,通过 ObjectAnimator、ValueAnimator 等 API 显式控制动画
内部实现机制 通过 TwoWayConverter 将任意类型转换为 AnimationVector(1D~4D),统一使用插值/物理算法计算 基于反射直接操作对象属性,利用 TypeEvaluator 计算各属性之间的插值
支持的数据类型与灵活性 适用于 Float、Color、Offset 以及任意自定义数据类型,统一转换后可复用现有动画算法 通常对预定义属性(如 View 的属性)支持较好,自定义类型动画需要额外实现评估器
扩展与自定义 高度模块化,借助 VectorizedAnimationSpec 和 TwoWayConverter 自定义扩展简单,逻辑一致 扩展性较弱,对自定义类型支持需要手动实现动画逻辑,难度和代码量较高
与 UI 框架协同 与 Compose 的 recomposition 紧密结合,动画值自动驱动界面重绘 动画与 UI 更新相对独立,开发者需要确保动画状态与界面同步

因此,总体来说,Compose 的矢量化是一种高度统一、模块化和声明式的数据驱动动画处理方式,而安卓传统属性动画则是一种基于命令式对象操作的动画机制,两者的设计理念和适用场景各有侧重。


总结

  • 矢量化(Vectorization) 指的是将需要动画的类型拆分为若干维度的数值向量,再统一用 VectorizedAnimationSpec 来进行插值或物理模拟。
  • AnimationVector 就是这个多维度数值的抽象载体,比如 1D、2D、3D、4D 等,具体维数由我们使用的的类型需要插值的分量数决定。
  • 优势 在于让动画系统对各种类型都能采用一致的方法进行插值,极大增强了灵活性和可扩展性。

最终,我们平时在使用 animate*AsState 或者低层的 updateTransition 等动画 API 时,内核都会在内部将它们转换成 VectorizedAnimationSpec 来执行,这就是为什么可以对多维数据、颜色、甚至是自定义类型进行"统一"的过渡和动画处理。

AnimationSpect

AnimationSpect之TweenSpec

在 Jetpack Compose 中,动画的行为通常由一个 AnimationSpec 来描述, 它定义了动画的持续时间、延迟、缓动函数等参数。而 TweenSpec 则是 AnimationSpec 的一种具体实现,用于创建基于时间插值(补间动画)的动画效果。TweenSpec 用来确定动画从初始状态到结束状态的运行方式。

TweenSpec 的关键参数

  • durationMillis:指定动画的总时长(以毫秒为单位)。
  • delayMillis:设置动画开始前的延迟时间。
  • easing:定义动画的缓动函数,这个函数决定了动画在播放过程中的加速和减速方式,例如常见的 LinearEasing(线性)、FastOutSlowInEasing(先快后慢)等。

使用场景

TweenSpec 常用于诸如 animateFloatAsState、animateDpAsState 等动画 API 中,帮助实现组件属性在初始状态和目标状态之间的平滑过渡。通过调整 TweenSpec 的参数,可以轻松控制动画的节奏和风格,从而获得更符合交互设计需求的效果。

示例代码

下面是一个使用 TweenSpec 的简单示例:

kotlin 复制代码
val animatedValue by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween( // 利用tween创建TweenSpec动画
        durationMillis = 1000,     // 动画持续1秒
        delayMillis = 0,           // 无延迟
        easing = FastOutSlowInEasing  // 使用先快后慢的缓动函数
    )
)

在这个例子中,animateFloatAsState 会利用 TweenSpec 定义的动画规格,从当前值平滑地过渡到目标值 1f。

常见的缓动函数及其使用场景

在 Jetpack Compose 中,缓动函数(Easing)决定了动画在播放过程中速率的变化曲线,从而直接影响动画的"手感"和自然度。下面介绍几种常见的缓动函数及其使用场景:

1. LinearEasing
  • 特点:动画以恒定速度进行,既没有加速也没有减速。

  • 适用场景:当需要一个匀速运动的动画效果时,如进度条的均匀更新。

  • 示例代码:

    kotlin 复制代码
    animationSpec = tween(
        durationMillis = 1000,
        easing = LinearEasing
    )
2. FastOutSlowInEasing
  • 特点:动画在开始时较快,然后在结束前逐渐放缓。这个效果符合大多数物理运动规律,使得动画看起来更自然。

  • 适用场景:Material Design 中经常使用这种缓动函数,如页面切换、组件过渡等。

  • 示例代码:

    kotlin 复制代码
    animationSpec = tween(
        durationMillis = 1000,
        easing = FastOutSlowInEasing
    )
3. LinearOutSlowInEasing
  • 特点:动画起始阶段是线性的,随后逐渐变慢。相比于 FastOutSlowInEasing,它更强调结束时的平滑过渡。

  • 适用场景:适用于需要在动画结束时平滑收尾的场景,比如控件淡出或缩小。

  • 示例代码:

    kotlin 复制代码
    animationSpec = tween(
        durationMillis = 1000,
        easing = LinearOutSlowInEasing
    )
4. FastOutLinearInEasing
  • 特点:动画起始时迅速加速,随后以线性方式保持运动速度。它的重点在于动画的开始部分,适合需要强调启动动作的场景。

  • 适用场景:适用于需要在动画初期快速获得动感,而后保持稳定状态的动画效果。

  • 示例代码:

    kotlin 复制代码
    animationSpec = tween(
        durationMillis = 1000,
        easing = FastOutLinearInEasing
    )
5. 自定义缓动函数(CubicBezierEasing)
  • 特点:通过指定四个控制点来自定义缓动曲线,可以实现任意所需的运动曲线。

  • 适用场景:当内置的缓动函数无法满足特殊动画需求时,可以通过 CubicBezierEasing 来精确控制动画节奏。

  • 示例代码:

    kotlin 复制代码
    val customEasing = CubicBezierEasing(0.2f, 0f, 0.8f, 1f)
    animationSpec = tween(
        durationMillis = 1000,
        easing = customEasing
    )

    可以在这个网站上进行三阶贝塞尔曲线的调试:cubic-bezier.com/#.75,.18,.3...

注意:三阶贝塞尔曲线实际上由四个点组成:起点、终点和两个控制点。在动画缓动函数的应用中,起点和终点通常固定为 (0,0) 和 (1,1),分别代表动画的开始和结束状态。因此,只需要输入中间两个控制点的参数,就能完全决定曲线的形状,从而控制动画的加速和减速曲线。

换句话说,虽然数学上需要四个点来描述曲线,但因为起点和终点是固定的,所以我们只需指定两个控制点的信息。

AnimationSpec 之 SnapSpec

SnapSpec 是 Jetpack Compose 中 AnimationSpec 的一种实现,与 TweenSpec 或 SpringSpec 不同,SnapSpec 用于创建"瞬间"完成的动画效果,也就是说,它不会在开始和目标状态之间进行平滑过渡,而是直接跳到目标状态。


SnapSpec 的特点

  • 瞬间跳转
    SnapSpec 不进行中间状态的插值,动画效果是立即切换到目标值。这使得它非常适合用于离散状态的变化,而非连续的平滑动画。
  • 延迟参数
    虽然动画效果本身是瞬间完成的,但 SnapSpec 允许设置 delayMillis 参数,从而可以在跳转之前延迟一段时间。
  • 简单明了
    当你不需要渐变动画,而只需要在状态改变时立刻反映结果时,SnapSpec 提供了一个简单而直接的方式来控制动画行为。

使用场景

  • 离散状态切换
    例如,当 UI 状态从"加载中"切换到"加载完成"时,直接跳转到最终状态更符合用户预期,而不需要过渡动画。
  • 即时反馈
    在某些交互场景中,为了确保界面能够迅速响应用户操作,使用 SnapSpec 可以确保状态变化不受动画时长的影响。

示例代码

下面的示例展示了如何使用 SnapSpec 来创建一个瞬间完成的动画效果:

kotlin 复制代码
val animatedValue by animateFloatAsState(
    targetValue = 1f,
    animationSpec = SnapSpec(
        delayMillis = 100  // 可选:设置动画开始前的延迟时间(毫秒)
    )
)

在这个例子中,animatedValue 会在 100 毫秒的延迟后,立即变为 1f,而不会经历任何中间状态的过渡。

AnimationSpec 之 KeyframesSpec

KeyframesSpec 是 Jetpack Compose 中 AnimationSpec 的一种实现,它允许开发者在动画过程中指定多个关键帧,从而使动画在特定时刻达到预定的状态。与 TweenSpec 或 SpringSpec 相比,KeyframesSpec 提供了更多对动画细节的控制,你可以在动画的不同时间点上定义目标值和对应的缓动效果。默认的动画效果是 LinearEasing。整体来讲就是一个分段式的TweenSpec而已


KeyframesSpec 的特点

  • 关键帧控制
    通过定义多个关键帧,可以精确指定动画在不同时间点上的状态,使动画过程更符合设计需求。
  • 灵活的时间配置
    除了设置动画的总体持续时间(durationMillis),还可以为每个关键帧指定具体的时间点(以毫秒为单位),从而让动画在这些时间点上达到预期的值。
  • 自定义缓动函数
    每个关键帧不仅可以设置目标值,还可以单独指定该阶段的缓动函数(easing),从而实现不同阶段的运动特性。

示例代码

以下代码展示了如何使用 KeyframesSpec 来创建一个带有多个关键帧的动画:

kotlin 复制代码
val animatedValue by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 1000  // 整个动画总时长 1000 毫秒
        
        // delayMillis = 500ms // 设置延时500ms启动

        // 在第 300 毫秒时,动画值达到 0.2,并采用线性缓动,执行时间是在300ms~600ms
        0.2f at 300 with LinearEasing

        // 在第 600 毫秒时,动画值达到 0.8,并采用先快后慢的缓动曲线 从600ms~1000ms之间执行
        0.8f at 600 with FastOutSlowInEasing

        // 动画最终在 1000 毫秒时达到目标值 1f
    }
)

在这个例子中:

  • 动画从初始值开始,经过 300 毫秒时达到 0.2f,600 毫秒时达到 0.8f,最终在 1000 毫秒时到达目标值 1f。
  • 每个关键帧都可以单独指定缓动函数,使得动画在不同阶段表现出不同的加速和减速效果。

使用场景

  • 复杂动画序列
    当动画需要在过程中表现出多个阶段或状态时,KeyframesSpec 可以提供更加细致的控制,确保动画在预定时刻达到准确的数值。
  • 特定交互反馈
    如果需要在动画过程中传递特定的信息(如中途的视觉提示或状态切换),可以通过关键帧精确调整动画进程。
  • 自定义动画曲线
    针对设计要求,需要在动画的各个阶段设置不同的运动曲线,KeyframesSpec 能够满足这种灵活性需求。

注意:由于 KeyframesSpec 的设置可以很详细,也会导致它的复用性降低,例如在那种进入和退出相反的动画中的是偶,就需要在配置的时候进行退出和进入的特殊判断,变得更麻烦了。更好的解法是退出和进入使用不同的 KeyframesSpec 就可以了。

TweenSpec(默认是300ms)、SnapSpec(默认是0ms)、KeyframesSpec(默认是300ms) 都是 DurationBasedAnimationSpec 的实现类,他们都是基于时长的,也就是时长是确定的动画。

AnimationSpec 之 SpringSpec

SpringSpec 是 Jetpack Compose 中 AnimationSpec 的一种实现,它基于弹簧物理模型模拟动画效果,通过调整物理参数来生成更加自然、富有弹性和回弹感的动画。与基于时间插值的 TweenSpec 或多关键帧控制的 KeyframesSpec 不同,SpringSpec 的动画过程更贴近真实物理运动,更适合用于需要表现弹性和振荡效果的场景。注意,它无法设置精确的停止时间。


关键参数

  • dampingRatio(阻尼比)

    • 功能:控制弹簧振荡的程度。

    • 说明:

      • 当 dampingRatio < 1 时,弹簧将出现过冲和振荡效果(欠阻尼)。
      • 当 dampingRatio == 1 时,达到临界阻尼,动画平滑结束,不振荡。
      • 当 dampingRatio > 1 时,动画将过阻尼,响应较慢但不会产生振荡。
  • stiffness(刚度)

    • 功能:定义弹簧的硬度,影响动画的速度和响应。

    • 说明:

      • 较低的 stiffness 值会使动画过程变得柔和、持续时间延长;
      • 较高的 stiffness 值会使动画迅速到达目标状态。
  • visibilityThreshold(可选)

    • 功能:设定动画停止判断的阈值,当目标值和当前值之间的差异低于该阈值时,动画被视为完成。
    • 说明:这对于避免无限接近目标值而无法完全停止的问题非常有用。

使用示例

示例1:

下面的示例展示了如何使用 SpringSpec 来创建一个弹性动画,模拟自然的物理运动效果:

kotlin 复制代码
val animatedValue by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioMediumBouncy, // 中等弹性的阻尼比
        stiffness = Spring.StiffnessLow                 // 较低的刚度,使动画过程柔和
    )
)

在这个例子中,我们使用 spring() 构建了一个 SpringSpec,其中:

  • dampingRatio 设置为 Spring.DampingRatioMediumBouncy,使动画有适中的弹性和回弹感;
  • stiffness 设置为 Spring.StiffnessLow,使动画的响应较为柔和和自然。

示例2: 将一个值从 2000.dp 弹簧式地动画过渡到 48.dp,并且带有回弹的物理效果。由于低阻尼和中等刚度的设置,回弹会产生明显的震动效果

kotlin 复制代码
anim.animateTo(
    48.dp, // 目标值是 48.dp
    spring(
        dampingRatio = 0.1f, // 低阻尼,弹簧震动多次回弹
        stiffness = Spring.StiffnessMedium // 中等刚度,弹簧的回弹程度和速度
    ),
    2000.dp // 当前值是 2000.dp,动画从 2000.dp 过渡到 48.dp
)
  • animateTo(48.dp, spring(...), 2000.dp)

    这行代码使用了 animateTo 函数,表示将当前值从 2000.dp 动画过渡到目标值 48.dp。

  • spring(dampingRatio = 0.1f, stiffness = Spring.StiffnessMedium)

    这里使用了 spring 插值器来控制动画的物理行为:

    • dampingRatio = 0.1f:低阻尼(较小的阻尼比),使得弹簧震动效果显得更加明显,会有多个回弹。
    • stiffness = Spring.StiffnessMedium:中等的刚度,控制弹簧的弹力,适中的弹力产生正常的回弹效果。
  • 2000.dp 和 48.dp

    • 动画的起始值是 2000.dp,目标值是 48.dp。动画会使得某个值从大到小变化,模拟一种从很大位置快速回到较小位置的效果,通常用于类似弹簧、回弹等效果。

微信的炸弹效果就可以用类似的方式实现。


使用场景

SpringSpec 特别适用于以下场景:

  • 自然反弹效果:例如,当用户拖拽释放浮动按钮或卡片时,希望看到一个自然的反弹效果。
  • 界面组件的弹性过渡:如对话框、列表项或其他 UI 组件的出现和消失时,带有一点弹跳感,使过渡更具动感。
  • 物理反馈动画:当需要模拟真实物理运动,比如滚动后惯性减速和回弹,SpringSpec 能提供更贴近自然规律的动画表现。

AnimationSpec 之 RepeatableSpec

AnimationSpec 定义了动画的时间、速度曲线以及插值方式。RepeatableSpec 则是在一个给定的 AnimationSpec 基础上,通过指定重复的次数以及重复模式,来实现动画的重复执行。通常我们通过 repeatable 函数来创建 RepeatableSpec。

  • RepeatableSpec 允许对动画进行重复控制,其中 iterations、animation 和 repeatMode 三个关键参数决定了动画的重复次数、单次动画细节以及重复播放时的行为。

  • Reverse 模式与 Restart 模式 会对动画的最终状态产生不同影响。对于 Reverse 模式,偶数次数会使动画回到初始状态,而奇数次数则最终停留在目标状态;而 Restart 模式下,每个循环都会从初始状态开始播放。


关键参数

  • iterations

    • 说明:指定动画需要重复的次数。
    • 用途:例如设为 3 表示动画会执行三次。
  • animation

    • 说明:内部使用的动画规范,可以是 tween、spring、keyframes中的任意一个,不能是SpringSpec,当然也不能重复自己。
    • 用途:定义每一次重复时动画的具体行为。
  • repeatMode

    • 说明:控制重复动画的执行模式,通常有两种模式:

      • RepeatMode.Restart:每次重复时动画从头开始。
      • RepeatMode.Reverse:动画在每次重复时先正向执行,然后倒序返回,形成往返动画效果。
  • initialStartOffset(可选)

    • 说明 :定义动画开始前的初始偏移,可以用来延迟第一轮动画的启动,指的是时间的偏移,而不是位置的偏移。
      • StartOffsetType.Delay:动画延时执行
      • StartOffsetType.FastForward:快进到指定时间,直接从指定的时间开始执行动画。有点类似于看电影的时候执行快进到指定的位置。因此会会给人感觉就是跳过动画的一些帧数。

使用示例

使用 Reverse 模式的示例
kotlin 复制代码
@Composable
fun RepeatableRestartAnimationDemo() {
    val animatable = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        animatable.animateTo(
            targetValue = 200f,
            animationSpec = repeatable(
                iterations = 4, // 不论偶数或奇数,每次循环结束都会回到初始状态
                animation = tween(durationMillis = 1000),
                repeatMode = RepeatMode.Restart
            )
        )
    }

    Box(
        modifier = Modifier
            .size(200.dp)
            .offset(x = animatable.value.dp)
            .background(Color.Red)
    )
}

说明:

  • 以上示例中,动画会循环执行 4 次:

    • 第 1 次:0f → 100f
    • 第 2 次:100f → 0f
    • 第 3 次:0f → 100f
    • 第 4 次:100f → 0f
  • 使用 Reverse 模式时,由于迭代次数为偶数,最终动画状态为初始值 0f。如果希望动画结束时停留在 100f,可以考虑使用奇数次数或者调整使用的重复模式为 Restart。

当模式是Reverse的时候,且初始值大于目标值的时候,一定不能重复偶数次,否则会出现下边的情况

上边的代码调整:

kotlin 复制代码
@Composable
fun RepeatableRestartAnimationDemo() {
    val animatable = remember { Animatable(200f) }
    val scope = rememberCoroutineScope()

    Box(
        modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 注意这里仅使用 offset 进行细微调整,不要让整个 Box 脱离屏幕
                .offset(x = animatable.value.dp)
                .background(Color.Red)
                .clickable {
                    scope.launch {
                        animatable.animateTo(
                            targetValue = 0f,
                            animationSpec = repeatable(
                                iterations = 4,
                                animation = tween(durationMillis = 1000),
                                repeatMode = RepeatMode.Reverse
                            )
                        )
                    }
                }
        )
    }
}
  • 以上示例中,动画会循环执行 4 次:
    • 第 1 次:200f → 0f
    • 第 2 次:0f → 200f
    • 第 3 次:200f → 0f
    • 第 4 次:0f → 200f

由于 Reverse 模式以及迭代数为偶数,所以动画最终会回到起始值 200f,而不是停留在 animateTo 时指定的目标值 0f。预期动画应该停留在 0f,而实际上在最后一帧突然跳回 200f,因此需要调到0f的位置上去,此时是没有动画的。

使用 Restart 模式的示例
kotlin 复制代码
@Composable
fun RepeatableRestartAnimationDemo() {
    val animatable = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        animatable.animateTo(
            targetValue = 100f,
            animationSpec = repeatable(
                iterations = 4, // 不论偶数或奇数,每次循环结束都会回到初始状态
                animation = tween(durationMillis = 1000),
                repeatMode = RepeatMode.Restart
            )
        )
    }

    Box(
        modifier = Modifier
            .size(50.dp)
            .offset(x = animatable.value.dp)
            .background(Color.Red)
    )
}

说明:

  • 在 Restart 模式下,每个动画周期都会从起始值(这里是 0f)重新开始播放,即使目标值是 100f。
  • 动画每次执行完后都会"重启",最终状态同样取决于动画结束时最后一次的播放效果和后续逻辑处理。

使用场景

  • 循环动画
    当需要实现循环性、往返性的动画效果(如呼吸效果、心跳效果、脉冲效果等)时,RepeatableSpec 能够提供非常直观的实现方式。
  • 用户交互反馈
    对于某些交互场景,例如按钮点击后的反馈动画,重复的动画效果可以增强用户体验。
  • 渐变动画
    在需要连续展示动画过渡(例如色彩渐变或者控件状态循环切换)的场景中,RepeatableSpec 可用于控制动画重复次数与模式,确保动画表现符合预期。

AnimationSpec 之 InfiniteRepeatableSpec

InfiniteRepeatableSpec 是 Jetpack Compose 中 AnimationSpec 的一种实现,用于创建无限重复的动画效果。与 RepeatableSpec 类似,它也可以指定动画的重复模式,但不同的是 InfiniteRepeatableSpec 不需要指定重复次数,而是会持续不断地循环播放动画,直到动画被取消(也就是所在协程被终止执行)。


关键参数

  • animation

    定义内部使用的动画规范,可以使用 tween、spring、keyframes 等。该参数决定了每一轮动画的表现形式。

  • repeatMode

    控制动画每次循环的播放模式,主要有两种:

    • RepeatMode.Restart:每次动画重启时,都会从初始状态重新开始播放。
    • RepeatMode.Reverse:动画在每次循环时先正向播放,再反向回到初始状态,从而产生往返运动的效果。
  • initialStartOffset (可选)

    指定第一次动画启动前的延迟偏移量,以便对动画首次开始的时机进行微调。


使用示例

下面的示例展示了如何使用 InfiniteRepeatableSpec 创建一个无限往返播放的动画。此动画会以 tween 动画为基础,每次循环持续 1000 毫秒,并在正向和反向之间来回切换:

kotlin 复制代码
LaunchedEffect(Unit) {
    val animatedValue by animateFloatAsState(
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1000),
            repeatMode = RepeatMode.Reverse
        )
    )
}

在这个示例中:

  • tween(durationMillis = 1000) 定义了每轮动画持续 1 秒的时间。
  • RepeatMode.Reverse 使得动画在达到目标值后反向回到起始值,形成连续的往返效果。

使用场景

  • 加载动画
    当需要展示一种持续循环的加载状态(例如旋转图标或脉冲效果)时,InfiniteRepeatableSpec 能够很好地满足需求。
  • 动态背景和装饰效果
    对于需要不断变化的背景动画或 UI 装饰效果,使用无限重复动画可以为用户界面增添活力和趣味性。
  • 用户交互提示
    当需要对某个状态持续进行视觉反馈,比如按钮的呼吸动画或警示效果,无限循环的动画能够不断提醒用户当前状态。

AnimationSpec 之其他 Spec

除了前面介绍的几种最常用的 AnimationSpec 外,Jetpack Compose 还提供或允许自定义其他动画规格,其中较为常见的有 DecayAnimationSpec,以及通过实现 AnimationSpec 接口来自定义符合特殊需求的动画规格。


DecayAnimationSpec

DecayAnimationSpec 是 Jetpack Compose 中的一种动画规格,用于创建基于衰减的动画效果,通常用于模拟物理世界中的自然衰减行为。例如,当一个物体或界面元素运动时,它会随着时间的推移逐渐减速并最终停止,或者是滑动聊天列表,手指离开屏幕的时候,页面慢慢停下来,DecayAnimationSpec 可以很好地模拟这种效果。 DecayAnimationSpec 被设计为一个接口或抽象类型,其具体实现类是私有的,不允许直接通过构造函数进行实例化。

衰减的特性通常通过以下参数来控制:

  • initialVelocity:初始速度,决定了动画开始时的速度。
  • animationStartDelayMillis:动画开始前的延迟时间,单位是毫秒。
  • friction:摩擦系数,用来控制衰减的速度。摩擦值越大,衰减的速度越快。
对比animateTo,他们的区别是什么?

DecayAnimationSpec 是基于速度衰减的动画规格,不能直接与 animateFloatAsState 一起使用。animateFloatAsState 适用于基于目标值的过渡动画,而 DecayAnimationSpec 适用于模拟自然衰减过程的动画。

  • DecayAnimationSpec 适用于基于物理衰减的自然效果,通常用来模拟滑动、惯性等场景,动画结束时没有明确的目标值,结果由物理参数控制。

  • animateTo 则用于精确的过渡效果,明确指定目标值并控制动画的持续时间,是指从一个明确状态到另一个状态的过渡动画。

衰减动画的三个函数
  • 指数衰减(exponentialDecay<>()) :实现简单,适合基本的衰减需求,但物理感和自然度可能不足。 当我们需要快速实现一个大致符合衰减特性的动画,且对物理真实性要求不高时,使用 exponentialDecay 就可以满足需求。

  • 样条衰减(splineBasedDecay<>()) :通过样条插值实现,更贴近真实物理效果,可以让惯性滑动的摩擦力可以和设备的像素密度相关联起来,像素密度越大的设备,它的摩擦力也越大(为什么呢?这是因为我们的滑动单位是像素,在像素密度越高的设备上,滑动同样的像素距离,它的物理距离是更小的,模拟出来就是摩擦力更大)。适用于对动画平滑性和自然感要求较高的场景。通常在 fling 或滑动控件中使用,能够让动画具有更加平滑的减速曲线和更自然的停止效果。

  • 记忆化的样条衰减(rememberSplineBasedDecay()) :在样条衰减基础上引入缓存,避免重组时重复计算,兼具高效性和自然流畅的动画效果,因此在实际开发中更为推荐使用。

使用

需要注意的事,不推荐在 splineBasedDecay 的泛型中使用 Dp ,splineBasedDecay 的设计目标就是在使用正确的单位(通常是像素)时,根据设备的像素密度进行补正,使得动画效果在不同设备上表现一致:

  • 默认使用像素类型(如 Float 或 Int)时:

    动画内部的衰减逻辑会根据设备的像素密度自动进行相应的补偿与调整,确保在不同设备上都有合适的减速曲线。例如,在高像素密度设备上因为物理像素更多,动画表现上减速可能会更快一些;而低密度设备则相应慢一些,这样的调整有助于在视觉上获得一致的手感和物理反馈。

  • 如果将泛型换成 Dp:

    Dp 是一个抽象单位,本身已经抽离了具体的像素概念,一旦使用 Dp,动画系统就不再需要进行基于像素密度的自动纠正。可是,内部逻辑依然会按照原有的物理模型对输入值进行调整(例如,高密度设备减速快,低密度设备减速慢),这就可能出现一种"叠加补正"的情况:

    • 在高密度设备上,由于动画逻辑仍然认为需要更快减速,加上 Dp 单位本身已经剔除了设备差异,结果就是减速更快;
    • 在低密度设备上,情况也类似,可能导致减速变得更慢。

这种叠加补正会使得预期的动画行为偏离原本自然的物理运动效果,从而得不到我们期望的手势响应。

对于 splineBasedDecay 这类衰减动画函数,其内部逻辑是基于像素计算的,所以推荐直接操作与物理像素相关的类型(如 Float 或 Int),而非 Dp。这样可以确保系统根据设备的像素密度自动进行调整,防止了因单位转换产生的重复补正,从而保证动画效果符合预期。

另外,一般的 splineBasedDecay 也不能在带有角度的动画中使用,例如转圈这种,这是因为:

  • 角度是一种不依赖于屏幕密度的量

    角度(无论是以度数还是弧度表示)在所有设备上都是固定的,不会因为屏幕的dpi不同而发生变化。相比之下,splineBasedDecay 这类基于物理运动模型的动画内部逻辑是根据像素进行补偿的,其目的正是为了使基于像素的运动在不同设备上达到一致的视觉效果。

  • 在转圈这类动画中的问题

    如果直接使用 splineBasedDecay 对角度进行动画,由于内部的补正逻辑会按照设备的像素密度来调整运动曲线,在像素密度较高的设备上,会因为补正得更强,从而导致角度动画(转圈)的衰减速度更快,表现不一致。

  • 解决方案:方向修正

    如果确实想用这类衰减动画模型来处理角度动画,就需要做一个转换,使得我们在动画中操作的角度变量不再是真正的角度,而是与设备dpi成正比的一个值。这样,内部的像素密度补正就可以正确地应用于这个"虚拟角度",从而获得预期的一致效果。

总结一下exponentialDecay与splineBasedDecay使用场景的区别:

  • splineBasedDecay 与像素相关

    • splineBasedDecay 是从 Android 原生的 Scroller 算法搬过来的,内部逻辑严格依赖于像素值,并且会根据设备的 DPI 做补正。这就意味着如果用它来处理角度或其他与像素无关的量,设备之间的效果会不一致。例如,对于旋转动画,角度在所有设备上都是一致的,但 splineBasedDecay 内部按像素计算,会在高密度设备上显示更快的减速,从而导致预期之外的动画效果。
    • 因此,使用 splineBasedDecay 时必须事先将数据转换为像素,动画结束后再转换回你需要的单位。这样才能避免叠加了不必要的补正效果。
  • exponentialDecay 面向非像素场景

    • exponentialDecay 则不同,它不针对像素密度进行自动修正。它设计上更适用于那些本身就是以 dp 或其他与设备无关单位为基础的动画场景,比如颜色变化、抽象数值动画等。
    • 如果你的动画效果不是严格依赖于实际像素(例如你希望在所有设备上衰减效果相同),那么 exponentialDecay 能保证一致性,而不会受屏幕 DPI 的干扰。
  • 为什么exponentialDecay 相比较于splineBaseDecay 没有remenber版本?

    • 由于 splineBasedDecay 需要依赖于当前设备的 density 来计算补正参数,所以框架提供了 rememberSplineBasedDecay 这样的缓存版本,方便在 Composable 内部重组时避免重复创建对象。
    • 而 exponentialDecay 本身没有内置 remember 版本,是因为它不需要依据 Density 参数做额外的补正,所以在性能和状态管理上没那么依赖于记忆化。但在 Composable 中,为避免不必要的实例重复创建,依然可以自己用 remember 包裹 exponentialDecay 的调用。
  • 重点

    • 如果面向像素的动画(例如平移、缩放等),应当使用 splineBasedDecay,它会根据设备 DPI 进行自动修正;但如果动画量是角度、颜色等与像素无关的量,就应选用 exponentialDecay。
    • 而且在使用 splineBasedDecay 进行非像素动画(例如角度动画)时,其内部密度修正会使效果叠加错误,这时必须先转换成像素来处理,动画结束后再转换回来。
    • 关于 exponentialDecay 没有内置的 remember 版本,确实是因为它不依赖 density 参数,但在 Composable 内部仍然建议使用 remember 来缓存实例。
示例 1:使用 exponentialDecay<>()

此示例展示如何利用 exponentialDecay 创建一个简单的基于指数衰减的动画规格。这里我们使用一个 Animatable 对象,并调用 animateDecay 方法,使一个视图在水平方向上根据初始速度进行衰减运动。

kotlin 复制代码
@Composable
fun ExponentialDecayExample() {
    // 创建一个 Animatable,用于动画控制 Float 类型的偏移量
    val offsetX = remember { Animatable(0f) }
    // 使用 exponentialDecay 创建衰减动画规格,通常可传入默认参数或调整 friction 参数
    val decaySpec = remember{exponentialDecay<Float>()}

    // 当组件进入组合后启动动画
    LaunchedEffect(Unit) {
        // animateDecay 根据给定的初始速度启动动画
        offsetX.animateDecay(
            initialVelocity = 2000f,
            animationSpec = decaySpec
        )
    }

    // 根据 Animatable 的值动态调整 Box 的水平位置
    Box(
        modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }
    ) {
        // 这里可以放置内容,例如一个显示文本或图形的组件
    }
}

说明

  • 使用 exponentialDecay<Float>() 得到的规格会让动画值按照指数规律衰减。
  • 在动画中,通过设置一个较大的初始速度(例如 2000f),视图会迅速开始滑动,然后随着时间以指数速率减速。

示例 2:使用 splineBasedDecay<>()

下面的示例演示了如何使用 splineBasedDecay 创建一个基于样条插值的衰减动画。此方式常用于需要更加真实手势滑动效果的场景。

kotlin 复制代码
@Composable
fun SplineBasedDecayExample() {
    val offsetX = remember { Animatable(0f) }
    // 创建基于样条插值的衰减规格,此模型会更符合真实手势滑动的惯性效果
    val decaySpec = splineBasedDecay<Float>()

    LaunchedEffect(Unit) {
        offsetX.animateDecay(
            initialVelocity = 2000f,
            animationSpec = decaySpec
        )
    }

    Box(
        modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }
    ) {
        // 可以放置需要展示的组件
    }
}

说明

  • 与指数衰减不同,splineBasedDecay 使用样条曲线来计算动画轨迹,能够更细腻地模拟实际滑动的惯性减速。
  • 适用于那些对动画流畅性和自然感要求更高的场景。

示例 3:使用 rememberSplineBasedDecay()

这个示例展示了如何使用 rememberSplineBasedDecay 来缓存衰减动画规格对象。在 Compose 中,重组会频繁发生,使用 remember 可以避免重复创建动画规格,提高性能。

kotlin 复制代码
@Composable
fun RememberSplineBasedDecayExample() {
    val offsetX = remember { Animatable(0f) }
    // 使用 rememberSplineBasedDecay 缓存一个基于样条的衰减规格实例
    val decaySpec = rememberSplineBasedDecay<Float>()

    LaunchedEffect(Unit) {
        offsetX.animateDecay(
            initialVelocity = 2000f,
            animationSpec = decaySpec
        )
    }

    Box(
        modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }
    ) {
        // 显示动画的组件内容
    }
}

说明

  • 使用 rememberSplineBasedDecay<Float>() 可以使得在每次重组时不必重新创建衰减规格,从而降低计算成本。
  • 它既保持了样条衰减带来的平滑动画效果,又优化了性能,特别适合复杂界面中频繁重组的场景。
总结

DecayAnimationSpec 可以帮助我们实现基于衰减的动画效果,常见于需要模拟物理现象的场景。通过调整 initialVelocity 和 friction 参数,我们可以控制衰减动画的表现,让它更符合你的需求。


自定义 AnimationSpec

除了系统内置的动画规格之外,你还可以根据需求实现自定义 AnimationSpec。只需要实现 AnimationSpec 接口,然后定义你自己的插值逻辑和动画曲线,这在一些特殊的动画场景下非常有用。例如,你可以设计一种特殊的运动规律或非线性过渡,来满足独特的交互设计需求。

实现思路
  • 实现 AnimationSpec 接口:重写动画计算方法,根据输入的初始状态、目标状态、当前时间等参数计算当前动画值。
  • 结合业务需求:可以基于现有的数学模型进行扩展,或者结合物理、贝塞尔曲线等方法设计出符合业务需求的动画曲线。

虽然大多数场景下内置的动画规格(如 TweenSpec、SpringSpec、KeyframesSpec、RepeatableSpec、InfiniteRepeatableSpec 和 DecayAnimationSpec)已经能满足绝大多数需求,但自定义 AnimationSpec 提供了极高的灵活性,适用于需要特殊动画效果的情况。


小结

  • DecayAnimationSpec :用于基于惯性衰减的动画效果,非常适合滑动和抛掷类动画,常见实现方式为 exponentialDecay。
  • 自定义 AnimationSpec:当内置规格无法满足特定需求时,你可以通过实现 AnimationSpec 接口来定义专属的动画逻辑。

最后的补充:Block 参数,监听每一帧

在 Jetpack Compose 的动画 API 中,许多动画函数(例如 Animatable 的 animateTo、animateDecay 等)允许传入一个 lambda 参数,也就是所谓的 "block 参数"。这个参数的主要作用是监听动画在每一帧的更新.

kotlin 复制代码
suspend fun animateTo(
    targetValue: T,
    animationSpec: AnimationSpec<T> = defaultSpringSpec,
    initialVelocity: T = velocity,
    block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> {
    val anim = TargetBasedAnimation(
        animationSpec = animationSpec,
        initialValue = value,
        targetValue = targetValue,
        typeConverter = typeConverter,
        initialVelocity = initialVelocity
    )
    return runAnimation(anim, initialVelocity, block)
}

suspend fun animateDecay(
    initialVelocity: T,
    animationSpec: DecayAnimationSpec<T>,
    block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> {
    val anim = DecayAnimation(
        animationSpec = animationSpec,
        initialValue = value,
        initialVelocityVector = typeConverter.convertToVector(initialVelocity),
        typeConverter = typeConverter
    )
    return runAnimation(anim, initialVelocity, block)
}

Block 参数的作用

  • 逐帧监听动画状态
    当动画运行时,每一帧都会计算新的动画值,传给这个 block 参数。你可以通过它获取当前帧动画的值,甚至可以根据这个值做额外的逻辑处理(比如更新其他状态、执行日志打印、调试分析等)。
  • 实现动画实时反馈
    通过 block 参数,你可以让动画不仅负责数值的插值,也能"反馈"当前状态至其它 UI 或逻辑中。例如,在复杂的动画场景中,你可能希望根据动画值实时调整其他组件的位置或属性。
  • 灵活控制与调试
    该参数可以帮助你在开发过程中追踪动画的进展,及时了解到动画当前处于哪个阶段。如果需要在动画进行时做一些附加的操作(例如处理特定的业务逻辑),都可以在这个 block 中实现。

代码示例

以 Animatable.animateTo 为例,下面代码展示了如何使用 block 参数监听动画的每一帧更新:

kotlin 复制代码
@Composable
fun AnimateToWithFrameListener() {
    // 创建一个可动画化的状态值
    val animValue = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        // 使用 animateTo 启动动画,并传入一个 block 参数监听每一帧
        animValue.animateTo(
            targetValue = 100f,
            animationSpec = tween(durationMillis = 1000)
        ) { currentValue ->
            // 这里的 currentValue 就是动画在当前帧计算得到的值
            // 可以在这里处理每一帧的动画状态,例如更新日志或驱动其他状态变化
            println("当前动画值: $currentValue")
        }
    }
}

在这个例子中,每一帧动画更新时,block 参数都会被调用,你可以在其中捕获当前动画值并输出,也可以根据需要执行其他操作。


使用注意事项

  • 性能考虑
    由于 block 参数在动画的每一帧都会执行,内部的操作务必保持轻量,避免执行耗时任务,以免影响动画的流畅度。
  • 线程与协程
    动画相关的函数通常运行在协程中,因此在 block 内部不要进行阻塞性操作,否则会拖慢整个动画进程。
  • 避免副作用干扰动画逻辑
    如果 block 参数里涉及 UI 更新,确保这些更新不会引起不必要的重组或耗时计算,保持动画过程尽可能平滑。

重点掌握下边动画的使用

再探索 Animatable:边界限制、结束与取消

新动画的执行会导致正在执行的动画被打断

动画执行过程中针对同一个Animatable对象(无论是否是在同一个协程中)再执行新动画,旧的动画会被停止,被打断。这种情况下Compose会在原来正在执行的动画协程里边抛出一个异常。

kotlin 复制代码
@Composable
fun ComposeAnimatableLeadingTheme() {
    // 将动画的初始值设置为 0.dp,并使用 Dp.VectorConverter 以支持 Dp 类型
    val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
    
    // 创建一个针对 Dp 类型的指数衰减动画规格
    val decay = exponentialDecay<Dp>()

    // 第一个协程:让值从 0.dp 以初始速度"抛"向正方向(如 1000.dp)
    LaunchedEffect(Unit) {
        try {
            // animateDecay 的第一个参数是初始速度或"抛出"量,这里可视为将值甩到 +1000.dp
            anim.animateDecay(1000.dp, decay)
        } catch (e: CancellationException) {
            println("动画被取消或中断:${e.message}")
        }
    }

    // 第二个协程:将同一个 Animatable 值再甩到 -1000.dp
    LaunchedEffect(Unit) {
        anim.animateDecay(-1000.dp, decay)
    }

    // 一个简单的示例UI,将 Box 在水平方向上随 anim 的值进行偏移
    Box(
        modifier = Modifier
            .size(400.dp) // 外层容器大小,演示用
            .background(Color.LightGray)
    ) {
        Box(
            modifier = Modifier
                // offset 接收 Dp 类型时,直接使用 anim.value 即可
                .offset(x = anim.value)
                .size(100.dp)
                .background(Color.Green)
        )
    }
}

同样的,animateDecay animateTo snapTo都可以相互打断对方。

主动结束一个正在进行中的动画

通过调用 Animatable 的 stop() 方法在动画进行中主动终止动画。由于stop animateTo 等都是挂起函数,因此我们不能直接让两个代码串行执行,这样是没有效果的,正如实例中那样,必须要用两个不同的协程取消。

示例中使用两个 LaunchedEffect 来模拟启动一个较长时间的动画后,在特定延迟后取消该动画:

kotlin 复制代码
@Composable
fun StopAnimationExample() {
    // 使用 Animatable 表示一个动画状态(初始值为 0f)
    val animatable = remember { Animatable(0f) }

    // 启动一个较长时间的动画(5秒内从 0f 动画到 300f)
    LaunchedEffect(Unit) {
        try {
            animatable.animateTo(
                targetValue = 300f,
                animationSpec = tween(durationMillis = 5000)
            )
        } catch (e: CancellationException) {
            // 当动画被取消时,会抛出 CancellationException
            println("动画被主动停止:${e.message}")
        }
    }

    // 模拟在动画进行中取消动画:2秒后主动调用 stop() 终止动画
    LaunchedEffect(Unit) {
        delay(2000L)
        animatable.stop()  // 主动停止当前正在进行的动画
        println("调用 stop() 停止动画")
    }

    // 根据 animatable 的当前值更新 UI(例如,通过 offset 水平位移 Box)
    Box(
        modifier = Modifier
            .offset { IntOffset(animatable.value.roundToInt(), 0) }
            .size(50.dp)
            .background(Color.Red)
    )
}

代码说明

  1. 创建 Animatable 对象
    使用 remember { Animatable(0f) } 创建一个初始值为 0 的 Animatable 对象,用于管理动画状态。
  2. 启动动画
    使用一个 LaunchedEffect 启动动画,通过 animateTo 将值从 0f 平滑过渡到 300f。这个动画设定了 5 秒内完成。
  3. 主动取消动画
    另一个 LaunchedEffect 在延迟 2 秒后调用 animatable.stop() 主动终止动画。此时,动画将停止在当前帧的状态,后续代码可以依据这个值执行逻辑。
  4. 更新 UI
    根据当前动画值,通过 Modifier.offset 使一个红色 Box 在水平位置上实时发生变化,从而直观展示动画停止效果。

当然,如果是在真实的项目中,一般我们应该再 lifecycleScope 类似于这样的协程中取取消,因为 stop 的调用一般是和业务逻辑挂钩的。

到达动画的边界

Animatable 提供了一个名为 updateBounds 的方法,用于动态更新动画值的边界限制。利用该方法,我们可以在动画运行过程中(或开始前)调整允许的最小值和最大值,从而确保动画值不会超出预设的范围。这在如下场景中尤为有用:

  • 动态布局调整:当屏幕尺寸或外部条件发生变化,需要更新动画的限制范围时。
  • 防止数值溢出:当我们希望动画的值始终保持在一定区间内(例如限制滑动距离或进度值)时,通过更新边界来防止动画值超出预定区间。
  • 动画逻辑控制:在物理动画(如 fling、衰减动画)中,根据新的用户交互或状态变化,重新设定边界,使动画计算更符合当前实际情况。

updateBounds 的作用

  • 更新边界限制
    调用 animatable.updateBounds(lowerBound, upperBound) 后,后续通过该 Animatable 执行动画时,就会以新的边界作为约束。举例来说,如果你设定了下限为 0f、上限为 100f,那么动画过程中计算出来的值就会被限制在这个区间内。
  • 对正在进行的动画的影响
    需要注意的是,updateBounds 并不会立刻中断正在运行的动画,而是更新后续动画计算时采用的边界。如果动画的目标值已经在新的边界之外,那么动画可能会自动根据边界进行调整、停止或发生其他物理效果(例如反弹)。

示例代码1

下面的示例展示了如何使用 updateBounds 来限制动画值的区间,并通过动画演示当目标值超出限制时,动画结果被边界约束住的效果。

kotlin 复制代码
@Composable
fun UpdateBoundsBoxWithConstraintsExample() {
    // BoxWithConstraints 提供当前容器的尺寸信息
    BoxWithConstraints(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.LightGray)
    ) {
        // 获取当前容器的最大宽度,作为边界的上限
        val containerWidth: Dp = maxWidth
        
        // 使用 Animatable 管理 Dp 类型的动画状态,初始值设为 50.dp
        val animatable = remember { Animatable(50.dp, Dp.VectorConverter) }
        
        // 当容器宽度变化时(或首次进入组合)启动动画
        LaunchedEffect(containerWidth) {
            // 设定边界,下限为 0.dp,上限为容器宽度
            animatable.updateBounds(0.dp, containerWidth)
            
            // 这里我们故意指定一个超出上限的目标值(容器宽度 + 20.dp),
            // 由于边界限制,动画最终不会超过 containerWidth
            animatable.animateTo(
                targetValue = containerWidth + 20.dp,
                animationSpec = tween(durationMillis = 2000)
            )
        }
        
        // 根据 animatable 的当前值偏移一个 Box,从而直观展示动画值被边界约束的效果
        Box(
            modifier = Modifier
                .offset(x = animatable.value)
                .size(50.dp)
                .background(Color.Red)
        )
    }
}

代码说明

  1. BoxWithConstraints
    提供了容器的尺寸信息(如 maxWidth),使得我们可以动态地基于实际布局尺寸来设定动画的上边界。
  2. Animatable 初始化
    使用 remember { Animatable(50.dp, Dp.VectorConverter) } 创建一个支持 Dp 类型的 Animatable 对象,并设置初始值为 50.dp。
  3. updateBounds 调用
    在 LaunchedEffect 中调用 animatable.updateBounds(0.dp, containerWidth) 将动画值的变化区间限制为 0.dp, containerWidth。这样即使后续动画目标超过上限,也会自动被约束。
  4. 动画执行
    调用 animateTo(targetValue = containerWidth + 20.dp) 故意指定一个超出上限的目标值,观察 updateBounds 限制作用,动画最终停止 containerWidth。
  5. UI 展示
    通过 Modifier.offset(x = animatable.value) 将红色 Box 在水平方向上移动,根据动画实时更新动画值展示动画效果。

另外需要注意的是,无论是 animateTo 或者是 animateDecay 这些动画执行结束之后都会一个 AnimationResult 的返回值,我们可以根据里边的状态来获取需要的信息。另外,无论是几个维度的动画,只要有一个维度到达了上下界,整个动画就都停止。如果希望在另一个维度还可以做动画,可以将动画按照维度拆分, 不相互影响就行了。

示例代码2,多维度拆分动画

下面的代码展示了将 X 与 Y 维度分开动画控制的方式。我们对 X 维度设置了边界(例如 0, 300),并尝试动画到超出这个边界的目标值;而 Y 维度则无边界限制,允许动画正常运行到目标值。两条动画各自返回 AnimationResult,并在日志中输出最终结果。

kotlin 复制代码
@Composable
fun TwoDimensionalAnimationDemo() {
    // 分别为 X 和 Y 维度创建 Animatable 对象,使用 Float 表示像素值
    val animX = remember { Animatable(0f) }
    val animY = remember { Animatable(0f) }

    // 针对 X 维度:设置动画边界为 [0, 300]
    LaunchedEffect(Unit) {
        // 更新动画边界
        animX.updateBounds(0f, 300f)
        // 尝试将 X 动画到超出上限的目标(例如 400f),由于边界限制,动画最终会停在 300f
        val resultX: AnimationResult<Float> = animX.animateTo(
            targetValue = 400f,
            animationSpec = tween(durationMillis = 3000)
        )
        // 输出 X 动画的返回值信息
        println("X Animation finished: endReason = ${resultX.endReason}, final x = ${animX.value}")
    }

    // 针对 Y 维度:没有边界限制,可以正常到达目标值
    LaunchedEffect(Unit) {
        val resultY: AnimationResult<Float> = animY.animateTo(
            targetValue = 500f,
            animationSpec = tween(durationMillis = 3000)
        )
        println("Y Animation finished: endReason = ${resultY.endReason}, final y = ${animY.value}")
    }

    // UI 展示:使用 Box 显示当前 X 与 Y 动画状态的合成位置
    Box(
        modifier = Modifier
            .offset { IntOffset(animX.value.roundToInt(), animY.value.roundToInt()) }
            .size(50.dp)
            .background(Color.Blue)
    )
}

代码说明

  • Animatable 对象
    分别为 X 与 Y 创建了独立的 Animatable,它们各自独立控制动画值。
  • updateBounds 与 animateTo
    对 X 维度调用 updateBounds(0f, 300f) 设置动画值只能在 0 到 300 之间变化,因此当调用 animateTo(400f) 时,由于边界限制,整个 X 动画会在到达 300f 时停止,并返回一个 AnimationResult,其中 endReason 会标识因边界等原因提前结束。
  • AnimationResult 的利用
    我们通过返回的 AnimationResult 输出结束原因以及最终的动画值,方便调试与后续逻辑处理。
  • 分离动画的优势
    如果把二维动画合并成一个 Animatable<Offset> 进行动画控制,那么一旦 Offset 的某一个分量(例如 X)达到边界,整个动画就会停止,这时 Y 分量也不会继续动画。通过拆分成两个 Animatable,可以让它们独立运行,即使 X 已经结束,Y 仍继续动画,满足你在多维动画下希望部分维度继续运行的需求。

示例代码3:反弹效果

除了之前提到的spring这种可以实现弹簧效果之外,下边的逻辑也能实现:

kotlin 复制代码
LaunchedEffect(Unit) {
    // 先以初始速度进行衰减动画
    val result = anim.animateDecay(2000.dp, decay)
    // 如果动画因为到达边界(BoundReached)而结束
    if (result.endReason == AnimationEndReason.BoundReached) {
        // 取到当前的速度,并取相反值再次进行衰减,就相当于"反弹"
        anim.animateDecay(-result.endState.velocity, decay)
    }
}

但要注意:

1. result.endState.velocity 并不是完全准确的"瞬时速度":
  • Compose 动画系统中的速度是通过离散时间采样计算的 ,比如在 60Hz 屏幕刷新下每 16.6ms、在 120Hz 下每 8.3ms。这种速度反映的是在"上一帧"到"这一帧"之间的位置变化速率,所以更准确的说,它是 时间段内的平均速度。无论是8.3ms(120HZ刷新) 或者16.6ms(60HZ刷新),他们只是一个时间段,没有办法去获取某一时刻完全准确的瞬时速度,我们获取到的速度只是8.3ms(120HZ刷新) 或者16.6ms(60HZ刷新)中的一个平均值。
  • 它不可能是无限精度的瞬时速度,因为 UI 刷新本身是离散的 ------ 物理模拟不是数学极限,而是时间片段逼近。
2. 多轮反弹后速度误差会积累:
  • 由于每次使用的是近似的 endState.velocity,经过多轮反弹后,误差就会微小地累积,导致运动轨迹与真实的物理模型存在细微偏差。
  • 虽然 Compose 的 exponentialDecay 已经很优秀,但毕竟是基于近似离散模拟,不是精确解。

要想完全获取准确的速度,只能通过数学计算了。做法就如下边的示例中所示:

kotlin 复制代码
CourseComposeAnimatableEndingTheme {
    BoxWithConstraints {
        val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
        val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decay = remember { exponentialDecay<Dp>() }
        /*LaunchedEffect(Unit) {
          delay(1000)
          try {
            anim.animateDecay(2000.dp, decay)
          } catch (e: CancellationException) {
            println("动画被stop或者新开启的动画中断了")
          }
        }
        LaunchedEffect(Unit) {
          delay(1500)
          anim.animateDecay((-1000).dp, decay)
        }*/
        /*LaunchedEffect(Unit) {
          delay(1000)
          anim.animateDecay(2000.dp, decay)
        }
        LaunchedEffect(Unit) {
          delay(1300)
          anim.stop()
        }*/
        LaunchedEffect(Unit) {
            delay(1000)
            var result = animX.animateDecay(4000.dp, decay)
            while (result.endReason == AnimationEndReason.BoundReached) {
                result = animX.animateDecay(-result.endState.velocity, decay)
            }
        }
        LaunchedEffect(Unit) {
            delay(1000)
            animY.animateDecay(2000.dp, decay)
        }
        // anim.updateBounds(0.dp, upperBound = maxWidth - 100.dp)
        animY.updateBounds(upperBound = maxHeight - 100.dp)

        // 准确获取速度,这其实就是 "mod + 镜像反射" 技巧。
        // 这一整段逻辑的数学含义就是:paddingX(t) = |(t mod (2W)) - W|, 
        // 其中 W = maxWidth - 100.dp,经典的三角波函数!
        // 让一个值沿着三角波不断运动,就像来回弹跳一样
        val paddingX = remember(animX.value) {
            var usedValue = animX.value
            // anim.value mod (maxWidth - 100.dp)
            while (usedValue >= (maxWidth - 100.dp) * 2) {
                usedValue -= (maxWidth - 100.dp) * 2
            }
            // 第一次撞墙之前,直接返回value就可以了
            if (usedValue < maxWidth - 100.dp) {
                usedValue
            } else {
                (maxWidth - 100.dp) * 2 - usedValue
            }
        }
        Box(
          Modifier
            .padding(paddingX, animY.value, 0.dp, 0.dp)
            .size(100.dp)
            .background(Color.Green)
        )
    }
}
  • animX.value:动画实际的位移值(可能是一直向右递增的 decay 动画,值可以超过边界)

  • (maxWidth - 100.dp):我们视为"单程弹跳"的范围

  • usedValue:我们要用来绘制 Box 的实际位置

为什么 val paddingX 计算的值是准确的? 因为 这其实就是 "mod + 镜像反射" 技巧。这一整段逻辑的数学含义就是:paddingX(t) = |(t mod (2W)) - W|, 其中 W = maxWidth - 100.dp,经典的三角波函数!

让一个值沿着三角波不断运动,就像来回弹跳一样

与"spring 弹跳"动画的比较

  • 该代码:简易碰撞模型
    通过检测"到边界时结束"→"翻转速度"→"再衰减",构建了手动处理的反弹流程。优点是逻辑直观,缺点是每次反弹都需要你在代码里检测并翻转速度,属于较"硬碰撞"的做法。
  • spring 动画:物理弹簧效果
    使用 animateTo 并结合 spring (或 spring(dampingRatio = ..., stiffness = ...)) 可以获得更平滑、更物理化的弹跳效果,且不需要手动检测边界或翻转速度。但若需要多次碰撞(例如左右两端往返),就要额外处理更多逻辑或再次分段动画。

Transition转场动画:多属性的状态切换

🌀 什么是 Transition?

在 Compose 中,Transition 是一个可组合的动画容器,它允许我们 对多个状态属性 进行统一的过渡动画控制。例如定义一个状态(如 Expanded 和 Collapsed),然后让多个属性(如大小、颜色、透明度等)根据状态的变化而同步动画。

参数介绍

kotlin 复制代码
inline fun <S> Transition<S>.animateDp(
    noinline transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<Dp> = {
        spring(visibilityThreshold = Dp.VisibilityThreshold)
    },
    label: String = "DpAnimation",
    targetValueByState: @Composable (state: S) -> Dp
): State<Dp> =
    animateValue(Dp.VectorConverter, transitionSpec, label, targetValueByState)
  • <S> 泛型参数:表示 Transition 中的状态类型。你可以使用任何能描述 UI 状态的类型,例如枚举、密封类或者布尔值。这个泛型确保了动画与状态切换之间的关联和类型安全。

  • transitionSpec :用于定义当前状态变化对应的动画行为,支持定制动画速度、缓动曲线、弹性效果等。在默认实现中,使用 spring 动画,并设置了一个基于 Dp.VisibilityThreshold 的视觉阈值。这个阈值通常用于判断当动画值变化非常小(已足够接近目标值)时,动画可以认为已结束,从而提高动画的稳定性。

  • label:动画的标识字符串,利于调试与日志输出。

  • targetValueByState :根据当前状态返回目标 Dp 值,驱动动画从当前状态平滑过渡到目标状态。

这个示例中,直接使用 Boolean 状态来创建 Transition,并在 animateDp 中使用三个参数来定义动画逻辑:

kotin 复制代码
@Composable
fun BooleanStateTransitionExample() {
    // 使用普通布尔状态来控制动画:初始为 false,点击后变成 true
    var isLarge by remember { mutableStateOf(false) }
    
    // 直接将 Boolean 状态传入 updateTransition
    val transition = updateTransition(targetState = isLarge, label = "BooleanTransition")
    
    // 使用 animateDp 配置动画:transitionSpec 指定动画规格,label 用于调试,targetValueByState 根据状态返回不同值
    val width by transition.animateDp(
        transitionSpec = { tween(durationMillis = 500, easing = FastOutSlowInEasing) },
        label = "WidthAnimation",
        targetValueByState = { state ->
            if (state) 200.dp else 100.dp
        }
    )
    
    Box(
        modifier = Modifier
            .width(width)
            .height(100.dp)
            .background(Color.Magenta)
            .clickable { isLarge = !isLarge },
        contentAlignment = Alignment.Center
    ) {
        Text(text = "点击切换", color = Color.White)
    }
}

🔧 基本用法

1. 定义状态枚举

kotlin 复制代码
enum class BoxState { Collapsed, Expanded }

2. 使用 updateTransition 创建一个 Transition 实例

kotlin 复制代码
val currentState = remember { mutableStateOf(BoxState.Collapsed) }

val transition = updateTransition(targetState = currentState.value, label = "Box Transition")

3. 定义多属性动画

kotlin 复制代码
val size by transition.animateDp(label = "Size") { state ->
    if (state == BoxState.Expanded) 200.dp else 100.dp
}

val color by transition.animateColor(label = "Color") { state ->
    if (state == BoxState.Expanded) Color.Green else Color.Gray
}

val alpha by transition.animateFloat(label = "Alpha") { state ->
    if (state == BoxState.Expanded) 1f else 0.5f
}

4. 应用到组件上

kotlin 复制代码
Box(
    modifier = Modifier
        .size(size)
        .background(color)
        .alpha(alpha)
        .clickable { 
            currentState.value = 
                if (currentState.value == BoxState.Collapsed) BoxState.Expanded 
                else BoxState.Collapsed 
        }
)

✨ 使用场景

  • 展开/收起动画(手风琴效果)
  • 卡片切换(颜色、尺寸、圆角等同时变化)
  • 列表项选中状态切换
  • 动态布局响应(如 Message 展开收起)

animateDpAsState 和 transition.animateDp 有什么区别?

在 Jetpack Compose 中,animateDpAsState 和 transition.animateDp 都能实现 Dp 动画,但它们的 适用场景 和 控制能力 是不同的:


🆚 animateDpAsState vs transition.animateDp

比较点 animateDpAsState transition.animateDp
用途 独立动画单个属性 同步动画多个属性
状态管理 不需要 Transition 对象,直接基于目标值 需要先创建 Transition 对象,基于状态变化
同步动画能力 不能保证多个属性同步 多个属性绑定一个状态,动画同步
控制力 简洁直接,控制少 更强大,可自定义每个属性的动画细节
推荐使用场景 动画一个属性(如宽度、偏移、透明度等) 多个属性需要协调变化时(如大小 + 颜色 + 透明度)

✅ 示例对比

使用 animateDpAsState
kotlin 复制代码
val expanded = remember { mutableStateOf(false) }

val size by animateDpAsState(
    targetValue = if (expanded.value) 200.dp else 100.dp,
    label = "Size"
)

Box(
    Modifier
        .size(size)
        .clickable { expanded.value = !expanded.value }
)

👉 简洁、方便,但如果再加一个颜色动画,就要写第二个 animateColorAsState,他们会运行在不同的协程。不一定一起运行。


使用 transition.animateDp
kotlin 复制代码
enum class BoxState { Collapsed, Expanded }
val boxState = remember { mutableStateOf(BoxState.Collapsed) }

val transition = updateTransition(boxState.value, label = "Box Transition")

val size by transition.animateDp(label = "Size") { state ->
    if (state == BoxState.Expanded) 200.dp else 100.dp
}

// 针对颜色label,调试动画的时候就很方便了
val color by transition.animateColor(label = "Color") { state ->
    if (state == BoxState.Expanded) Color.Green else Color.Gray
}

Box(
    Modifier
        .size(size)
        .background(color)
        .clickable {
            boxState.value = if (boxState.value == BoxState.Expanded)
                BoxState.Collapsed else BoxState.Expanded
        }
)

另外,Transition支持label,这样我们在预览图中调试动画的时候就很方便了:

👉 所有属性会一起开始、一起结束,非常适合需要"整体变化"的动画。


Transition中的MutableTransitionState

在默认使用上,无论是animateDpAsState 还是 transition.animateDp 本身不提供直接设置"初始动画值"的参数。也就是说,它会在初次组合时,根据传入的目标值直接初始化动画状态,通常不会触发动画效果,这就是为什么在刚进入页面时看不到动画的原因。

  • 初次组合时的状态

    当 Compose 第一次计算 Composable 时,animateDpAsState 会以传入的 targetValue 作为动画的初始值。如果没有前一个动画状态,当前值会直接等于目标值,此时不会看到动画过渡。

  • 状态变化触发动画

    animateDpAsState 的设计思想是基于状态变化来驱动动画:当 targetValue 后续发生变化时,Compose 会从之前的状态值开始,以动画形式平滑过渡到新的 targetValue。

由于transition.animateDp 和animateDpAsState 存在这样的缺陷,为了解决这个问题 MutableTransitionState 应运而生。

  • 无法区分"进入动画"

    当我们希望组件进入时有一个明显的动画过渡(比如从 0.dp 动画到目标值),单独使用 animateDpAsState或者是 transition.animateDp 都无法实现,因为在初次组合时它的初始值已经等于目标值。为此,通常可以考虑以下几种方法:

    • 使用 MutableTransitionState 配合 updateTransition:这种方式允许你明确设置一个初始状态和目标状态,从而触发进入动画。

      kotlin 复制代码
      @Composable
      fun MutableTransitionStateWithTransitionExample() {
          // 使用 MutableTransitionState 明确设置初始状态为 false(小状态),目标状态为 true(大状态)
          val transitionState = remember {
              MutableTransitionState(false).apply {
                  targetState = true // 触发进入动画,从 false 过渡到 true
              }
          }
          
          // 使用 MutableTransitionState 作为参数来创建 Transition 对象(注意:这个 API 是支持传入 MutableTransitionState 的)
          val transition = updateTransition(transitionState, label = "SizeTransition")
          
          // 定义动画:当状态为 false时显示小尺寸(例如 50.dp),为 true 时显示大尺寸(例如 100.dp)
          val size by transition.animateDp(label = "sizeAnimation") { state ->
              if (state) 100.dp else 50.dp
          }
          
          Box(
              modifier = Modifier
                  .size(size)
                  .background(Color.Blue),
              contentAlignment = Alignment.Center
          ) {
              Text(text = "进入动画", color = Color.White)
          }
      }
    • 延迟设置 targetValue:通过在 LaunchedEffect 中延迟更新 targetValue,以便在初始显示时先显示非目标值,然后再变更来触发动画(虽然这种方式也有其局限性)。

      kotlin 复制代码
      @Composable
      fun DelayedTargetValueExample() {
          // 定义一个控制大小动画的状态,初始为 false
          var big by remember { mutableStateOf(false) }
          
          // 延迟一段时间再更新状态,从而触发动画
          LaunchedEffect(Unit) {
              // 延迟 500 毫秒,例如等待页面稳定后再触发动画
              delay(500)
              big = true  // 由 false 变为 true,触发动画过渡
          }
          
          // 根据状态 big 来返回目标尺寸,当 big 为 false 时尺寸为 50.dp,为 true 时为 150.dp
          val size by animateDpAsState(targetValue = if (big) 150.dp else 50.dp)
          
          Box(
              modifier = Modifier
                  .size(size)
                  .background(Color.Green),
              contentAlignment = Alignment.Center
          ) {
              Text(text = "延迟动画", color = Color.White)
          }
      }

Transition 延伸:AnimatedVisibility()

AnimatedVisibility 是 Jetpack Compose 中用于实现元素进入和退出时动画效果的一个便捷的 Composable。它内部利用了 Transition 的机制,在显示或隐藏组件时自动执行预设或自定义的进入(enter)和退出(exit)动画,从而简化了常见的"显示/隐藏"场景的动画实现。


1. 基本介绍
  • 功能定位
    AnimatedVisibility 用于在状态变化时,对组件的显示与隐藏做动画过渡。
  • 使用场景
    适合用于例如列表项的淡入淡出、面板的展开收起、或者任何需要以动画效果动态控制组件可见性的场景。
  • 内部原理
    AnimatedVisibility 内部基于 Transition 来监控"可见"状态(通常由一个 Boolean 控制)的变化,并根据用户提供的 enter 和 exit 动画设置,自动处理各属性动画的协调。

2. 主要参数
kotlin 复制代码
@Composable
fun AnimatedVisibility(
    visibleState: MutableTransitionState<Boolean>,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandIn(),
    exit: ExitTransition = fadeOut() + shrinkOut(),
    label: String = "AnimatedVisibility",
    content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visibleState, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

AnimatedVisibility 的常用参数包括:

  • visible :
    一个 Boolean 值,决定组件是否可见。当该值发生变化时,AnimatedVisibility 会自动触发相应的进入或退出动画。
  • enter :
    定义组件从不可见到可见时的动画效果。Compose 提供了常用的动画构造器,例如 fadeIn()、slideInVertically()、expandIn() 等,也可以自定义动画组合。
  • exit :
    定义组件从可见到不可见时的动画效果。常见的有 fadeOut()、slideOutVertically()、shrinkOut() 等,同样支持自定义。
  • initiallyVisible (可选):
    指定在首次进入组合时,组件是否可见。如果未设置,则默认根据 visible 参数执行动画。有时我们希望组件第一次组合时即直接显示或隐藏,这个参数就能帮忙控制。
  • modifier 、content 等其它参数,用于细粒度调整布局和子组件的显示内容。

对于 Row 和 Column ,还有特定的实现函数,来实现默认的效果:

kotlin 复制代码
@Composable
fun RowScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandHorizontally(),
    exit: ExitTransition = fadeOut() + shrinkHorizontally(),
    label: String = "AnimatedVisibility",
    content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visible, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}


@Composable
fun ColumnScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandVertically(),
    exit: ExitTransition = fadeOut() + shrinkVertically(),
    label: String = "AnimatedVisibility",
    content: @Composable AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visible, label)
    AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

3. 示例代码

下面是一个简单示例,展示如何使用 AnimatedVisibility 来制作一个点击按钮后控制组件淡入淡出效果的动画。

kotlin 复制代码
@Composable
fun AnimatedVisibilityDemo() {
    // 状态变量控制可见性
    var visible by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        // 切换可见性的按钮
        Button(onClick = { visible = !visible }) {
            Text(text = if (visible) "隐藏" else "显示")
        }

        Spacer(modifier = Modifier.height(16.dp))

        // AnimatedVisibility 用于包裹需要动画展示的内容
        AnimatedVisibility(
            visible = visible,
            enter = fadeIn(animationSpec = tween(durationMillis = 500)) + slideInVertically(
                initialOffsetY = { it }
            ),
            exit = fadeOut(animationSpec = tween(durationMillis = 500)) + slideOutVertically(
                targetOffsetY = { it }
            )
        ) {
            // 需要显示/隐藏的组件内容
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(Color.Red),
                contentAlignment = Alignment.Center
            ) {
                Text("欢迎", color = Color.White)
            }
        }
    }
}

代码解析:

  • 使用 Boolean 变量 visible 控制组件的显示与隐藏。

  • 当点击按钮时,visible 状态取反,AnimatedVisibility 自动检测到状态变化后:

    • 若 visible 变为 true,则执行 enter 动画,由淡入(fadeIn)和垂直方向滑入(slideInVertically)组合的动画效果;
    • 若 visible 变为 false,则执行 exit 动画,实现淡出(fadeOut)和垂直方向滑出(slideOutVertically)的效果。
  • 通过自定义 animationSpec(例如 tween 动画)来控制动画时长和缓动曲线。


4. 高级用法
4.1 自定义动画组合

AnimatedVisibility 支持 enter 或 exit 参数传入多种动画组合,通过加号(+)组合多个动画。比如同时使用 expandIn和scaleIn():

单独使用expandIn的效果:

单独使用scaleIn的效果:

还有例如本例中同时使用了 fadeIn 与 slideInVertically。

同时,Compose 默认还自带了很多便捷的滑动函数以便于我们直接调用:

EnterTransition 是实现动画效果的核心类,他的实现类的构造方法中的 TransitionData ,它的内部又包含了4个参数,这四个参数就决定了动画的效果:

Kotlin 复制代码
private class EnterTransitionImpl(override val data: TransitionData) : EnterTransition()

@Immutable
internal data class TransitionData(
    val fade: Fade? = null,
    val slide: Slide? = null,
    val changeSize: ChangeSize? = null,
    val scale: Scale? = null
)

// 实现渐变效果
@Immutable
internal data class Fade(val alpha: Float, val animationSpec: FiniteAnimationSpec<Float>)

// 实现滑动效果
@Immutable
internal data class Slide(
    // fullSize表示组件的尺寸,利用这个可以计算和组件尺寸相关的。
    val slideOffset: (fullSize: IntSize) -> IntOffset,
    val animationSpec: FiniteAnimationSpec<IntOffset>
)

// 注意,需要区别并不是单纯的大小变化的时候,而是触发裁剪视图尺寸的时候触发
@Immutable
internal data class ChangeSize(
    val alignment: Alignment,
    val size: (fullSize: IntSize) -> IntSize = { IntSize(0, 0) },
    val animationSpec: FiniteAnimationSpec<IntSize>,
    val clip: Boolean = true
)

// 视图发生缩放的时候触发
@Immutable
internal data class Scale(
    val scale: Float,
    val transformOrigin: TransformOrigin,
    val animationSpec: FiniteAnimationSpec<Float>
)
  • Fade:透明度动画数据,用于控制渐入渐出效果。

  • Slide:滑动动画数据,用于控制位置偏移效果。

    • slideOffset:fullSize表示组件的尺寸,利用这个可以计算和组件尺寸相关的。
    • animationSpec:可以传入不同的动画规格(例如 tween、spring 或 keyframes)
  • ChangeSize :需要区别并不是单纯的大小变化的时候,而是触发裁切视图尺寸的时候触发。裁剪区域的大小就代表了最终显示的大小,裁剪的越小,显示的就会越大。

    • alignment:当内容逐渐扩展时,哪一侧或哪一角是"固定"不变的,而其他部分则逐步展开。 例如,默认值通常设置为 Alignment.BottomEnd,这意味着动画首先从右下角开始显示内容, 再向上或向左扩展,直到完全显示整个内容。 可以通过设置不同的对齐方式来改变动画的视觉效果,例如从左上角展开、从中间向外扩展等。

    • size:初始尺寸(IntSize)。默认实现为 { IntSize(0, 0) },表示动画开始时,内容的 尺寸从 0 开始,即内容完全不可见,随着动画进行逐步扩大到完整尺寸。可以根据完整内尺按比 例返回一个初始尺寸,从而实现自定义的入场动画。例如,可以设置初始尺寸为内容尺寸的一 半,使动画从半透明状态进入。

    • animationSpec:定义了如何在一定时间范围内将尺寸从初始值过渡到目标值。这包括动画时长、弹性、阻尼、缓动函数等参数。默认使用 spring(弹性动画)的配置,这通常能产生平滑自然的扩展效可以传入不同的动画规格(例如 tween、spring 或 keyframe以调节动画的速度、加速度或者自定义阻尼,最终达到所期望的入场动画效果。

    • clip:如果 clip 为 true,则在动画过程中,只显示位于当前动画尺寸内的内容;超出部分将被剪裁掉。这在动画过程中可以制造出内容逐渐显示出来的视觉效果。当希望在内容扩展动画中,通过不断调整剪裁区域来控制展示内容时使用。比如内容从一个小区域逐步扩展到全屏,clip 为 true 则可以保证只有动画范围内的内容被显示,从而形成"露出"或"展开"的效果。如果是false,动画就只有唯一效果,没有裁切效果了。下边的两个图,上边表示的事clip为true的效果,下边就是clip为false的效果:

      同样的, expand也提供了很多便捷的函数可以调用:

  • Scale:通过缩放实现组件的变化,用于控制组件的比例变换。

    • initialScale:空间初始大小
    • transformOrigin:围绕那个点做缩放的,默认值是center
    • animationSpec
+ 是如何实现动画的组合的?

其实就是对象参数合并之后创建一个新的对象返回回来。

kotlin 复制代码
@Stable
operator fun plus(enter: EnterTransition): EnterTransition {
    return EnterTransitionImpl(
        TransitionData(
            fade = data.fade ?: enter.data.fade,
            slide = data.slide ?: enter.data.slide,
            changeSize = data.changeSize ?: enter.data.changeSize,
            scale = data.scale ?: enter.data.scale
        )
    )
}

并且通过源码也知道,+ 左边的逻辑是优先的,也就是同时写多个针对同一属性的效果的话,左边的会优先,右边的会被放弃。例如,右边的0.3f就会被放弃。

kotlin 复制代码
AnimatedVisibility(shown, enter = fadeIn(initialAlpha = 0.5f) + scaleIn() + fadeIn(initialAlpha = 0.3f)
4.2 使用 MutableTransitionState

在某些场景中,如果我们希望明确设置组件首次显示时的初始状态(例如组件进入时需要执行动画,而不是直接显示最终状态)。这时可以通过 MutableTransitionState 来触发入场动画。例如:

kotlin 复制代码
@Composable
fun AnimatedVisibilityWithMutableTransitionStateDemo() {
    // 创建 MutableTransitionState,初始状态设置为 false,进入后设置为 true
    val visibilityState = remember {
        MutableTransitionState(false).apply {
            targetState = true
        }
    }
    
    AnimatedVisibility(
        visibleState = visibilityState,
        enter = expandIn(expandFrom = Alignment.TopStart) + fadeIn(),
        exit = shrinkOut(shrinkTowards = Alignment.BottomEnd) + fadeOut()
    ) {
        Box(
            modifier = Modifier
                .size(120.dp)
                .background(Color.Green),
            contentAlignment = Alignment.Center
        ) {
            Text("进入动画", color = Color.White)
        }
    }
}

在该示例中,通过 MutableTransitionState 在首次组合时将组件初始状态设置为不可见,然后立即设置目标状态为可见,从而触发"进入动画"(expandIn + fadeIn)。

4.3 Transition 上调用扩展函数 AnimatedVisibility
kotlin 复制代码
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun TransitionAnimatedVisibilitySample() {
  // 定义一个状态:true 表示内容应该显示,false 表示隐藏
  var visible by remember { mutableStateOf(false) }

  // 使用 updateTransition 创建一个 Transition 对象,
  // 注意这里直接传入 visible 布尔值作为 Transition 的目标状态
  val transition = updateTransition(targetState = visible, label = "VisibilityTransition")

  // 使用 Transition<T>.AnimatedVisibility 扩展函数,
  // 根据当前 Transition 状态来决定内容是否显示
  transition.AnimatedVisibility(
    // 指定初始时是否可见,这里传 false,表示第一次进入时就要做动画
    {visible},
    // 定义进入动画:这里将内容淡入,同时通过扩展动画展开展示效果
    enter = fadeIn(animationSpec = tween(500)) + expandIn(),
    // 定义退出动画:淡出并伴随着收缩效果
    exit = fadeOut(animationSpec = tween(500)) + shrinkOut(),
    // visible 传递了一个 lambda,根据 Transition 的当前状态判断内容是否应当显示
    // 当状态为 true(visible == true)时,返回 true,表示需要显示
  ) {
    // 这里定义具体要动画显示的内容
    Box(
      modifier = Modifier
        .size(150.dp)
        .background(Color.Magenta),
      contentAlignment = Alignment.Center
    ) {
      Text(text = "Animated Content", color = Color.White)
    }
  }

  Spacer(modifier = Modifier.height(16.dp))

  // 提供一个按钮来切换可见性状态
  Button(onClick = { visible = !visible }) {
    Text(text = if (visible) "Hide" else "Show")
  }
}

代码解释:

  • 状态与 Transition 的创建:

    • 使用 visible 这个 Boolean 状态来控制内容的显示或隐藏。
    • 通过 updateTransition(targetState = visible) 创建一个 Transition 对象,Transition 会追踪从旧状态到新状态的变化过程。
  • Transition.AnimatedVisibility 函数:

    这是一个扩展函数,其参数说明如下:

    • initiallyVisible: 指定内容在第一次组合时是否可见。设置为 false 后,即使 Transition 的目标状态为 true,也会先执行进入动画。
    • enter: 定义进入动画效果,这里通过 fadeIn 和 expandIn 组合实现淡入和边界扩展的效果。
    • exit: 定义退出动画效果,使用 fadeOut 和 shrinkOut 组合实现淡出和收缩效果。
    • visible: 一个 lambda,接收 Transition 当前状态(本例中为 Boolean),返回一个 Boolean 值来告诉 AnimatedVisibility 是否应该显示内容。
    • 内容块: 传入需要进行动画显示和隐藏的 Composable,这里以一个 Box 和内部的 Text 为例。
  • 交互:

    • 页面提供一个按钮,通过改变 visible 的值触发 Transition 状态变化,从而启动进入或退出动画。
4.4 布局过渡

AnimatedVisibility 不仅可以用于简单的显示与隐藏动画,也适用于复杂布局的切换,可以与 AnimatedContent 配合,实现内容的渐变动画效果。但是在使用过程中需要注意,不支持同时设置多个content:

kotlin 复制代码
AnimatedVisibility(shown, enter =  scaleIn() + expandIn(), exit = fadeOut() + shrinkOut()) {
  TransitionSquare()
  TransitionSquare() // 错误,这种写法无效。
}

正确的写法是写两个分别做:

kotlin 复制代码
AnimatedVisibility(shown, enter =  scaleIn() + expandIn(), exit = fadeOut() + shrinkOut()) {
  TransitionSquare()
}

AnimatedVisibility(shown, enter =  scaleIn() + expandIn(), exit = fadeOut() + shrinkOut()) {
  TransitionSquare()
}

Transition 延伸:Crossfade()

Crossfade 是 Jetpack Compose 中提供的一种便捷的动画效果,用于在不同界面或不同状态内容之间实现平滑的淡入淡出切换 。它基于 Transition 的理念,在状态发生变化时自动管理透明度(alpha)的动画,使前后内容之间实现交叉渐变。AnimateVisibility是让一个组件的出现或者消失用一个渐变的过程进行,而Crossfade则是让两个交替的组件进行渐变的无缝衔接。另外Crossfade 只能做淡入淡出效果,可以配置速率参数,不能配置滑动效果。


1. Crossfade 的特点
  • 状态驱动的动画切换
    Crossfade 的动画效果依赖于目标状态的变化。当传入的 targetState 改变时,Crossfade 会自动淡出当前显示的内容,并淡入新的内容。
  • 简化代码
    使用 Crossfade,我们无需手动控制每个动画属性(例如透明度),Compose 内部会基于状态变化自动进行插值和淡入淡出处理,从而简化了代码编写。
  • 灵活性
    我们可以为 Crossfade 提供任意的状态类型(如字符串、枚举、数据类等),并在 content lambda 中根据传入的当前状态来返回不同的 Composable 内容,达到界面切换的目的。
  • 动画规格配置
    Crossfade 支持通过 animationSpec 参数传入自定义的动画规格(例如 tween、spring 等),以便控制动画的时长和缓动效果。

2. 基本使用方法
示例 1:简单的状态切换

下面是一个简单示例,展示如何使用 Crossfade 在两个状态之间切换时实现淡入淡出的动画效果。

kotlin 复制代码
@Composable
fun CrossfadeSimpleExample() {
    // 定义一个字符串状态,用于表示当前显示的内容
    var currentScreen by remember { mutableStateOf("Screen1") }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Button(
            onClick = {
                // 切换状态,使当前屏幕在 "Screen1" 与 "Screen2" 之间切换
                currentScreen = if (currentScreen == "Screen1") "Screen2" else "Screen1"
            }
        ) {
            Text("切换屏幕")
        }

        Spacer(modifier = Modifier.height(16.dp))

        // Crossfade 会监听 targetState 的变化,并自动过渡显示内容
        Crossfade(
            targetState = currentScreen,
            // 可选:指定动画规格,例如使用 tween 动画,动画时长为 500 毫秒
            animationSpec = tween(durationMillis = 500)
        ) { screen ->
            when (screen) {
                "Screen1" -> {
                    // 当当前状态为 "Screen1" 时显示红色背景的文本
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .background(Color.Red),
                        contentAlignment = Alignment.Center
                    ) {
                        Text("这是屏幕1", color = Color.White)
                    }
                }
                "Screen2" -> {
                    // 当状态为 "Screen2" 时显示蓝色背景的文本
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .background(Color.Blue),
                        contentAlignment = Alignment.Center
                    ) {
                        Text("这是屏幕2", color = Color.White)
                    }
                }
            }
        }
    }
}
示例说明
  • 状态管理
    使用 remember { mutableStateOf("Screen1") } 来保存当前状态,当用户点击按钮时,状态在 "Screen1" 与 "Screen2" 间切换。如果两个的大小不一致,结束之后会保留最终的大小:
  • Crossfade 用法
    调用 Crossfade(targetState = currentScreen, animationSpec = tween(durationMillis = 500)),当 currentScreen 改变时,Crossfade 内部自动将旧内容淡出,并将新内容淡入。
    在 content lambda 中,根据传入的状态(screen),通过 when 表达式返回相应的布局。
  • 动画效果
    由于配置了 tween 动画,整个切换过程动画时长为 500 毫秒,前后内容会平滑过渡,形成柔和的交叉淡入淡出效果。

3. 进阶使用场景
  • 屏幕或页面切换
    Crossfade 非常适合用在多屏应用中,比如在应用主界面中切换不同 tab 或页面时,通过淡入淡出效果让 UI 更显流畅。
  • 内容动态切换
    例如,一个数据列表切换不同的展示方式(网格视图与列表视图)时,使用 Crossfade 能够实现平滑的过渡,缓解视觉突兀问题。
  • 动画规格定制
    可以根据需求传入不同的 animationSpec,例如 spring 动画可以实现更"弹性"的过渡效果。只需更改 animationSpec 参数即可。

CrossFade总结

Crossfade 是 Jetpack Compose 提供的方便且灵活的动画工具,其主要作用在于:

  • 基于状态变化实现内容的交叉淡入淡出效果,
  • 简化代码,不需要手动管理动画属性插值,
  • 可轻松结合多种动画规格和自定义布局,实现更加平滑、自然的界面切换体验。

通过使用 Crossfade,你可以更轻松地为应用添加平滑的视觉过渡效果,从而增强用户体验。

Transition 延伸:AnimatedContent()

AnimatedContent 是 Jetpack Compose 提供的另一个高级动画 API,用于在状态变化时对内容进行平滑过渡动画。与 Crossfade 类似,它基于状态变化来更新界面,但 AnimatedContent 更加强大和灵活,支持对内容整体布局、大小、进入/退出动画进行完整的控制,包括在内容发生变化(例如:文本、布局、图片等)时同步执行动画。是Crossfade的极度加强版本。


AnimatedContent 的特点
  1. 状态驱动内容变化:
    AnimatedContent 接受一个 targetState,当 targetState 发生变化时,它会触发动画过渡,并在动画过程中根据当前状态计算内容的显示方式。

  2. 内容转换与布局调整:
    AnimatedContent 不仅可以对内容执行淡入淡出效果,还可以在内容切换的同时自动处理布局变化(如尺寸的变化),让动画效果更加自然。默认的动画转换会自动处理容器大小的变化。

  3. 自定义 Transition 规格:
    通过 transitionSpec 参数,我们可以自定义进入和退出动画的组合。你可以基于初始状态和目标状态返回一个 ContentTransform,它描述了如何同时对新内容执行进入动画、对旧内容执行退出动画,并在需要时同步进行内容大小转换(SizeTransform)。

    • ContentTransform:

      kotlin 复制代码
      class ContentTransform(
          val targetContentEnter: EnterTransition,
          val initialContentExit: ExitTransition,
          targetContentZIndex: Float = 0f,
          sizeTransform: SizeTransform? = SizeTransform()
      ) {
      • targetContentEnter 定义新内容如何"飞入"或"淡入";

      • initialContentExit 定义旧内容如何"飞出"或"淡出";

      • targetContentZIndex 确保动画期间内容的绘制层级正确,默认入场覆盖出场,一般不需要调整,该值为正数的时候表示入场组件盖住出场组件,反之则不然。

      • sizeTransform 则负责处理内容大小变化的平滑过渡。

        kotlin 复制代码
        fun SizeTransform(
            // 是否裁剪
            clip: Boolean = true, 
            // 配置速度曲线
            sizeAnimationSpec: (initialSize: IntSize, targetSize: IntSize) -> FiniteAnimationSpec<IntSize> =
                { _, _ -> spring(visibilityThreshold = IntSize.VisibilityThreshold) }
        ): SizeTransform = SizeTransformImpl(clip, sizeAnimationSpec)
  4. 支持复杂的内容动画:
    AnimatedContent 适用于不仅仅是简单的内容替换,例如在列表项、标签切换、页面切换等场景中,当内容本身、布局或尺寸都发生变化时,它能提供更灵活的动画过渡。


AnimatedContent 的基本用法

AnimatedContent 的基本签名大致如下:

kotlin 复制代码
@Composable
fun <T> AnimatedContent(
    targetState: T,
    modifier: Modifier = Modifier,
    transitionSpec: AnimatedContentScope<T>.() -> ContentTransform = { ContentTransform.None },
    contentAlignment: Alignment = Alignment.Center,
    content: @Composable AnimatedContentScope<T>.(targetState: T) -> Unit
)
  • targetState: 用于控制内容显示的状态,当它的值变化时,AnimatedContent 会自动触发内容过渡动画。
  • transitionSpec: 用于定义进入/退出动画(以及尺寸变化动画)的转换逻辑,返回一个 ContentTransform 对象。默认是 ContentTransform.None(即不做任何动画转换)。
  • contentAlignment: 定义新旧内容在过渡过程中如何对齐。
  • content: 根据当前的 targetState 构建具体显示内容的 Composable。

示例:数字计数的动画转换

下面提供一个示例,当计数值发生变化时,AnimatedContent 自动执行基于数字大小变化的动画。不同增减方向时,可以选择不同的进入与退出动画:

kotlin 复制代码
@OptIn(ExperimentalAnimationApi::class)
@Preview
@Composable
fun AnimatedContentWithFontSizeExample() {
  var count by remember { mutableStateOf(0) }

  // 自定义一个尺寸动画规格,根据初始尺寸与目标尺寸返回一个 spring 动画规格
  val customSizeAnimationSpec: (IntSize, IntSize) -> FiniteAnimationSpec<IntSize> =
    { _, _ ->
      spring(
        stiffness = Spring.StiffnessMediumLow,
        dampingRatio = Spring.DampingRatioMediumBouncy,
        visibilityThreshold = IntSize.VisibilityThreshold
      )
    }

  Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    modifier = Modifier.fillMaxSize().padding(16.dp)
  ) {
    AnimatedContent(
      targetState = count,
      transitionSpec = {
        // 根据状态变化方向定制动画:
        // 当 targetState 大于 initialState 时,新内容从底部滑入、旧内容向上滑出;
        // 反之,新内容从顶部滑入、旧内容向下滑出。
        val animation = if (targetState > initialState) {
          slideInVertically { height -> height } + fadeIn() with
                  slideOutVertically { height -> -height } + fadeOut()
        } else {
          slideInVertically { height -> -height } + fadeIn() with
                  slideOutVertically { height -> height } + fadeOut()
        }
        // 使用自定义 SizeTransform,添加弹簧效果控制尺寸过渡
        animation.using(SizeTransform(clip = false, sizeAnimationSpec = customSizeAnimationSpec))
      },
      contentAlignment = Alignment.Center
    ) { targetCount ->
      // 根据 count 动态调整 fontSize,进而影响 Text 的整体尺寸
      // 例如,字体大小随着 count 增加而增大 
      // 当 count 变化时,Text 的字体大小会改变,从而导致内容整体尺寸的变化,
      // 这时 SizeTransform 就会使得新旧内容在尺寸上的变化使用弹簧动画平滑过渡。
      val fontSize = (24f + targetCount * 4).sp
      Text(
        text = "$targetCount",
        fontSize = fontSize,
        fontWeight = FontWeight.Bold
      )
    }

    Spacer(modifier = Modifier.height(24.dp))

    Row {
      Button(onClick = { count-- }) {
        Text("减少")
      }
      Spacer(modifier = Modifier.width(16.dp))
      Button(onClick = { count++ }) {
        Text("增加")
      }
    }
  }
}
示例解读
  • 状态控制:

    我们定义了一个整数状态 count 作为 AnimatedContent 的 targetState,当 count 改变时触发动画转换。

  • transitionSpec 定制动画:

    在 transitionSpec 中,我们根据 targetState 与 initialState 的大小关系(即增减方向)返回不同的动画效果:

    • 当 count 增加时,新内容从底部滑入并淡入,旧内容向顶部滑出并淡出。
    • 当 count 减小时,方向相反。
    • 使用 with 将进入和退出动画组合成对,同时通过 .using(SizeTransform(clip = false)) 来处理内容大小的变化(例如当新旧内容大小不同),确保布局平滑过渡。
  • 内容展示:

    content lambda 根据当前的 targetState(即当前的 count 数值)构建 Text 显示新的计数值。

  • 交互:

    两个按钮分别对 count 进行加减操作,从而触发 AnimatedContent 的动画转换。


总结

AnimatedContent 是一个功能强大的动画容器,它能在 targetState 变化时智能地切换显示内容,并支持自定义进入、退出动画以及内容大小变化处理。通过 transitionSpec 参数,你可以定义灵活的动画效果,使不同内容之间的过渡更加平滑、自然,从而为复杂的状态变化提供更好的用户体验。


🎯 总结

  • 只需要一个属性过渡 → 用 animateDpAsState
  • 多个属性协同变化(如状态切换)→ 用 updateTransition + transition.animate*
  • 当我们希望组件进入时有一个明显的动画过渡, 推荐使用 使用 MutableTransitionState 配合 updateTransition,而不是采用延时的方式,延迟的时机与可能产生的体验问题。
  • AnimatedVisibility 简化了组件显示/隐藏时的动画实现。它能自动将状态变化转化为动画过渡,并支持自定义多种 enter 和 exit 动画效果,基于此,我们可以实现简单的淡入淡出或者是复杂的滑动、扩展动画。

✅ 使用建议

建议 说明
使用 label 标记动画 方便调试和性能分析
动画属性尽量使用 animate* 避免 LaunchedEffect 等低层手动控制,保持声明式
适量使用 太多属性变化可能影响性能和用户感知
可组合重构 将动画逻辑封装成 composable,提高复用性
相关推荐
wangruofeng3 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng3 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈7 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技7 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户47949283569157 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马9 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng10 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding11 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js
鶴哥只手遮天11 小时前
《Electron 45.2.3 内核解剖》第 4 章 补丁体系:380 个补丁改了什么
前端·electron
FungLeo13 小时前
成为全栈·Vue 管理后台篇·分类树与标签管理:层级数据如何落到管理界面
前端·javascript·vue.js·分类树·naive ui·标签管理·前端数据结构