《Jetpack Compose系列学习》-21 Compose中的其它动画

1,335 阅读9分钟

属性动画

Compose是声明式的,不仅仅是布局,动画也是声明式的,同样是状态驱动UI来刷新。

animateDpAsState函数是Compose中最简单的动画API,可将即时值变化呈现为动画值。它由Animatable提供支持,Animatable是一种基于协程的动画API,用于为单个值添加动画效果。updateTransition可创建过渡对象,用于管理多个动画值,并且根据状态变化运行这些值。rememberInfiniteTransition与其相似,不过,它会创建一个无限过渡对象,以管理多个无限期运行的动画。所有这些API都是可组合项(Animatable除外),这意味着这些动画可以在非组合期间创建。

下面看看animateDpAsState函数的用法。顾名思义,该函数时用来处理Dp值修改动画的,它的定义如下:

@Composable 
public fun animateDpAsState(
    targetValue: Dp, 
    animationSpec: AnimationSpec<Dp> = dpDefaultString,
    finishedListener: ((Dp) -> Unit)? = null
): State<Dp>

可以看到animateDpAsState函数一共有三个参数:第一个参数为targetValue,类型Dp,就是动画的目标值;第二个参数为animationSpec,用来进行动画相关的配置;最后一个参数为finishedListener,动画结束监听,并且将最后的Dp值回调。实际怎么使用呢?写一个例子:

@Composable
fun OtherAnimation() {
    var isSmall by remember { mutableStateOf(true)}
    val size: Dp by animateDpAsState(targetValue = if (isSmall) 40.dp else 100.dp) {
        Log.e("LM" , "AnimateAsState: $it")
    }
    Column(Modifier.padding(16.dp)) {
        Button(onClick = {
            isSmall = !isSmall
        }, modifier = Modifier.padding(vertical = 16.dp)) {
            Text("Change Size Dp")
        }
        Box(Modifier.size(size).background(Color.Red))
    }
}

首先使用了remember记一个状态State,表示Box的size状态;然后通过animateDpAsState构建一个可订阅的State;接着构建一个Column,在Column中创建一个Button,点击事件中修改State的值,触发可组合项重组并执行相关动画;最后写一个Box,size设置为上面定义的size大小。

4.gif

如上,点击按钮的时候,Box的size会伴随着动画增大为100dp,再次点击会伴随着动画缩小到40dp。

需要注意的是,我们无须创建任何动画的实例,也不必处理中断。系统会在后台调用点创建并记录一个动画对象(即Animatable实例),并将第一个目标值设为初始值。此后,只要我们为此可组合项提供不同的目标值,系统就会自动开始向该值播放动画,这个可组合项会重组,并返回已更新的每帧动画值。

Compose中不只为Dp提供了animate * AsState函数,还为Float、Color、Dp、Size、Bounds、Offset、Rect、Int、IntOffset和IntSize提供了animate * AsState函数。实际使用过程中可以根据需求选择不同的animate * AsState函数。

帧动画

帧动画是一种常见的动画形式,也就是在时间轴上逐帧绘制不同的内容,使其连续播放形式动画。帧动画非常灵活,几乎可以表现任何你想要表现的内容。Compose中帧动画使用Animatable来实现。Animatable是一个值容器,它可以在通过animateTo更改值时为值添加动画效果。该API支持animate * AsState的实现,它可确保连续性和互斥性,意味着值变化始终是连续的,并且会取消任何正在播放的动画。来看看如何使用Animatable:

@Composable
fun OtherAnimation() {
    var count by remember {
        mutableStateOf(0)
    }
    Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
        Button(onClick = { count++ }) {
            Text("Count $count")
        }
        LineAnimation(count)
    }
}

@Composable
fun LineAnimation(lives: Int) {
    val animVal = remember { Animatable(0f) }
    if (lives > 5) {
        LaunchedEffect(animVal) {
            animVal.animateTo(
                targetValue = 1f,
                animationSpec = tween(durationMillis = 1000, easing = LinearEasing)
            )
        }
    }
    Canvas(modifier = Modifier.size(200.dp, 200.dp)) {
        drawLine(
            color = Color.Black,
            start = Offset(0f, 0f),
            end = Offset(animVal.value * size.width, animVal.value * size.height),
            strokeWidth = 2f
        )
    }
}

5.gif

我们可以看到当按钮点击次数大于5时,去设置指定动画,我们只需要用LaunchedEffect进行包装。这里它就相当于启动一个协程。

使用Animatable需要注意的是,Animatable只为Float和Color提供了开箱即用的支持,不过不用担心,Compose为我们提供了TwoWayConverter,因此可以使用任何数据类型,后续会介绍它的使用。

多动画同步

开发的时候我们会同时组合多个动画,并保持同步,以实现特定的动画效果,在Compose中需要使用updateTransition可组合项来实现。Transition可以管理一个或多个动画并将其作为其子项。并在多个状态之前同时运行这些动画。这里的状态可以是任何数据类型,但开发过程中一般会自定义一个密封类来确保类型安全。看个例子吧:

