Compose 实践与探索四 —— 单值动画

本篇文章作为动画的第一篇,会先整体介绍一下 Compose 动画的 API 体系,然后从单值动画 animate*AsState() 入手,从易至难介绍动画的主体内容。

1、Compose 动画架构

Compose 提供了一系列强大的动画 API,它们在使用上与属性动画非常类似,遵循了 Compose 状态驱动 UI 的设计思想。由于 API 数量众多,因此我们有必要先了解整个动画架构。

通常我习惯将 Compose 的动画 API 按层级分为四层,由低至高依次为:

  1. 底层:核心动画引擎
    • 作用:提供最基础的动画计算能力,如插值、物理模型、时间管理等。
    • 关键 API:
      • Animatable:直接控制动画值的底层工具(如手动启动、暂停、监听进度)。
      • AnimationState:跟踪动画的当前状态(值、速度等)。
      • AnimationSpec:定义动画的规格(时长、曲线、弹簧参数等),如 springtweenkeyframes
    • 使用场景:需要精细控制动画(如手势驱动的复杂动画)。
  2. 中层:状态驱动动画
    • 作用:通过监听状态变化自动触发动画,减少手动管理。
    • 关键 API:
      • animate*AsState:自动根据状态变化驱动单一值的动画(如 animateFloatAsState)。
      • updateTransition:管理多个动画值的组合过渡(如同时改变大小和颜色)。
      • rememberInfiniteTransition:创建无限循环动画(如旋转加载图标)。
    • 使用场景:90% 的常见动画需求(如按钮点击效果、页面切换)。
  3. 上层:高级动画组件
    • 作用:封装复杂动画逻辑,开箱即用。
    • 关键 API:
      • AnimatedVisibility:控制组件的显示/隐藏动画(如淡入淡出+缩放)。
      • AnimatedContent:内容切换时的过渡动画(如文本变化+布局切换)。
      • Crossfade:交叉淡入淡出切换两个组件。
      • SwipeToDismiss:滑动删除动画。
    • 使用场景:快速实现标准交互模式(如列表删除、页面切换)。
  4. 布局层级:布局动画
    • 作用:自动为布局变化(如列表项增删、布局重组)添加动画。
    • 关键 API:
      • Modifier.animateContentSize:平滑过渡布局尺寸变化(如展开/折叠)。
      • LazyColumn/LazyRow + animateItemPlacement:列表项增删/重排序动画。
      • AnimatedLayout:自定义布局变化的动画(实验性)。
    • 使用场景:动态布局调整(如列表更新、网格重排)。

而从使用维度将动画 API 大致分为两类 —— 高级别 API 与低级别 API:

  • 高级别 API 是声明式、开箱即用,自动处理动画生命周期(开始、中断、完成),大多是一个 Composable 函数,实际上是通过低级别 API 实现的,能快速实现常见动画(如显隐、属性变化)
  • 低级别 API 需手动控制动画流程,支持更复杂的交互(如手势联动、物理效果),使用场景更加广泛,可以基于协程完成任何状态驱动的动画效果,接口复杂度也更高,常用于自定义动画逻辑(如拖拽吸附、复杂插值)

Compose 官方文档给出了一张图表以帮助开发者确认应该使用哪种动画 API 来实现动画效果:

请添加图片描述

具体的文字描述,可参考选择动画 API

2、基于值的动画 animate*AsState()

animate*AsState 函数是 Compose 中最简单的动画 API,用于为单个值添加动画效果。它类似于传统视图的属性动画,属于状态转移型动画,可以自动完成从当前值到目标值的过渡估值计算。需要注意的是,这类函数不用也不能设置初始值,它会将第一个目标值作为初始值。

Compose 为 FloatColorDpSizeOffsetRectIntIntOffsetIntSize 提供开箱即用的 animate*AsState 函数,以 animateDpAsState() 为例:

@Composable
fun animateDpAsState(
    targetValue: Dp,
    animationSpec: AnimationSpec<Dp> = dpDefaultSpring,
    finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
    return animateValueAsState(
        targetValue,
        Dp.VectorConverter,
        animationSpec,
        finishedListener = finishedListener
    )
}

该函数将 Dp 转成一个可以在 Composable 中访问的 State,Dp 变化触发 Composable 重组,从而完成动画效果。

