上一篇文章我们了解了单值动画 animate*AsState()
以及基于协程的单值动画 Animatable 的用法,并对两种动画都会用到的 AnimationSpec 做了系统的介绍,本篇我们会介绍衰减动画以及动画的流程控制。
1、衰减动画 animateDecay()
animateDecay() 本是 Animatable 中的挂起函数,从类的归属上来讲,应该在上一篇介绍 Animatable 时放在一起讲。但是受限于篇幅,且衰减动画与上一篇的主体结构并不搭配,因此才拿到这里讲。
1.1 适用场景
说到衰减动画,其含义无非是动画的速度越来越慢,直到停止。这种动画使用 animateTo(),在指定 TweenSpec 时选择一个最后会减速的缓动也可以实现:
anim.animateTo(96.dp, tween(easing = LinearOutSlowInEasing))
那为什么 Animatable 还要提供一个 animateDecay() 呢?因为它们的设计目标和底层机制有本质区别,这种区别源于两种动画的物理模型和目标驱动方式的不同:
animateTo()
:目标导向的动画- 核心逻辑:从当前值向固定目标值运动,通过插值或弹簧模型实现动画
- 关键参数:targetValue(必须指定最终目标值)+ AnimationSpec(如 tween()、spring())
- 适用场景:需要精确控制动画终点(如按钮展开到固定宽度、颜色渐变到指定色值)
- 详细解释:即便使用 LinearOutSlowInEasing,动画最终也只会停在 targetValue 指定的目标值上,无法动态改变目标值
animateDecay()
:速度衰减的物理动画- 核心逻辑:基于初始速度和摩擦力动态计算动画轨迹,直到速度衰减为 0 时自动停止
- 关键参数:initialVelocity(初始速度) + DecayAnimationSpec(如 splineBasedDecay)
- 适用场景:手势交互后的惯性动画(如滑动列表松手后的惯性滚动、拖拽卡片释放后的减速滑动)
- 详细解释:动画的终点由初始速度和衰减参数动态计算,速度随时间逐渐衰减为 0,动画自然停止
因此,animateDecay() 的使用场景就是惯性衰减,最常见的就是手指滑动列表后抬起,列表惯性滑动一段时间后自己停下来。
1.2 基本用法
animateDecay() 是一个挂起函数,需要在协程中使用:
/**
* 启动一个衰减动画(即从给定的[initialVelocity]初始速度开始,逐渐减速至0的动画,
* 起始点为当前[Animatable.value]。若已有动画正在运行,该动画将被立即取消。
* 衰减动画通常用于快速滑动(fling)手势后的惯性效果。
*
* [animationSpec] 定义用于此动画的衰减动画规格。可选的[animationSpec]包括:
* [splineBasedDecay][androidx.compose.animation.splineBasedDecay](基于样条的衰减)
* 和 [exponentialDecay](指数衰减)。[block]闭包将在每帧动画时被调用。
*
* 返回一个[AnimationResult]对象,其中包含动画结束的[原因][AnimationEndReason]及最终状态。
* 若动画顺利完成未被中断,结束原因为[Finished]。
* 若动画在任一维度达到[lowerBound](下限)或[upperBound](上限),结束原因为[BoundReached]。
*
* 若动画被以下操作中断:
* 1) 调用新动画(如[animateTo]/[animateDecay])
* 2) [Animatable.stop]
* 3) [Animatable.snapTo]
* 被取消的动画将抛出[CancellationException](因协程任务被取消),
* 这将导致调用方协程中后续操作被取消。此行为通常符合预期。
* 若需在动画取消时执行清理操作,建议在`try-catch`代码块中启动动画。
*
* __注意__:动画结束后,速度将被重置为0。若需在动画被中断或触达边界前保持动量延续,
* 建议使用返回的[AnimationResult.endState]中的速度值启动新动画。
*/
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)
}
animateDecay() 有三个参数:
- initialVelocity:初速度。通常需要借助 Compose 提供的 VelocityTracker 计算手指抬起时的速度作为初速度,速度的单位取决于创建 Animatable 对象时传入的目标值的单位。比如传入的是 xxx.dp,那么调用 animateDecay() 时给 initialVelocity 传的就应该是 xx.dp 表示初速度为每秒 xx dp
- animationSpec:类型是 DecayAnimationSpec,该接口的唯一实现类 DecayAnimationSpecImpl 是 private 的,因此无法通过构造函数直接创建对象,只能通过 exponentialDecay() 和 splineBasedDecay() 来获取 DecayAnimationSpec 的实例,它们需要被包含在 remember() 内使用
- block:animateTo() 中也有这个函数参数,会在动画执行的每一帧刷新时被调用,相当于是对动画的监听。
Compose 无法直接使用传统 View 体系下的 VelocityTracker,因此 Compose 又单独实现了一份。它实现了速度计算中最难的两件事:记录位置与时间、用位置差与时间间距做除法算速度,因此使用它还是能很方便地计算出速度值的。
splineBasedDecay()
splineBasedDecay() 中的 spline 译为“样条”,是一个数学概念。Android 自带的惯性滑动曲线的算法就用到了它,像 Android 原生的滑动组件,如 ListView、RecyclerView、ScrollView 的惯性滑动曲线都是它。这些组件的惯性滑动都是用辅助类 OverScroller 来进行滑动相关的计算,比如惯性滑动的实时位移的计算。但在 Compose 中不能使用 OverScroller,因为 Compose 是独立于平台的,需要完全隔离,因此至少在上层是不能直接使用 Android 原生的东西的。但 OverScroller 的算法被照搬到 splineBasedDecay() 中了,因此用该函数就可以实现与原生相同的惯性滑动了。
splineBasedDecay() 有一个参数 density 表示屏幕的像素密度:
fun <T> splineBasedDecay(density: Density): DecayAnimationSpec<T> =
SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()
可以直接使用简便方法 rememberSplineBasedDecay():
@Composable
actual fun <T> rememberSplineBasedDecay(): DecayAnimationSpec<T> {
// This function will internally update the calculation of fling decay when the density changes,
// but the reference to the returned spec will not change across calls.
val density = LocalDensity.current
return remember(density.density) {
SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()
}
}
rememberSplineBasedDecay() 内部会通过 CompositionLocal 获取 LocalDensity 并传入,使用它不仅可以省去传 Density 的麻烦,也省去了调用 splineBasedDecay() 时必须包含在 remember() 中的麻烦。
有一个问题需要特别注意,就是 splineBasedDecay() 和 rememberSplineBasedDecay() 虽然都是泛型函数,看似可以传入任意的类型,但实际上,它是面向像素的函数,不能用于计算 DP。因为针对不同像素密度的设备会有不同的减速曲线,它会有优化与修正,在像素密度越大的屏幕上减速就越快(相同初速度的两个屏幕应该惯性滑动相同的像素个数,但由于像素密度越大,同样的物理尺寸内的像素点就越多,所以在屏幕上滑动的物理距离就越短)。不论你传的泛型是 Float 还是 Int,它都会按照像素进行计算,并根据当前屏幕的像素密度计算出一条最合适的减速曲线,计算时会进行修正工作,将面向像素转换为面向 DP。正是因为有这个修正,泛型就不能传 DP,因为你传了 DP,它也是认为你传的是像素,也会进行修正,相当于多修正了一次,从而得到错误的结果。因此,这里的泛型只能传像素单位,不能传 DP。
类似地,在设计角度衰减动画时也不能使用上述两个函数,因为角度是与像素密度无关的,在任何屏幕上,角度的衰减速度都应该是一致的。如果将它们用于角度衰减的动画,会出现屏幕密度高的设备动画较快停止的问题。
综上,splineBasedDecay() 和 rememberSplineBasedDecay() 的适用场景很单一,只能用于面向像素的移动或放缩的动画,否则就会出现在不同像素密度的设备上表现不一致的问题。正是因为只能面向像素的限制,因此实际开发中用的很少。
exponentialDecay()
使用另一个函数 exponentialDecay() 可以解决 spline 的短板。exponential 是指数的意思,exponentialDecay() 就是指数衰减函数,它不会像 spline 的两个函数那样针对像素做出修正,因此如果使用它做面向像素的动画,就会出问题。但是反而它可以用于像素以外的其他单位,如 Dp,没有偏差。角度也可以用这个,颜色衰减也可以用这个。
exponentialDecay() 的适用范围更广,且可以通过参数进行调节:
fun <T> exponentialDecay(
frictionMultiplier: Float = 1f,
absVelocityThreshold: Float = 0.1f
): DecayAnimationSpec<T> =
FloatExponentialDecaySpec(frictionMultiplier, absVelocityThreshold).generateDecayAnimationSpec()
frictionMultiplier 是摩擦力系数,值越大衰减越快;absVelocityThreshold 是速度阈值的绝对值,要结合指数衰减的图像来理解这个参数:
横轴是时间,纵轴是速度,速度随着时间增长无限趋近为 0 但不会为 0,如果按照这个曲线速度一直不为 0 那么动画就不会停止,因此我们需要设置一个值,当速度衰减到这个值时就让动画停止,这个值就是速度阈值的绝对值 absVelocityThreshold。
与 splineBasedDecay() 有 remember 版本的简便函数 rememberSplineBasedDecay() 相比,exponentialDecay() 没有一个对应的 remember 版本的函数,这是因为 splineBasedDecay() 使用屏幕像素密度 Density,你需要保证在重组时,如果像素密度没变,不重复创建 DecayAnimationSpec 对象,但是在变化时,要创建该对象,所以使用使用 remember() 将 Density 作为 key 传入了:
@Composable
actual fun <T> rememberSplineBasedDecay(): DecayAnimationSpec<T> {
// This function will internally update the calculation of fling decay when the density changes,
// but the reference to the returned spec will not change across calls.
val density = LocalDensity.current
// 在像素密度发生变化时才重新创建 DecayAnimationSpec 对象
return remember(density.density) {
SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()
}
}
而 exponentialDecay() 并不需要像素密度,因此也就没有对应的 remember 版本了。
block 实现动画监听
block 会在动画执行的每一帧刷新时被调用,相当于是对动画的监听。举个简单例子,让红色的方块跟随绿色方块一起滑动相同的距离:
@Composable
fun BlockSample() {
val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
var paddingTopRed by remember { mutableStateOf(animatable.value) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
// 延迟一下,运行程序后能看到完整动画
delay(2000)
// 在 block 中将绿色方块执行动画的值同步给红色方块的顶部 padding
animatable.animateDecay(1000.dp, decay) { // this: Animatable
paddingTopRed = value
}
}
Row {
Box(
Modifier
.padding(0.dp, animatable.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
Box(
Modifier
.padding(0.dp, paddingTopRed, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Red)
)
}
}
效果如下:
2、动画的停止
一个动画没有自然完成就被停止,通常有三种情况:
- 主动启动新动画:新动画的执行会导致正在执行的动画被立即取消
- 显式调用停止函数:主动结束一个动画的执行
- 动画到达边界限制:动画值到达边界,触发边界条件后结束,属于正常结束,前两种属于打断
2.1 主动启动新动画
当调用 animateTo
、animateDecay
或 snapTo
时,正在运行的动画会被立即取消,比如:
@Composable
fun BreakSample() {
val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
try {
animatable.animateDecay(2000.dp, decay)
} catch (e: CancellationException) {
// 协程取消时会抛出 CancellationException,为了演示效果才捕获这个异常,
// 正常开发中一定不要捕获它,否则会影响协程的结构化取消
println("糟糕!被打断了")
}
}
// 第二个协程比第一个协程多延时一些时间以打断第一个动画的执行
LaunchedEffect(Unit) {
delay(1500)
animatable.animateDecay((-1000).dp, decay)
}
Box(
modifier = Modifier
.padding(0.dp, animatable.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
效果如下:
Box 在向下滑动过程中会向上移动,并且打印"糟糕!被打断了"。说明第一个协程由于确实被取消而抛出了 CancellationException,协程取消就会导致协程内部执行的动画取消。
2.2 显式调用停止函数
业务开发难免会有主动停止动画执行的场景,调用 Animatable 的 stop() 即可停止动画,该函数是一个挂起函数,也应该在协程环境中调用。使用时需要注意它不应该紧接着一个执行动画的函数后面调用,例如:
LaunchedEffect(Unit) {
// 挂起函数,执行完毕才轮到 stop() 执行
anim.animateDecay((-1000).dp, decay)
anim.stop()
}
因为执行动画的函数也是挂起函数,在该挂起函数运行完毕之前,stop() 得不到执行。也就是说,这样调用 stop() 无法中断动画,失去了调用 stop() 的意义。因此通常情况下 stop() 应该是在另一个协程中被调用。
此外,还需注意,stop() 一般是与业务逻辑相关的,而不是与 Compose 界面相关的。比如点击了界面的某个按钮,触发了相应的业务流程后结束动画,这时使用传统的启动协程的方式就可以了,比如 lifecycleScope.launch(),在这里面调用 stop(),而不是在 LaunchedEffect() 中。
调用 stop() 也会引发正在执行中的动画所在的协程抛出 CancellationException,示例代码:
@Composable
fun StopSample() {
val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
try {
animatable.animateDecay(2000.dp, decay)
} catch (e: CancellationException) {
println("糟糕!被打断了")
}
}
LaunchedEffect(Unit) {
delay(1200)
// 停止动画
animatable.stop()
}
Box(
modifier = Modifier
.padding(0.dp, animatable.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
2.3 动画到达边界限制
动画正常运行完毕以及动画运行到边界条件后停止被 Compose 认为是正常的动画停止,这个信息可以通过 animateTo() 和 animateDecay() 的返回值 AnimationResult 中查看到:
class AnimationResult<T, V : AnimationVector>(
val endState: AnimationState<T, V>,
val endReason: AnimationEndReason
)
enum class AnimationEndReason {
/**
* 动画值到达上限或下限时会被强制结束,这种状态下结束通常会比初始目标值短一些,
* 并且剩余速度通常不为零。可以通过 [AnimationResult] 获取结束值和剩余速度。
*/
BoundReached,
/**
* 动画已经成功完成,没有任何中断。
*/
Finished
}
举一个简单示例,让 Box 水平向右滑动到边界后停止的动画:
@Composable
fun BoundsSample() {
// 用以获取屏幕的最大宽度进而设置动画的上限值
BoxWithConstraints {
val animatable = remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
animatable.animateDecay(2000.dp, decay)
}
// 更新动画的上限值,由于 animatable 动画值是作为 Box 的左边界,
// 因此上限就是屏幕宽度减去 Box 的宽度
animatable.updateBounds(upperBound = maxWidth - 100.dp)
Box(
Modifier
.padding(animatable.value, 0.dp, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
效果如下:
以上是一维动画,Compose 支持最多至四维的多维动画。在多维空间中,只要有一个维度到达边界,Compose 就会停止动画。但假如需求是多维动画的所有维度均到达边界后才停止动画,该如何实现呢?
一种比较直观的想法是在一个维度到达边界后,通过 AnimationResult 的 endReason 先判断是因为到达边界引发的动画停止,然后获取 endState 从中获取尚未到达边界的维度数据,然后用这个数据在该维度重新开启一段动画,直到其到达边界。以二维 Offset 为例的代码如下:
@Composable
fun BoundsSample1() {
BoxWithConstraints {
// 第一个动画是 Offset 动画,XY 轴同时滑动
val animatable = remember { Animatable(DpOffset.Zero, DpOffset.VectorConverter) }
val decay = remember { exponentialDecay<DpOffset>() }
// 第二个动画是 Y 轴动画
val animatableY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
// Offset 动画是否已经结束并开始 Y 轴动画了
var startY by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(1000)
val result = animatable.animateDecay(DpOffset(2000.dp, 3000.dp), decay)
if (result.endReason == AnimationEndReason.BoundReached) {
// 开始 Y 轴动画,用 Offset 动画结束时 Y 轴速度作为 Y 轴衰减动画的初速度
startY = true
val initVelocityY = result.endState.velocity.y
animatableY.animateDecay(initVelocityY, decayY)
}
}
// 更新动画的边界,上边界应该是最大宽度减去 Box 的宽度,即是 Box 左边的最大位移值
animatable.updateBounds(upperBound = DpOffset(maxWidth - 100.dp, maxHeight - 100.dp))
// Y 轴动画边界最大值应该是屏幕高度减去 Box 高度再减去 Offset 动画在 Y 轴上的位移
animatableY.updateBounds(upperBound = maxHeight - 100.dp - animatable.value.y)
Box(
Modifier
.padding(
animatable.value.x,
if (!startY) animatable.value.y else (animatable.value.y + animatableY.value),
0.dp,
0.dp
)
.size(100.dp)
.background(Color.Green)
)
}
}
经过上述一通操作实现了如下效果:
上述操作实际上是有些繁琐的,可以借鉴原生的 OverScroller 将多个维度的动画分开实现:
public class OverScroller {
private final SplineOverScroller mScrollerX;
@UnsupportedAppUsage
private final SplineOverScroller mScrollerY;
public void fling(int startX, int startY, int velocityX, int velocityY,
int minX, int maxX, int minY, int maxY, int overX, int overY) {
// Continue a scroll or fling in progress
if (mFlywheel && !isFinished()) {
float oldVelocityX = mScrollerX.mCurrVelocity;
float oldVelocityY = mScrollerY.mCurrVelocity;
if (Math.signum(velocityX) == Math.signum(oldVelocityX) &&
Math.signum(velocityY) == Math.signum(oldVelocityY)) {
velocityX += oldVelocityX;
velocityY += oldVelocityY;
}
}
mMode = FLING_MODE;
// 两个方向上分别惯性滑动
mScrollerX.fling(startX, velocityX, minX, maxX, overX);
mScrollerY.fling(startY, velocityY, minY, maxY, overY);
}
}
因此上面的例子也可以采用这种方式,代码会方便很多:
@Composable
fun BoundsSample2() {
BoxWithConstraints {
// X 轴动画
val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayX = remember { exponentialDecay<Dp>() }
// Y 轴动画
val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
animX.animateDecay(2000.dp, decayX)
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(3000.dp, decayY)
}
animX.updateBounds(upperBound = maxWidth - 100.dp)
animY.updateBounds(upperBound = maxHeight - 100.dp)
Box(
Modifier
.padding(animX.value, animY.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
下面再看一个复杂一点的需求,让 Box 碰到边缘后反弹:
示例代码如下:
@Composable
fun BoundsSample3() {
BoxWithConstraints {
// X 轴动画
val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayX = remember { exponentialDecay<Dp>() }
// Y 轴动画
val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
var result = animX.animateDecay(4000.dp, decayX)
// 每次碰到边界都反速运行动画
while (result.endReason == AnimationEndReason.BoundReached) {
result = animX.animateDecay(-result.endState.velocity, decayX)
}
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(2000.dp, decayY)
}
// 需要更新一下下界,因为动画 value 要作为 padding 不能是负值
animX.updateBounds(upperBound = maxWidth - 100.dp, lowerBound = 0.dp)
animY.updateBounds(upperBound = maxHeight - 100.dp)
Box(
Modifier
.padding(animX.value, animY.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
这样做能实现一个大致准确的动画,之所以说大致准确,是因为速度 AnimationResult.endState.velocity 的精度不够高。Compose 在动画的每一帧中去采集图形的位置,再通过计算时间段进行相除来计算出实时速度。这个计算出来的速度显然与 Box 解除到边界的一瞬间的速度是有毫厘之差的。
我们可以自己进行数学计算,得到一个没有误差的精确值,这里仅以 X 轴方向为例,主要的改进在于 paddingX 的计算上:
@Composable
fun BoundsSample4() {
BoxWithConstraints {
// X 轴动画
val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayX = remember { exponentialDecay<Dp>() }
// Y 轴动画
val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
var result = animX.animateDecay(4000.dp, decayX)
while (result.endReason == AnimationEndReason.BoundReached) {
result = animX.animateDecay(-result.endState.velocity, decayX)
}
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(2000.dp, decayY)
}
animY.updateBounds(upperBound = maxHeight - 100.dp)
// 自己计算 X 轴的距离
val paddingX = remember(animX.value) {
var usedValue = animX.value
// 将从左到右,再从右到左这一个来回视为一次循环,碰撞后的位移,就是总的动画值
// 这个位移对一次循环求模的结果
while (usedValue >= (maxWidth - 100.dp) * 2) {
usedValue -= (maxWidth - 100.dp) * 2
}
if (usedValue < maxWidth - 100.dp) {
// 从左到右移动过程中,paddingX 应该就是求模后的 usedValue
usedValue
} else {
// 从右向左移动过程中,paddingX 应该是一个来回的距离减去已经移动过的距离
(maxWidth - 100.dp) * 2 - usedValue
}
}
Box(
Modifier
// 使用 paddingX 作为 start 值
.padding(paddingX, animY.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
实测发现精确值与前面通过 AnimationResult 提供的速度值计算出的结果确实存在一定偏差。
2.4 其他情况
除了上面说的三种主要情况,还有一些其他情况也会停止动画,比如协程作用域被取消,常见于:
- 组件退出组合(
DisposableEffect
清理) LaunchedEffect
的key
参数变化触发重组- 父协程被取消
此外,重组导致副作用失效也可能导致动画中断。比如,组件重组时未能正确管理状态,导致动画重启。