总结:如果能满足需求,尽量使用 animate*AsState()。若需要控制起始值则使用 Animatable

状态切换动画

Android View 体系中,动画几乎默认就是属性动画了。同学们对它的原理应该不陌生,无非是高频地连续地修改 View 属性从而达到动画的效果。Compose 中组件自己不再有状态,但状态没有消失,通常以 MutableState 的方式存储在某个地方,对状态的修改可以触发界面刷新。所以完全可以借用 View 属性动画的思想,高频率地连续地修改某个状态,照样能实现动画。

即使没有任何 API,我们也可以通过协程等方式实现这个功能。对 Compose 团队来说,可以提供一套类似 ObjectAnimator 的 API 来简化使用。实际上,这样还不够简化,开发者还得指明哪个属性、变化到多少、作用在哪个对象,并且这种调用风格也和「声明式」格格不入

干脆一不做二不休,Compose 直接提供高度封装后的 API animate*AsState(),传入当前需要的值,返回一个不可变的 State。每当传入的值改变时其内部会计算合适的动画时长,并实时输出最新的值。因为自身是 State 因此也不用我们手动刷新 UI 了,只需像平常一样使用这个属性就好。

比如:

var big by remember { mutableStateOf(false) }
val size by animateDpAsState(targetValue = if (big) 32.dp else 64.dp)
Box(modifier = Modifier
    .size(size) // 像普通的 MutableState 一样使用
    .background(Color.Green)
    .clickable { big = !big })

点击这个 Box 其大小会动画地变换。

简单往往意味着鸡肋,animate*AsState() 属于上层动画 API,简单到一行代码实现动画,也简单到难以手动设置起始值或进行其他定制。遇到复杂一点的需求记得老老实实回到下面要说的低级别 API。

Animatable

animate*AsState() API 封装的非常好,同时也意味着隐藏了太多的细节。如果要更好地定制动画,需要更底层的 API,比如 Animatable

Animatable 只能计算 float 数据,对于其他类型(比如 Dp)则需要提供 TwoWayConverter 指明如何与 float 互转。幸运的是常见数据类型 Compose 默认提供了对应的转换器,比如 Dp.VectorConverter,熟悉一下这个命名规则,先试一下有没有,别傻乎乎的上来就自己写一个😄

实际上需要的是 AnimationVectorXD 类型,其中 X 可能为 1~4。这是为了能够统一多维数据的表示,比如位置数据就是两维的,需要两个 float。这个 Vector 的背后才是 float。因此存在 Float.VectorConverter 这么个看起来比较奇怪的东西。

基本用法如下:

var big by remember { mutableStateOf(false) }
// 设置动画初始值
val sizeAnim = remember { Animatable(if (big) 64.dp else 36.dp, Dp.VectorConverter) }
LaunchedEffect(key1 = big) {
  sizeAnim.snapTo(16.dp) // 手动设置起始值
  sizeAnim.animateTo(if (big) 64.dp else 36.dp) // 启动动画
}
Box(modifier = Modifier
    .size(sizeAnim.value)
    .background(Color.Green)
    .clickable { big = !big })

明显,实现相同的需求 Animatable 麻烦多了。但 Animatable.snapTo() 方法允许瞬间设置值,从而达到随意控制动画起始值的目的。

惯性动画

Animatable 还有个方法叫 animateDecay(),主要用于惯性动画,比如快速滑过手指抬起后,组件应该继续移动或滚动的动画。与常规动画相比最大的不同是,它需要指定初始速度,并且不需要动画时间与结束状态,这些都是根据物理模型算出来的

Decay 惯性动画只能使用独立的 DecayAnimationSpec 去配置,不兼容 AnimationSpec,这俩没有继承关系。目前只有一个实现类,选择困难患者大喜,可惜喜太早了,这一个私有的实现类却有两个创建函数:

  • splineBasedDecay() : 它使用与 Android 原生 View (OverScroller) 相同的惯性算法,需要一个像素密度参数。实际开发中使用便捷函数 rememberSplineBasedDecay() 可以省掉获取密度的代码。为了能及时响应系统配置变更,所以把 remember 一并加上了。
  • exponentialDecay(): 另一种减速算法,视觉上与 spline 差不多。它不会通过像素密度做修正,但可手动指定一个摩擦力修正系数

像素密度用于作为物理模型中摩擦力的修正因子,它俩是正相关的。

比如用户同样是在 1 秒时间内划过了 1000px,显然,像素密度小的手机上划过的物理距离更长,也就是划地更「猛」,那么惯性滑动时间应该更长,距离更远。为此,应该把摩擦力减小一点。所以,像素密度越小,摩擦力越小。反过来一样的道理。

追根究底是因为像素数不能代表实际的物理长度,需要乘上屏幕密度这个系数才行。