animateDpAsState() 已经包含了 mutableStateOf() 这种数据改变通知监听者的功能,同时还包含 remember() 防止多次初始化的功能,因此创建动画属性就不用 remember() 和 mutableStateOf() 了。此外还需注意 animateDpAsState() 返回的是 State 而不是 MutableState。这样外界不能通过 State 对象修改其内部保存的数据 value,所以由 animateDpAsState() 代理生成的属性不能是 var,只能是 val 的:

// 可以通过点击修改 big 这个 Boolean 值来改变 size 大小
val size by animateDpAsState(if (big) 96.dp else 48.dp)

Compose 的动画写起来比 View 的属性动画更简单,因为 Compose 动画只是对界面属性做渐变,而不是拿到具体的属性动画对象(想拿也拿不到),然后对动画对象的属性进行渐变。

写法简单意味着功能会受到限制,设置不了初始值(强行将第一个目标值设置为初始值),更无法对动画过程做出详细的定制。虽然在 animateDpAsState() 中也提供了 animationSpec 参数用来指定动画规格,但这只是通用的规格,如动画曲线、动画时长等信息。

3、Animatable

上节说到 animate*AsState 系列函数有一个本质上的缺陷就是无法对动画做出最细粒度的定制,它无法指定动画的初始值,如果想对动画做出随心所欲的定制,就需要使用基于协程的单值动画 Animatable。相比于 animate*AsState,可以定制动画的流程,因此也可称为流程定制型动画。

2.1 两种写法的区别

查看 animateDpAsState() 的源码:

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

animateValueAsState() 内还是使用了 Animatable():

@Composable
fun <T, V : AnimationVector> animateValueAsState(
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: AnimationSpec<T> = remember { spring() },
    visibilityThreshold: T? = null,
    label: String = "ValueAnimation",
    finishedListener: ((T) -> Unit)? = null
): State<T> {
    val animatable = remember { Animatable(targetValue, typeConverter, visibilityThreshold, label) }
    ...
    return animatable.asState()
}

因此 animate*AsState() 实际上是对 Animatable 做了便捷性的扩展,使得在写法上可以更容易地就实现一个动画。但是在功能性上则属于收窄了,因为 animate*AsState() 不能设置动画的初始值了。确切地说,是 animate*AsState() 用不到初始值而抛弃了这个功能。它是对 Animatable 具体场景化的实现,是对状态切换这种场景进行的专门的扩展,而状态的切换或转移是不需要初始值的。

因此,当你想要做一个动画并且需要初始值时,说明这个场景不适用于 animate*AsState(),此时来使用更底层的 Animatable 就行了。

2.2 创建 Animatable

创建 Animatable 对象通常有两种方式,一是调用 Animatable() 传入 Float 类型的初始值,二是调用 Animatable 的构造函数。

两种方式

我们先来看第一种方式:

/**
* Animatable 外面必须包上 remember,否则编译报错:
* Creating an Animatable during composition without using remember
* 这个原因实际上与 mutableState() 外需要 remember() 是一样的
*/
val animation = remember { Animatable(0f) }

这种方式要求初始值必须是 Float 类型的:

/**
* 这个 Animatable 函数创建了一个浮点数值的持有者,当通过 animateTo 改变其值时,
* 它会自动对值进行动画处理。Animatable 支持在正在进行的值变化动画期间更改值。
* 当这种情况发生时,新的动画将把 Animatable 从其当前值(即中断时的值)过渡到新的目标值。
* 这确保了使用 animateTo 时值的变化始终是连续的。如果 animateTo 使用了弹簧动画(即默认动画),
* 速度的变化也将保证是连续的。
*
* 与 AnimationState 不同,Animatable 确保了其动画的互斥性。为了实现这一点,
* 当通过 animateTo(或 animateDecay)启动新动画时,任何正在进行的动画任务都将被取消。
* 
* 参数:
* initialValue - 动画值持有者的初始值。
* visibilityThreshold - 动画可以舍入到其目标值的阈值。默认为 Spring.DefaultDisplacementThreshold。
*/
fun Animatable(
    initialValue: Float,
    visibilityThreshold: Float = Spring.DefaultDisplacementThreshold
) = Animatable(
    initialValue,
    Float.VectorConverter,
    visibilityThreshold
)

