思想
Compose的动画系统是基于值系统的动画,和传统的基于回调的动画不同,Compose的动画api通常对外暴露一个可观察的随时间改变的状态,进而驱动重组或者重绘,从而达成动画的效果
基本使用
可见性动画使用AnimatedVisibility,内容尺寸动画用animateContentSize,根据不同的状态展示不同的Composable之间的动画切换使用AnimatedContent
,也可以使用Crossfade
简单的值动画使用使用animateXXAsState,多个动画同时启用,可以用Transition进行管理,Transition可以基于状态启动多个动画
如果需要在启动的时候就进行一个动画,推荐使用Transition或者Animtable动画接口,通过在可组合函数中声明一个基础状态,在compose中启动该动画是一个副作用效应,应该在LaunchEffect中进行,Animatable提供的animateTo是一个中断函数,直到动画状态进行到目标状态时才会恢复,通过该特性我们可以执行序列化的动画,同样地,如果我们想要动画同时执行的话,可以通过启动多个协程来达成这一点
Compose动画api的设计
底层动画为实现Animation
接口的两个对象,一个是TargetBasedAnimation
,提供了从起始值到目标值变更的动画逻辑,DecayAnimation
一个无状态的动画,提供了一个从初始速度渐渐慢下来的动画逻辑(滑动动画)
Animation的目标是提供无状态的动画逻辑,因此他是一个底层组件,顶层组件在Animation的基础上构建有状态的动画逻辑,设计为无状态的接口,意味着不同于安卓View动画,是没有pause,cancel等逻辑的,换句话说,如果使用底层动画api创建的动画被取消了,并且需要重新开始,这种情况下应该重新创建一个底层动画的实例,并且初始值和初始速度为之前取消时的当前值和当前速度
Animatable是在Animation的基础上封装的有状态的动画api,提供停止和开始动画的能力
Animatable使用
- 创建动画实例,指定初始值,如果是在compose中使用,要用remember保存起来
java
val animatable = remember { Animatable(targetValue, typeConverter, visibilityThreshold, label) }
- 启动动画
根据动画是以值动画为基础,还是以速度为基础,分别调用不同的api,前者是animateTo,后者是animateDecay,这两个api实际上也对应了底层两个不同的Animation类型,
可以看到在启动动画这里
java
animatable.animateTo(newTarget, animSpec)
- 停止动画
分为两种两种,一种是我们需要手动停止动画,另外一种是随着需要动画的composable退出组合自动停止动画,其中手动停止动画,直接调用Animatable的stop方法,另外一种是自动停止动画,只需要在使用该动画的composable处使用LaunchEffect启动动画就好
手动停止动画会设置一个flag,在动画执行的过程中会检查该flag,从而达到停止动画的目的,而自动停止动画,则是通过协程的取消机制来保证的,
java
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)
}
private suspend fun runAnimation(
animation: Animation<T, V>,
initialVelocity: T,
block: (Animatable<T, V>.() -> Unit)?
): AnimationResult<T, V> {
// Store the start time before it's reset during job cancellation.
val startTime = internalState.lastFrameTimeNanos
return mutatorMutex.mutate {
try {
internalState.velocityVector = typeConverter.convertToVector(initialVelocity)
targetValue = animation.targetValue
isRunning = true
val endState = internalState.copy(
finishedTimeNanos = AnimationConstants.UnspecifiedTime
)
var clampingNeeded = false
endState.animate(
animation,
startTime
) {
updateState(internalState)
val clamped = clampToBounds(value)
if (clamped != value) {
internalState.value = clamped
endState.value = clamped
block?.invoke(this@Animatable)
cancelAnimation()
clampingNeeded = true
} else {
block?.invoke(this@Animatable)
}
}
val endReason = if (clampingNeeded) BoundReached else Finished
endAnimation()
AnimationResult(endState, endReason)
} catch (e: CancellationException) {
// Clean up internal states first, then throw.
endAnimation()
throw e
}
}
}
动画被放在mutatorMutex.mutate逻辑中,这个函数被设计成启动成获取新锁的时候会取消前一个协程,等前一个协程取消时,再重新获取锁,保证同一时间只有一段代码在访问临界区的Animatable相关状态,可以看到多次调用Animatable的animateTo方法,会将当前动画的进度和速度保存下来,作为新的起点,因此如果动画进行到一半,动画被取消了,动画会从一半的进度继续播放到目标进度,如果动画进行已经结束了,这个时候再调用animateTo,则会相当于重新执行动画
Animtable将一些动画的内部状态保留在AnimationState
这个数据结构中,
实际的动画逻辑在此处
java
endState.animate(
animation,
startTime
) {
updateState(internalState)
val clamped = clampToBounds(value)
if (clamped != value) {
internalState.value = clamped
endState.value = clamped
block?.invoke(this@Animatable)
cancelAnimation()
clampingNeeded = true
} else {
block?.invoke(this@Animatable)
}
}
首先我们看下animate函数本身做了什么
java
internal suspend fun <T, V : AnimationVector> AnimationState<T, V>.animate(
animation: Animation<T, V>,
startTimeNanos: Long = AnimationConstants.UnspecifiedTime,
block: AnimationScope<T, V>.() -> Unit = {}
) {
val initialValue = animation.getValueFromNanos(0) // 获取动画的初始值
// 获取动画的初始速度
val initialVelocityVector = animation.getVelocityVectorFromNanos(0)
// 如果前一个动画是被取消的,那么这两个值都会继承
var lateInitScope: AnimationScope<T, V>? = null
try {
if (startTimeNanos == AnimationConstants.UnspecifiedTime) {
val durationScale = coroutineContext.durationScale
animation.callWithFrameNanos {
lateInitScope = AnimationScope(
initialValue = initialValue,
typeConverter = animation.typeConverter,
initialVelocityVector = initialVelocityVector,
lastFrameTimeNanos = it,
targetValue = animation.targetValue,
startTimeNanos = it,
isRunning = true,
onCancel = { isRunning = false }
).apply {
// First frame
doAnimationFrameWithScale(it, durationScale, animation, this@animate, block)
}
}
} else {
lateInitScope = AnimationScope(
initialValue = initialValue,
typeConverter = animation.typeConverter,
initialVelocityVector = initialVelocityVector,
lastFrameTimeNanos = startTimeNanos,
targetValue = animation.targetValue,
startTimeNanos = startTimeNanos,
isRunning = true,
onCancel = { isRunning = false }
).apply {
// First frame
doAnimationFrameWithScale(
startTimeNanos,
coroutineContext.durationScale,
animation,
this@animate,
block
)
}
}
// Subsequent frames
while (lateInitScope!!.isRunning) {
val durationScale = coroutineContext.durationScale
animation.callWithFrameNanos {
lateInitScope!!.doAnimationFrameWithScale(it, durationScale, animation, this, block)
}
}
// End of animation
} catch (e: CancellationException) {
lateInitScope?.isRunning = false
if (lateInitScope?.lastFrameTimeNanos == lastFrameTimeNanos) {
// There hasn't been another animation.
isRunning = false
}
throw e
}
}
根据是第一次启动该动画(或者重新运行)构建一个AnimationScope,记录下动画开始的正确时间戳,创建完该Scope对象,调用该Scope对象上的doAnimationFrameWithScale方法,后续只要动画没有取消,就一直跟随时钟运行动画
java
private fun <T, V : AnimationVector> AnimationScope<T, V>.doAnimationFrameWithScale(
frameTimeNanos: Long,
durationScale: Float,
anim: Animation<T, V>,
state: AnimationState<T, V>, // endState
block: AnimationScope<T, V>.() -> Unit
) {
val playTimeNanos =
if (durationScale == 0f) {
anim.durationNanos
} else {
((frameTimeNanos - startTimeNanos) / durationScale).toLong()
}
// 根据外部的尺度来决定动画的速度
doAnimationFrame(frameTimeNanos, playTimeNanos, anim, state, block)
}
private fun <T, V : AnimationVector> AnimationScope<T, V>.doAnimationFrame(
frameTimeNanos: Long,
playTimeNanos: Long,
anim: Animation<T, V>,
state: AnimationState<T, V>,
block: AnimationScope<T, V>.() -> Unit
) {
lastFrameTimeNanos = frameTimeNanos
value = anim.getValueFromNanos(playTimeNanos) // 根据时长获取当前值
velocityVector = anim.getVelocityVectorFromNanos(playTimeNanos)
val isLastFrame = anim.isFinishedFromNanos(playTimeNanos)
if (isLastFrame) {
// TODO: This could probably be a little more granular
// TODO: end time isn't necessarily last frame time
finishedTimeNanos = lastFrameTimeNanos
isRunning = false
}
updateState(state)
block()
}
getValueFromNanos的调用链路,以TargetBasedAnimation
为例,
调用该方法,实际将方法转发给插值器,给插值器当前时间,以及初始状态,目标状态,初始速度等等值给出当前的值,如果当前动画在这一帧结束,就不再运行动画,将部分状态同步到state中,然后执行block, block代码如下
java
updateState(internalState)
val clamped = clampToBounds(value)
if (clamped != value) {
internalState.value = clamped
endState.value = clamped
block?.invoke(this@Animatable)
cancelAnimation()
clampingNeeded = true
} else {
block?.invoke(this@Animatable)
}
首先是将scope的值同步到internalState,internalState对外暴露了一个state,这个state里面保存了最新的值,外部通过观察这个值,就能够得到当前动画的最新值
Compose动画中的插值器
首先这里存在两个概念,第一个是转换器,转换器是将我们外部希望进行动画变更的状态转换成动画库内部使用的状态,第二个是插值器,插值器能够对内部状态根据动画当前进行时长给出一个合理的值,插值器的命名都是XXXSpec,根据动画是否是一直运行的,分为FiniteAnimationSpec和InfiniteRepeatableSpec,插值器在安卓原生动画中的命名是以xxInterpolator来命名的,插值器的暗示意味更强
目前思考下来,动画的中间状态都保存在动画本身中,插值器可以设计为无状态的,这样插值器在各个动画之间复用都不会出现bug,实际看下来也验证了这个结论
Compose动画插值器接口声明如下
java
interface AnimationSpec<T> {
/**
* Creates a [VectorizedAnimationSpec] with the given [TwoWayConverter].
*
* The underlying animation system operates on [AnimationVector]s. [T] will be converted to
* [AnimationVector] to animate. [VectorizedAnimationSpec] describes how the
* converted [AnimationVector] should be animated. E.g. The animation could simply
* interpolate between the start and end values (i.e.[TweenSpec]), or apply spring physics
* to produce the motion (i.e. [SpringSpec]), etc)
*
* @param converter converts the type [T] from and to [AnimationVector] type
*/
fun <V : AnimationVector> vectorize(
converter: TwoWayConverter<T, V>
): VectorizedAnimationSpec<V>
}
可以看到可以对任意类型做动画插值,前提是能够将这个类型转换成动画库内部使用的类型,也就是AnimationVector,AnimationVector本身也是一个接口,目前支持了最多四个维度的变化,其中每一个维度的数据限定为Float
所以实际进行动画的是VectorizedAnimationSpec,我们首先
VectorizedAnimationSpec家族类图
VectorizedFloatAnimationSpec
被spring动画和tween动画用来实现内部逻辑,首先我们来,
因此细化拆分,我们需要有一个对Float做动画的机制,能够根据初始值,初始速度等等获取目标浮点值,VectorizedFloatAnimationSpec
就是这个逻辑,它将相关进度值的获取委托给了FloatAnimationSpec
因此Tween的动画核心在FloatTweenSpec中getValueFromNanos
java
override fun getValueFromNanos(
playTimeNanos: Long,
initialValue: Float,
targetValue: Float,
initialVelocity: Float
): Float {
// TODO: Properly support Nanos in the impl
val playTimeMillis = playTimeNanos / MillisToNanos
val clampedPlayTime = clampPlayTime(playTimeMillis)
val rawFraction = if (duration == 0) 1f else clampedPlayTime / duration.toFloat()
val fraction = easing.transform(rawFraction.coerceIn(0f, 1f))
return lerp(initialValue, targetValue, fraction)
}
可以看到进度的确定是由easing来决定的,Easing有唯一一个实现CubicBezierEasing
java
class CubicBezierEasing(
private val a: Float,
private val b: Float,
private val c: Float,
private val d: Float
)
4个控制点,从我的理解来看,x坐标代表了时间,y坐标了代表了这个时候的真实进度,具体数学逻辑留待进一步分析
Transition
公开给外部的有启动动画的接口animateTo,有结束Transition的接口onTransitionEnd,有添加各种动画的接口,以及创建子Transition的接口,
这部分逻辑应该是最复杂的,所以留待最后一部分分析,Transition本质上是根据状态的变更去管理一群动画同时运行,compose给我们提供的一个接口使用Transition的是updateTransition,这个函数可以驱动Transition状态变更,进而驱动动画的进行
java
fun <T> updateTransition(
targetState: T,
label: String? = null
): Transition<T> {
val transition = remember { Transition(targetState, label = label) }
transition.animateTo(targetState)
DisposableEffect(transition) {
onDispose {
// Clean up on the way out, to ensure the observers are not stuck in an in-between
// state.
transition.onTransitionEnd()
}
}
return transition
}
Transition这里的各种各样的增加动画接口是在Animation的基础上扩展出来的,通过TransitionAnimationState
进行管理该动画,后续所有的这些动画由Transition进行驱动管理,至于子Transition,首先它本身也是Transition,其次在原始Transition的概念上嵌套就可以了,什么场景适合Transition呢,当我们需要根据一个状态的变化同时做多种动画,且动画可能本身比较复杂的时候,就可以使用Transition来管理我们的东西,如果动画本身比较简单,根据自己的场景去挑选其他动画接口就可了,如果从某种意义上来说,Transition相当于AnimationManager,但它本身只能控制动画同时播放(除非设置延迟),不能保证动画播放的先后顺序,不过在Compose的场景下也是完全够用了
通篇看下来,可以说Compose的动画系统和原生的动画系统完全不是一回事,再次感叹State系统设计得十分巧妙!
👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