因为 splineBasedDecay() 利用像素密度做了修正,因此不要使用 dp 作为惯性动画的单位,否则相当于两次修正。同理,其他一些与像素密度无关的动画/单位也不推荐,应该采用 exponentialDecay()

动画配置

上面的两种动画 API 都能够接受一个 AnimationSpec 参数来配置各种属性。学到现在 Compose 的脾气咱也基本了解了,一个类很多时候不用自己 new,而是用现成的一些函数来创建。AnimationSpec 也不例外,比如 tween()TweenSpec() 都可以创建一个补间动画配置对象,到底用哪个就看自己喜好吧,不过对于某些类,函数方式的确简便不少。

AnimationSpec 实现类巨多。把它们的实现关系铺平了,有这么几类:

  • DurationBasedAnimationSpec: SnapSpec, TweenSpec, KeyframesSpec。这类动画的总时长是确定的。
  • FiniteAnimationSpec: SpringSpec, RepeatableSpec,以及所有的 DurationBasedAnimationSpec。这些动画是有限的(早晚会播放完毕),但时长不好说。
  • InfiniteRepeatableSpec: 自己就是个实现类,用于无限循环的动画。

长度不确定的动画乍一听好像比较奇怪,其实这种更符合实际物理情况,而速度曲线算法只是拙劣地模拟了物理效果而已。因此 Compose 中默认的动画效果选择了 SpringSpec 这种更真实的非定长动画。

SnapSpec

应该还记得 Animatable.snapTo() 这个方法吧。SnapSpec 也是这个效果,即没有动画跳变过去。

下面三种写法是等价的:

sizeAnim.snapTo(16.dp)
sizeAnim.animateTo(16.dp, SnapSpec())
sizeAnim.animateTo(16.dp, snap())

snapTo() 方法唯一的区别就是可指定一个延时。

的确比较鸡肋...

TweenSpec

如果非要翻译的,可以叫做「补间动画」,也就是给出开始与结束状态,以及持续时间,系统来计算期间任意时刻的状态。补间动画也是最典型的动画实现方案,原生 View 系统中的属性动画以及后来的对象动画也是这种实现。这里动画要求前后状态必须有改变才可以使用。

不难看出,补间动画其实是一个关于时间的函数,如果采用线性函数,就是线性动画(即匀速动画),若采用其他函数就会有不一样的效果。曾经这些函数被称为插值器 Interpolator,在 Compose 中它们换了个更大众的名字「缓动」Easing。默认提供 4 种缓动实现,不过它们的命名十分迷惑,似乎谷歌的工程师脑回路都很清奇...

EasingInterpolator描述
FastOutSlowInEasingAccelerateDecelerateInterpolator先加速再减速
LinearOutSlowInEasingDecelerateInterpolator减速,适合入场动画
FastOutLinearInEasingAccelerateInterpolator加速,适合出场动画
LinearEasingLinearInterpolator匀速

(这些缓动效果以顶级变量形式提供)

其中的三个非线性动画使用了三阶贝塞尔曲线,它一共需要 4 个点,Compose 写死了其中的两个,分别是 (0,0), (1,1),我们只需要再确定两个控制点就好,因此 CubicBezierEasing(x1, y1, x2, y2) 有四个参数。有一个完美的工具 cubic-bezier.com 能可视化地编辑这条曲线,坐标系也和 Compose 的定义一样。图中曲线的斜率代表动画速度。

补间动画其实是动画行业的一个术语,而不是程序行业。不禁回想起自己初一玩 Flash 时第一次接触到这个词,转眼 Flash 落幕好几年了,而当初的那些人,又还有几个在身边呢?如果终究要离别,又何必遇见。

和 Flash 一样,补间动画在程序中也已经过时了。

KeyframesSpec

关键帧动画,顾名思义了吧。 TweenSpec 可以看作是特殊的 KeyframesSpec,前者相当于只定义开始结束两个关键帧,后者则可以定义多个。

可以想象,创建这种动画配置非常麻烦,因为它的参数个数取决于关键帧个数,因此必然需要一层额外的包装统一传给构造函数。这种情况下使用便捷函数 keyframes() 优势就非常明显了。

所谓关键帧,就是指定某时刻动画的属性应该是多少,代码是这样的:

val anim = remember { Animatable(100.dp, Dp.VectorConverter) }
LaunchedEffect(Unit) {
    anim.animateTo(200.dp, keyframes {
        50.dp at 100
        100.dp at 200 with LinearOutSlowInEasing // set easing in [200, end]
        durationMillis = 5000 // set duraion
    })
}

借助 Kotlin 中缀函数的特性,T.at(Int) 可以按上面这种形式调用,返回一个 KeyframeEntity 对象。