实际上,这个 Animatable 函数是拿着参数去调用 Animatable 的构造函数从而得到一个 Animatable 对象返回给调用者的。

这种方式有一个限制,就是初始值必须是 Float 类型,假如动画使用的是其他单位,如 Dp。那么只能通过这个持有 Float 类型的 Animatable 获取到动画进行时的 Float 数据,再做一步转换才能得到 Dp 数据。难道 Compose 没有提供持有其他数据类型的 Animatable 吗?当然有,只不过你需要实现 TwoWayConverter 这个接口,告知 Compose 这个“其他类型”与 Float 如何进行双向转换:

@Suppress("NotCloseable")
class Animatable<T, V : AnimationVector>(
    initialValue: T,
    val typeConverter: TwoWayConverter<T, V>,
    private val visibilityThreshold: T? = null,
    val label: String = "Animatable"
)

这是 Animatable 的构造函数,回头看 Animatable 函数在调用 Animatable 构造函数时,给 typeConverter 传的是一个 Float.VectorConverter,这是 Compose 提供的一个现成的 TwoWayConverter 的实现类。常用的类型,如 Color、Int、Float 都有类似的实现类。因此,当想要创建一个 Dp 类型的 Animatable 时,就可以直接调用构造函数:

val animation = remember { Animatable(8.dp, Dp.VectorConverter) }

以上就是创建 Animatable 的两种方式。很容易看出来,第一种方式是针对初始值为 Float 类型的场景做了一个简便函数,当简便函数无法满足需求时,才需要使用底层的构造函数。

TwoWayConverter 的实现

接下来会做一个小小的延伸,看一下 Compose 提供的 TwoWayConverter 是如何实现的:

// VectorConverters.kt:
// Float 的 VectorConverter 实现:
val Float.Companion.VectorConverter: TwoWayConverter<Float, AnimationVector1D>
    get() = FloatToVector

private val FloatToVector: TwoWayConverter<Float, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it) }, { it.value })

// Dp 的 VectorConverter 实现:
val Dp.Companion.VectorConverter: TwoWayConverter<Dp, AnimationVector1D>
    get() = DpToVector

private val DpToVector: TwoWayConverter<Dp, AnimationVector1D> = TwoWayConverter(
    convertToVector = { AnimationVector1D(it.value) },
    convertFromVector = { Dp(it.value) }
)

这里我们找了 Float 和 Dp 两个常用类型,TwoWayConverter 的两个泛型是互相转换的类型,convertToVector 是将 Float、Dp 这种数据类型转换为 AnimationVector 类型的函数,convertFromVector 则是反过来的。比如 FloatToVector 将 Float 转换为 AnimationVector1D 就直接将 Float 的值存入 AnimationVector1D,反向转换时就是直接取出 AnimationVector1D 对象内的 value 就是转换后的 Float 值。

至于 AnimationVector1D 这个类型,是 Compose 在底层进行动画计算的一维类型,从一维到四维,类型也是从 AnimationVector1D 到 AnimationVector4D。像 Float、Dp 这种一维类型就转换为 AnimationVector1D,而像坐标、偏移量、尺寸这些二维类型就转换成 AnimationVector2D:

val DpOffset.Companion.VectorConverter: TwoWayConverter<DpOffset, AnimationVector2D>
    get() = DpOffsetToVector

val Size.Companion.VectorConverter: TwoWayConverter<Size, AnimationVector2D>
    get() = SizeToVector

2.3 使用 Animatable

创建好 Animatable 对象后就可以定制动画过程以展示动画了。这里我们举一个例子,点击绿色正方形让其做大小切换:

@Composable
fun AnimatableSample() {
    // 初始是小尺寸
    var big by remember { mutableStateOf(false) }
    // 规定大小尺寸分别是多少 DP
    val size = remember(big) { if (big) 96.dp else 48.dp }
    // 创建 Animatable 对象
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    // 在 Compose 中启动协程要使用 LaunchedEffect,对重组做了专项优化
    LaunchedEffect(big) {
        // 在执行 animateTo 之前将目标值调整为动画运行的起始值,这就相当于为动画设置了初始值
        // snapTo 是瞬间完成,animateTo 是动画式的渐变完成,因此 snapTo 可以用来设置初始值
//        anim.snapTo(if (big) 192.dp else 0.dp)
        // 挂起函数,要放在协程里
        anim.animateTo(size)
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
        	// 点击 Box 时切换大小
            .clickable { big = !big }
    )
}