private sealed class BoxState(
    val color: Color, 
    val size: Dp, 
    val offset: Dp, 
    val angle: Float) {
    
    operator fun not() = if (this is Small) Large else Small

    object Small : BoxState(Blue, 60.dp, 20.dp, 0f)
    object Large : BoxState(Red, 90.dp, 50.dp, 45f)
}

BoxState是一个密封类,定义了四个参数:颜色、大小、平移和角度,然后创建两个子类,传入了不同的参数以区分,最后对“!”操作符进行了重载,方便使用。下面看看如何使用Transition:

@Composable
fun TransitionTest() {
    var boxState: BoxState by remember { mutableStateOf(BoxState.Small) }
    val transition = updateTransition(targetState = boxState, label = "transition")
    val color by transition.animateColor(label = "color") {
        boxState.color
    }
    val size by transition.animateDp(label = "size") {
        boxState.size
    }
    val offset by transition.animateDp(label = "offset") {
        boxState.offset
    }
    val angle by transition.animateFloat(label = "angle") {
        boxState.angle
    }
    Column(Modifier.padding(16.dp).size(360.dp)) {
        Button(
            onClick = { boxState = !boxState }
        ) {
            Text("Transition Test")
        }
        Box(
            Modifier.padding(top = 20.dp)
                .rotate(angle)
                .size(size)
                .offset(x = offset)
                .background(color)
        )
    }
}

首先remember记录一个BoxState的值,默认为BoxState.Small,然后通过updateTransition创建一个Transition对象,通过Transition的扩展方法获取BoxState中的对应值,点击按钮的时候会使用上面的重载“!”操作符来修改状态,然后将对应值设置到Box中。看看效果:

6.gif

从上面代码我们可知,transition的扩展函数很多:

val color by transition.animateColor(label = "color") {
    boxState.color
}
val size by transition.animateDp(label = "size") {
    boxState.size
}
val offset by transition.animateDp(label = "offset") {
    boxState.offset
}
val angle by transition.animateFloat(label = "angle") {
    boxState.angle
}

根据实际开发需求选择指定的扩展函数。

多动画重复

重复动画也很常见,比如应用程序的加载动画,一直重复执行,直到数据加载完成。在Compose中重复动画使用InfiniteTrasition来构建,InfiniteTransition可以像Transition一样保存一个或多个子动画,但是,这些动画一进入组合阶段就开始运行,除非被移除,否则不会停止。看个案例:

@Composable
fun InfiniteTransitionTest() {
    val infiniteTransition = rememberInfiniteTransition()
    val color by infiniteTransition.animateColor(
        initialValue = Color.Red,
        targetValue = Color.Green,
        animationSpec = infiniteRepeatable(
            animation = tween(1000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        )
    )

    Box(Modifier.size(360.dp).background(color))
}

我们使用rememberInfiniteTransition,通过animateColor添加子动画,然后设置infiniteRepeatable指定动画规范,看下效果:

7.gif

可以看到不间断地在红色和绿色之间切换,这些动画一进入组合阶段就开始运行,除非被移除,否则不会停止。

自定义动画

很多动画API通常能接收用于自定义其行为的参数,比如上面定义的重复动画中的tween(1000, easing = LinearEasing),其实就是通过可选参数AnimationSpec创建的动画,下面看看如何定义不同规格的动画

  • 动画规则————AnimationSpec 前面我们经常遇到这个参数,它用来存储动画的规格,包括要进行动画处理的数据类型,将数据转换为动画后将使用的动画配置。AnimationSpec是一个接口,Compose为我们实现了常用的一些动画,来看下:
  1. 基于物理特性的动画————spring spring可在起始值和结束值之前创建基于物理特性的动画,源码如下:
@Stable
fun <T> spring(
    dampingRatio: Float = Spring.DampingRatioNoBouncy,
    stiffness: Float = Spring.StiffnessMedium,
    visibilityThreshold: T? = null
): SpringSpec<T> =
    SpringSpec(dampingRatio, stiffness, visibilityThreshold)

dampingRatio定义动画的弹性,默认值为Spring.DampingRatioNoBouncy;stiffness定义弹性应向结束值移动的速度,默认值为Spring.StiffnessMedium。看个案例:

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRationHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

相对于AnimationSpec类型,spring可以更流畅地处理中断,因为它可以在目标值在动画中变化时保证速度的连续性。spring在很多动画API中是动画规格的默认值,比如animate * AsState和updateTransition。

  1. 渐变动画————tween tween用来创建使用给定的持续时间、延迟以及缓和曲线配置的tween规范。看下tween源码:
@Stable
fun <T> tween(
    durationMillis: Int = DefaultDurationMillis,
    delayMillis: Int = 0,
    easing: Easing = FastOutSlowInEasing
): TweenSpec<T> = TweenSpec(durationMillis, delayMillis, easing)

可以看到tween有三个参数,durationMillis表示在指定时间内使用缓和曲线和曲线在起始值和结束值之间添加动画效果;delayMillis表示动画延迟时间;easing用于在开始和结束之间进行插值的缓动曲线,类型为Easing。看下Easing的源码:

@Stable
fun interface Easing {
    fun transform(fraction: Float): Float
}

/**
 * 以静止开始和结束的元素都使用此标准缓动。它们会快速加速并逐渐减速,以强调过渡的结束
 */
val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)

