总结:如果能满足需求,尽量使用 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 种缓动实现,不过它们的命名十分迷惑,似乎谷歌的工程师脑回路都很清奇...
Easing | Interpolator | 描述 |
---|---|---|
FastOutSlowInEasing | AccelerateDecelerateInterpolator | 先加速再减速 |
LinearOutSlowInEasing | DecelerateInterpolator | 减速,适合入场动画 |
FastOutLinearInEasing | AccelerateInterpolator | 加速,适合出场动画 |
LinearEasing | LinearInterpolator | 匀速 |
(这些缓动效果以顶级变量形式提供)
其中的三个非线性动画使用了三阶贝塞尔曲线,它一共需要 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。
💡把动画起止状态设置成一样,但是给一个初速度,配上适当的阻尼比,可以做出原地震动的效果。初速度 initialVelocity
是 animateTo()
的参数。
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 四个维度。这个接口的实现们就处理这些数学上的东西了。