Animatable 通过 animateTo() 开启动画过度到指定的 size,由于它是一个挂起函数,因此需要在协程中调用。这里我们不能使用普通协程的 lifecycleScope.launch() 而是使用 LaunchedEffect(),因为前者没有针对 Compose 重组做出优化。LaunchedEffect() 可以传递一个或多个 Any? 类型的参数 key,当 key 发生变化时才执行内部代码,没有变化则不执行。假如填了一个 true、false 这样的常量,那么它就只会在第一次执行,后续因为 key 没有发生变化,就不再执行。

最后我们再考虑设置动画初始值的问题,因为我们引入 Animatable 时说过它可以弥补 animate*AsState() 不能设置动画初始值这个缺点。你可以通过 Animatable 的 snapTo() 在动画开始之前先调整到想要的值,这个值就是动画的初始值:

	LaunchedEffect(big) {
        // 在执行 animateTo 之前将目标值调整为动画运行的起始值,这就相当于为动画设置了初始值
        // snapTo 是瞬间完成,animateTo 是动画完成,因此 snapTo 可以用来设置初始值
        anim.snapTo(if (big) 192.dp else 0.dp)
        // 挂起函数,要放在协程里
        anim.animateTo(size)
    }

animate*AsState() 与 Animatable 的选择上,一种比较简单粗暴的选择方式是,能用 animate*AsState() 就用它,如果实现不了,需要定制动画过程时才使用 Animatable。

虽然从用途和需求上对二者做出了分类,但是它们并不是并列的两套 API,Animatable 是下层基础,而 animate*AsState() 是基于 Animatable 做出的定向的定制。

4、AnimationSpec

不论是 animate*AsState() 还是 Animatable 的 animateTo() 都可以传入 AnimationSpec 以配置动画规格:

@Composable
fun animateDpAsState(
    targetValue: Dp,
    animationSpec: AnimationSpec<Dp> = dpDefaultSpring,
    label: String = "DpAnimation",
    finishedListener: ((Dp) -> Unit)? = null
)

suspend fun animateTo(
    targetValue: T,
    animationSpec: AnimationSpec<T> = defaultSpringSpec,
    initialVelocity: T = velocity,
    block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V>

比如对于上节方块变大变小的例子,可以指定一个弹簧效果:

anim.animateTo(size, spring(Spring.DampingRatioMediumBouncy))

spring() 会返回 SpringSpec,是 AnimationSpec 的实现类。为了全面了解 AnimationSpec,我们先查看一下 AnimationSpec 的继承树。

Compose 中有多种 AnimationSpec 的接口,除了 AnimationSpec,还有 VectorizedAnimationSpec、DecayAnimationSpec、FloatDecayAnimationSpec、VectorizedDecayAnimationSpec 等接口都有各自的继承树。这一节我们主要介绍 AnimationSpec 继承树中的内容:

在这里插入图片描述

4.1 TweenSpec

看到 Tween 这个单词容易联想到视图动画(View Animation)中的补间动画(Tween Animation),但是这两个 Tween 从程序角度上看没有任何关联,不要试图以补间动画的知识去理解 TweenSpec。

Tween 是一个有些古老的词汇,它来自于动画领域的 Inbetween,是指动画主创在完成关键帧后,由助手完成关键帧之间的补帧工作。后续发展成 Tween 这个词,在程序领域中,指程序员指定起始帧和结束帧,然后由程序完成两帧之间的动画补全。

TweenSpec 的主构造有三个参数:

@Immutable
class TweenSpec<T>(
    // 动画时长,默认值是 300ms
    val durationMillis: Int = DefaultDurationMillis,
    // 动画启动延时
    val delay: Int = 0,
    // 缓动,动画曲线设置
    val easing: Easing = FastOutSlowInEasing
) : DurationBasedAnimationSpec<T>

easing 这个单词不论是 Google 的官方翻译还是业界的通用翻译都是“缓动”,指动画是如何进行渐变的,也就是动画曲线。通常我们会从 Compose 提供的四个 Easing 中选取一个使用:

/**
* 从静止状态开始并结束于静止状态的元素使用这种标准的缓动效果。它们会快速加速并逐渐减速,
* 以强调过渡的结束部分。
* 标准缓动通过在减速阶段分配比加速阶段更多的时间,将细微的注意力集中在动画的结尾部分。
* 这是最常见的缓动形式。
* 这相当于 Android 中的 FastOutSlowInInterpolator。
*/
val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)