/**
 * 使用减速缓动为传入的元素设置动画,该缓动以峰值速度开始过渡,并在静止时结束
 */
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)

/**
 * 离开屏幕的元素使用加速缓动,从静止开始并以峰值速度结束
 */
val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)

/**
 * 它返回未修改的分数,经常作为默认值
 */
val LinearEasing: Easing = Easing { fraction -> fraction }

// 三阶贝塞尔曲线
@Immutable
class CubicBezierEasing(
    private val a: Float,
    private val b: Float,
    private val c: Float,
    private val d: Float
) : Easing {
    // 省略...
}

可以看到Easing是一个接口,Compose为我们提供了5种常用的方法,分别是上面五种,有注释,根据实际需求去选择使用。我们同样,看一个tween的使用案例:

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)
  1. 帧动画————keyframes keyframes会根据在动画时长内不同时间戳中指定的快照值添加动画效果。在任意给定时间,动画值都将插入到两个关键帧之间。对于每个关键帧,都可以指定Easing来确定插值曲线。看下keyframes方法定义:
@Stable
fun <T> keyframes(
    init: KeyframesSpec.KeyframesSpecConfig<T>.() -> Unit
): KeyframesSpec<T> {
    return KeyframesSpec(KeyframesSpec.KeyframesSpecConfig<T>().apply(init))
}

keyframes只有一个init参数,用于动画的初始化。同样看个例子:

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

可以选择在0毫秒和持续时间处指定值。如果不指定,它们将分别默认为动画的起始值和结束值。

  1. 重复有限动画 repeatable反复运行基于时长的动画,直至达到指定迭代计数。看下它的定义:
@Stable
fun <T> repeatable(
    iterations: Int,
    animation: DurationBasedAnimationSpec<T>,
    repeatMode: RepeatMode = RepeatMode.Restart
): RepeatableSpec<T> =
    RepeatableSpec(iterations, animation, repeatMode)

可以看到repeatable有3个参数。iterations是动画需要重复指定的次数;animation表示目标动画;repeatMode指定动画是从头开始还是从结尾开始重复播放。看个案例:

val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = repeatable(
            iterations = 2,
            animation = tween(durationMillis = 300),
            repeatMode = RepeatMode.Reverse
        )
    )
  1. 重复无限动画 repeatable重复动画需要设置动画重复次数,当需要定于无限重复动画,就需要使用infiniteRepeatable。infiniteRepeatable和repeatable类似。但前者会重复无限次迭代。看看infiniteRepeatable的定义:
@Stable
fun <T> infiniteRepeatable(
    animation: DurationBasedAnimationSpec<T>,
    repeatMode: RepeatMode = RepeatMode.Restart
): InfiniteRepeatableSpec<T> =
    InfiniteRepeatableSpec(animation, repeatMode)

可以看到infiniteRepeatable比repeatable少一个iterations参数,剩下参数一样,使用方法也一样:

val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 300),
            repeatMode = RepeatMode.Reverse
        )
    )
  1. 提前结束动画 很多情况我们需要提前结束动画,这时我们需要用snap方法。snap是特殊的AnimationSpec,它会立即将值切换到结束值。看下它的定义:
@Stable
fun <T> snap(delayMillis: Int = 0) = SnapSpec<T>(delayMillis)

是有一个参数delayMillis,用来指定动画的延迟执行时间。看使用方法:

 val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = snap(delayMillis = 50)
    )
  • 矢量动画————AnimationVector 前面说过,大多数Compose动画API支持将Float、Color、Dp以及其它基本类型数据作为开箱即用的动画值,但有时我们需要为其它数据类型(如自定义类型)添加动画效果。在动画播放期间,任何动画值都表示为AnimationVector。使用相应的TwoWayConverter即可将值转换为AnimationVector,反之亦然,这样,核心动画系统就可以统一对其进行处理了。例如:Int表示为包含单个浮点值的AnimationVector1D。用于Init的TwoWayConverter如下所示:
private val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color实际上是red、green、blue和alpha这4个值的集合,因此Color可转换为包含4个浮点值的AnimationVector4D。通过这种方式,动画中使用的每种数据类型都可以根据其维度转换为AnimationVector1D、AnimationVector2D、AnimationVector3D和AnimationVector4D。这样可为对象的不同组件独立添加动画效果,每个组件都有自己的速度轨迹。

如需支持将新的数据类型作为动画值,我们可以创建自己的TwoWayConverter并将其提供给API。看个例子:

data class TestSize(val width: Dp, val height: Dp)

@Composable
fun TestAnimation(targetSize: TestSize) {
    val animSize: TestSize by animateValueAsState<TestSize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: TestSize ->
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                TestSize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

首先定义一个数据类TestSize,两个参数分别为宽、高。由于TestSize有两个参数,所以可以转换为包含两个浮点值的AnimationVector2D,这样就为TestSize独立添加了动画效果。

好了,就学习到这,相关代码已上传:github.com/Licarey/com… 欢迎star