总结一下:

  • KeyframesSpec 支持 TweenSpec 的所有属性。
  • 使用 at() 定义关键帧。
  • 使用 with() 定义速度曲线(从此关键帧开始生效)。默认是 Linear,这点和 TweenSpec 不一样,但很好理解。
  • 如果要定义第一段的速度曲线,则需要把动画起始状态再写一遍到关键帧里。记得要和真正的初始值一样,否则就要跳变了。

另外 KeyframesSpec 所定义的动画在反向播放时各个关键帧不会自动逆序,需要再写一个 KeyframesSpec 才可以,的确不太优雅...

SpringSpec

SpringSpec 基于弹簧这种物理模型,因此参数也是和物理有关的,恐怕 Andriod 开发者们不太熟悉。目前 spring() 提供了三个参数:

fun <T> spring(
    dampingRatio: Float = Spring.DampingRatioNoBouncy, // 1f
    stiffness: Float = Spring.StiffnessMedium, // 1500f
    visibilityThreshold: T? = null // 0.01 (in dp animation)
): SpringSpec<T>

💡提示:前两个参数可以用内置的语义化常量来设置,省得自己摸不清这些单位的刻度。

  • dampingRatio: 阻尼比。说人话就是弹簧有多「弹」,也就是弹的过程中的阻力。1 是没有弹的效果,超过 1 动画会更慢,小于 1 就会弹起来。越小弹的次数越多,视觉上弹地越快。设置为 0 就没有动画了,等同于 SnapSpec
  • stiffness: 刚度。人话就是弹簧有多「硬」。越硬的弹簧弹起来越快,如果很软的话就想一个布丁了。使用这个参数可以让动画变慢的同时还有「弹」的效果。
  • visibilityThreshold: 可视阈值。这个不是物理参数了,而是精度。真实世界是模拟量,弹簧会弹很多很多次,只不过后面的幅度微乎其微,肉眼都看不出来。表现在屏幕上可能连一像素都不到,那么这种运动就没必要继续计算了。这个参数表示弹的幅度小于多少时视为停止。类型和单位与动画的单位一致。设置过大动画会突然停住,过小会有多余计算。

注意,对 dp 单位做动画的话,0.01 的精度用户已经感受不出来了。但若动画的那个属性本身就很敏感,可能整个动画不过就是在 0.01 和 0.02 之间变换,那精度就得手动设置足够小,比如 0.000001。

💡把动画起止状态设置成一样,但是给一个初速度,配上适当的阻尼比,可以做出原地震动的效果。初速度 initialVelocityanimateTo() 的参数。

RepeatableSpec

应该能出来,这是一个套娃的东西,它需要包裹另一个 DurationBasedAnimationSpec 来赋予它重复执行的能力。注意被包裹的动画类型有要求,比如 SpringSpec 就不行,想多层套娃的也可以歇歇了。

函数定义如下:

fun <T> repeatable(
    iterations: Int,
    animation: DurationBasedAnimationSpec<T>,
    repeatMode: RepeatMode = RepeatMode.Restart,
    initialStartOffset: StartOffset = StartOffset(0)
)

注意点:无论动画过程如何,最终一定会停在目标状态,而 AnimationSpec 只对过程进行配置。如果指定重复模式为 RepeatMode.Reverse 并且重复次数为偶数,那么动画的最后一刻会回到起始状态,然后跳变到结束状态。

initialStartOffset 控制动画何时、从哪开始播放。当类型为 StartOffsetType.Delay 等效于添加一个启动延时。StartOffsetType.FastForward 则是从动画的一半开始播放。例如原动画有 400ms,添加了一个 300ms 的快进,那么就只播放最后的 100ms,动画实际时长也只有 100ms 了。RepeatableSpec 只接受时长固定的动画大概也是因为这个参数的存在。

另外,InfiniteRepeatableSpec 和这个基本一致,使用上也一致。

其他

下面这些已经不是 AnimationSpec 的子类了,日常开发中用不到,语法上也不允许把这些作为参数。

FloatAnimationSpec

这是一个接口,目前有两个实现类:FloatTweenSpec, FloatSpringSpec。从名字能看出作用,它们就是把对应的那两个效果的泛型给写死成 Float ,仅此而已。其实 Kotlin 的类型推断已经很强了,用这个专用类代码上一点都没有简洁,甚至还要多打几个字母。

那它到底有啥用?我也不知道🤣

VectorizedAnimationSpec

一个几乎是 AnimationSpec 的镜像的接口,它的实现类是 AnimationSpec 对应实现类的底层计算。Compose 动画中一切计算基于 Float,并且被包装为 1~4 四个维度。这个接口的实现们就处理这些数学上的东西了。

Last modification:January 26, 2024