/**
 * 进入的元素使用减速缓动进行动画处理,这种缓动效果使过渡以峰值速度(元素运动的最快点)开始,
 * 并以静止状态结束。
 * 这相当于 Android 中的 LinearOutSlowInInterpolator。
 */
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)

/**
 * 退出屏幕的元素使用加速缓动效果,它们从静止状态开始,并以峰值速度结束。
 *
 * 这相当于 Android 中的 FastOutLinearInInterpolator。
 */
val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)

/**
 * 它直接返回未经修改的分数值。这在需要一个 [Easing] 但实际上不需要任何缓动效果的情况下,
 * 作为默认值非常有用。
 */
val LinearEasing: Easing = Easing { fraction -> fraction }

四种 Easing 的简介:

  • FastOutSlowInEasing:快速启动、缓慢停止,相当于属性动画中的 AccelerateDecelerateInterpolator。它加速入场并减速出场,也就是说入场和出场时都是慢速的,在中间过程是快速的,适用于按钮点击、卡片展开、元素的强调动作(如弹窗出现)
  • LinearOutSlowInEasing:匀速启动、缓慢停止,相当于属性动画的 DecelerateInterpolator。它入场速度较快,匀速减速到 0 的曲线,适合做元素入场动画
  • FastOutLinearInEasing:快速启动、匀速停止,相当于 AccelerateInterpolator。从静止开始加速退出屏幕(如通知消失)
  • LinearEasing 的动画曲线是一条直线,也就是匀速的

使用时,可以通过 TweenSpec 的构造函数:

anim.animateTo(size, TweenSpec(easing = FastOutSlowInEasing))

也可以使用 Compose 提供的简便函数:

anim.animateTo(size, tween())

简便函数大概只是为了让我们少写几个字母吧,它的参数以及默认值与 TweenSpec 构造函数完全一样:

@Stable
fun <T> tween(
    durationMillis: Int = DefaultDurationMillis,
    delayMillis: Int = 0,
    easing: Easing = FastOutSlowInEasing
): TweenSpec<T> = TweenSpec(durationMillis, delayMillis, easing)

大多数情况下使用现有的四个 Easing 即可,但开发过程难免有需要自己定制的时候,参照现成的实现,都是使用了三阶贝塞尔曲线 CubicBezierEasing():

@Immutable
class CubicBezierEasing(
    private val a: Float,
    private val b: Float,
    private val c: Float,
    private val d: Float
) : Easing

三阶贝塞尔曲线需要四个点才能确定,其中起点固定为 (0,0),终点固定为 (1,1),剩下两个点的四个坐标就是 CubicBezierEasing() 的四个参数。至于如何确定这四个参数,可以借助一个三阶贝赛尔曲线的动画网站来确定:

在这里插入图片描述

左侧坐标的横轴表示时间,纵轴表示动画进度,如上图所示就是 CubicBezierEasing 的图像。

4.2 SnapSpec

SnapSpec 与 TweenSpec 是兄弟,它的效果与前面介绍过的 Animatable 的 snapTo() 是一样的效果。当 animateTo() 使用 SnapSpec 时,与直接使用 snapTo() 的唯一区别是 SnapSpec 可以增加一个延时:

anim.animateTo(size, SnapSpec(3000))

SnapSpec 也有个简便函数 snap():

anim.animateTo(size, snap(3000))

4.3 KeyframesSpec

Keyframes 是关键帧的意思,意味着在动画运行过程中可以选取几个关键的时间点,给出对应时间点的动画完成度,KeyframesSpec 就可以根据这些信息计算出整个动画完整的速度曲线。可以看作分段式的 TweenSpec,即将整个动画按照时间点分段,每一段都是一个 TweenSpec。

KeyframesSpec 的构造函数只有一个参数 KeyframesSpecConfig,可以配置动画属性:

@Immutable
class KeyframesSpec<T>(val config: KeyframesSpecConfig<T>) : DurationBasedAnimationSpec<T> {
    class KeyframesSpecConfig<T> {
        // 动画时长
        var durationMillis: Int = DefaultDurationMillis
        // 动画延时启动
        var delayMillis: Int = 0
        // 关键帧
        internal val keyframes = mutableMapOf<Int, KeyframeEntity<T>>()
        // 中缀函数用于指定时间点
        infix fun T.at(/*@IntRange(from = 0)*/ timeStamp: Int): KeyframeEntity<T> {
            return KeyframeEntity(this).also {
                keyframes[timeStamp] = it
            }
        }
        ...
    }
}

基于以上源码,如果想通过 KeyframesSpec 的构造函数创建动画,大致代码如下:

// 创建 KeyframesSpecConfig 对象通过 apply() 指定具体属性
anim.animateTo(size, KeyframesSpec(KeyframesSpec.KeyframesSpecConfig<Dp>().apply {
    durationMillis = 450
    delayMillis = 500
    144.dp at 150 with FastOutLinearInEasing
    20.dp at 300
}))

这样写起来很麻烦,因此 Compose 提供了简便方法 keyframes():

// size 是动画的目标值,也就是终点值
anim.animateTo(size, keyframes {
    // 指定动画时长为 450ms
    durationMillis = 450
    delayMillis = 500
    // 指定在 150ms 时 size 为 144dp,并且从 150ms 这个时间点
    // 开始的这一段动画曲线为 FastOutLinearInEasing
    144.dp at 150 with FastOutLinearInEasing
    // 指定在 300ms 时 size 为 20dp,没有显式指定 Easing,默认
    // 使用 LinearEasing
    20.dp at 300
})

看到这里应该能理解为什么 TweenSpec 和 SnapSpec 有简便方法了,其实主要还是为了给 KeyframesSpec 提供简便方法 keyframes(),同时为了保持一致,就把另外两个也带上了。

使用 KeyframesSpec 有一个限制,就是这样写动画复用性降低了。比如要实现一个反向动画,只能重新写一个 keyframes()。

到这里,DurationBasedAnimationSpec 的三个子类就介绍完了,DurationBased 意思是动画时长都是确定的。

4.4 SpringSpec

与 DurationBasedAnimationSpec 是兄弟关系的 Spec 有两个 —— SpringSpec 和 RepeatableSpec,本节我们先介绍 SpringSpec。

SpringSpec 是 DurationBasedAnimationSpec 的兄弟,意味着它不具备 DurationBasedAnimationSpec 可以确定动画时长的特性。动画有不基于时长的吗?似乎原生的属性动画都是有动画时长的,Compose 却没有?实际上,这种认识是片面的。原生的属性动画确实都有动画时长,但是却没有 SpringSpec 这种基于物理模型的弹簧动画。因此 Compose 不仅没有落后于属性动画,反而是相对于原生增加了一种大的动画类别。

此外,Jetpack 库也提供过弹簧动画的扩展库,同样也是不能设置动画时长的,因为基于物理模型的动画没办法设定一个准确的动画时长。

具体使用上,我们来看前面已经提到过的 spring 函数:

/**
* 创建一个使用给定弹簧常量(即阻尼比和刚度)的 SpringSpec。可选的 visibilityThreshold 
* 定义了当动画在视觉上足够接近目标值时,可以将其舍入到目标值。
* 参数:
* dampingRatio - 弹簧的阻尼比。默认为 Spring.DampingRatioNoBouncy。
* stiffness - 弹簧的刚度。默认为 Spring.StiffnessMedium。
* visibilityThreshold - 可选参数,指定可见性阈值。
*/
@Stable
fun <T> spring(
    dampingRatio: Float = Spring.DampingRatioNoBouncy,
    stiffness: Float = Spring.StiffnessMedium,
    visibilityThreshold: T? = null
): SpringSpec<T> =
    SpringSpec(dampingRatio, stiffness, visibilityThreshold)

下面简单解释三个参数的含义与用法。

dampingRatio 是弹簧的阻尼比,它决定了弹簧有多“弹”,值越小,弹性越大;值越大,弹簧的弹性越差,到达目标值时就不会振动。用在动画上就是控制动画的振动幅度和收敛速度:

  • 0 < dampingRatio < 1(欠阻尼):动画会有弹性振动效果(类似弹簧回弹)
  • dampingRatio = 1(临界阻尼):动画快速平滑收敛,无振动(默认值 DampingRatioNoBouncy = 1f)
  • dampingRatio > 1(过阻尼):动画缓慢收敛,无振动

stiffness 是弹簧的刚度系数,指弹簧在受到外力作用时,产生单位变形所需的载荷。刚度越大,压缩之后越容易回弹。用在动画中用于控制动画速度,值越大动画越快达到目标(类似更硬的弹簧),默认值 StiffnessMedium = 1500f,平衡了速度和流畅度。

假如想做一个阻尼低(来回弹的次数很多),刚度小(弹的速度很慢)的动画,可以使用如下预设值:

// 阻尼 0.2f,刚度 50f
anim.animateTo(size, spring(Spring.DampingRatioHighBouncy, Spring.StiffnessVeryLow))

visibilityThreshold 是可见性阈值,当弹簧偏离原点距离小于阈值时强制让弹簧的运动停下来,实际上就是在定义动画何时被认为已经完成,当动画值与目标值的差异小于此阈值时,动画自动结束。设置它主要是为了防止阈值过小导致肉眼不可见或阈值过大导致用户还能看到动画运动时就突然停止了。

animateTo() 使用 SpringSpec 时可以搭配初速度实现震动效果:

anim.animateTo(
    size,
    spring(Spring.DampingRatioHighBouncy, Spring.StiffnessVeryLow),
    2000.dp // 初始速度
)

4.5 RepeatableSpec

RepeatableSpec 用于重复播放动画,我们直接看它的简便方法 repeatable():

/**
* 创建一个 RepeatableSpec,用于播放基于时间的 DurationBasedAnimationSpec(例如 TweenSpec、
* KeyframesSpec),并按照 iterations 指定的次数重复播放。
* 迭代次数描述了动画将运行的次数。1 表示不重复。如果需要创建无限重复的动画,建议使用 infiniteRepeatable。
*
* 注意:在 RepeatMode.Reverse 模式下重复时,强烈建议使用奇数的迭代次数。否则,动画
* 在完成最后一次迭代时可能会跳转到结束值。
* 
* initialStartOffset 可用于延迟动画的开始或将动画快进到给定的播放时间。此起始偏移量不会重复,
* 而动画中的延迟(如果有)将会重复。默认情况下,偏移量为 0。
*/
@Stable
fun <T> repeatable(
    // 总迭代次数,应大于 1 以实现重复
    iterations: Int,
    // 需要重复的动画
    animation: DurationBasedAnimationSpec<T>,
    // 动画重复的方式,是从头开始(即 RepeatMode.Restart)还是从末尾开始(即 RepeatMode.Reverse)
    repeatMode: RepeatMode = RepeatMode.Restart,
    // 动画的起始偏移量
    initialStartOffset: StartOffset = StartOffset(0)
): RepeatableSpec<T> =
    RepeatableSpec(iterations, animation, repeatMode, initialStartOffset)

参数讲解:

  1. iterations:总迭代次数,就是动画播放的次数,大于 1 才能重复播放动画,填 1 不重复,填 0 报错
  2. animation:需要重复的动画,类型必须是 DurationBasedAnimationSpec,也就是其子类的 TweenSpec、SnapSpec 以及 KeyframesSpec 之一
  3. repeatMode:动画重复的方式,有两种选择,从头开始(即 RepeatMode.Restart)还是从末尾开始(即 RepeatMode.Reverse),需要注意如果是 RepeatMode.Reverse 的话,iterations 必须是一个奇数,否则,动画在完成最后一次迭代时可能会直接跳到结束值
  4. initialStartOffset:动画起始偏移量,这个是时间上的偏移量,而不是位置上的偏移量

解释一下为什么 repeatMode 是 RepeatMode.Reverse 时 iterations 必须是奇数。比如动画的初始值是 48,目标值是 96,Reverse 模式下 iterations 为 2。那么 48 -> 96 是第一次,第二次倒放就是 96 -> 48,由于我们是通过 animateTo() 指定目标值并展示动画,因此动画结束时必须在目标值 96 上,因此这里会有一个 48 直接跳到 96 的变化,这与设定的倒放 2 次最终应该处于 48 的结果不符。

一种解决方案是使用 KeyframesSpec 设置起始值和目标值都为 48,中间放一个关键帧,值为 96,这样就做出了上面那种 48 -> 96 -> 48 的重复效果了。

最后再详细说一下 initialStartOffset,其类型是 StartOffset,主构造被设置成 private 的,次构造提供了两个参数:

@kotlin.jvm.JvmInline
value class StartOffset private constructor(internal val value: Long) {
    /**
     * 为 [repeatable] 和 [infiniteRepeatable] 创建一个起始偏移量。[offsetType] 可以是以下两种之一:
     * [StartOffsetType.Delay] 和 [StartOffsetType.FastForward]。[offsetType] 默认为 
     * [StartOffsetType.Delay]
     *
     * [StartOffsetType.Delay] 会将动画的开始延迟 [offsetMillis] 指定的时间,而
     * [StartOffsetType.FastForward] 会立即从动画的 [offsetMillis] 处开始播放动画。
     */
    constructor(offsetMillis: Int, offsetType: StartOffsetType = StartOffsetType.Delay) : this(
        (offsetMillis * offsetType.value).toLong()
    )
}

第一个参数就是以毫秒为单位的偏移量,第二个参数 offsetType 有两种类型可选:

  • Delay:延时模式,即为动画设置启动延时,延时 offsetMillis 毫秒后再开始播放动画
  • FastForward:立即播放模式,跳过前 offsetMillis 毫秒的动画,快进到 offsetMillis 这个时间点开始播放后续动画

4.6 InfiniteRepeatableSpec

前面讲到的 DurationBasedAnimationSpec、RepeatableSpec 以及 SpringSpec 是 FiniteAnimationSpec 接口的子类或子接口,而接下来要介绍的 InfiniteRepeatableSpec 与 FiniteAnimationSpec 是兄弟关系。

InfiniteRepeatableSpec 用于展示无限循环的动画,它与 RepeatableSpec 在本质上是没有区别的,只不过循环次数一个是有限的,一个是无限的,并且有限循环的 RepeatableSpec 可以计算出总的动画时长。

如果一个动画使用了 InfiniteRepeatableSpec,那动画何时会结束,这个过程中会涉及到内存泄漏或者性能损耗吗?

animateTo() 结束的时候,动画就结束了。因为 animateTo() 是挂起函数,它需要在 LaunchedEffect() 内的协程环境中才能调用。当 LaunchedEffect() 结束时,就会让 animateTo() 结束。而 LaunchedEffect(key) 会在 key 发生变化时重新启动,当它重启时,上一次执行的 LaunchedEffect() 就会自动结束。

当 animateTo() 所在的协程结束时动画就会停止,不会有性能损耗或内存泄漏。

4.7 其他 Spec

AnimationSpec 接口一共有三个子类或子接口:FiniteAnimationSpec 和 InfiniteRepeatableSpec 我们已经讲过,还剩下一个 FloatAnimationSpec 接口:

@JvmDefaultWithCompatibility
interface FloatAnimationSpec : AnimationSpec<Float> {...}

这个接口把 AnimationSpec 的泛型类型直接实例化为 Float,因此它的两个子类 FloatTweenSpec 与 FloatSpringSpec 就是针对 Float 类型的动画规格了。

既然已经有了支持通用类型的 TweenSpec 和 SpringSpec,为什么还要单独为 Float 类型做对应的类呢?实际上 FloatTweenSpec 与 FloatSpringSpec 是为 Compose 更底层的动画计算做辅助工作的,不是给上层开发用的。因此,不要去使用这两个类。

在 AnimationSpec 之外,还有其他的 AnimationSpec 接口:

  • VectorizedAnimationSpec:也是辅助底层动画计算的接口,它的继承树结构与 AnimationSpec 的十分相似。Vectorized 译为向量化,因为 Compose 底层计算动画都是要先统一转成向量,也就是密封类 AnimationVector 中的 AnimationVector1D ~ AnimationVector4D
  • DecayAnimationSpec:衰减动画,后续会与 Animatable 的 animateDecay() 一起讲解

参考资料:

Compose 动画文档

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值