Android Compose 动画使用详解(五)动画配置之SnapSpec、KeyframesSpec

2,506 阅读10分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

上一篇介绍了 TweenSpec补间动画规格的配置和使用,本篇继续介绍关于动画规格的 SnapSpecKeyframesSpec 的配置和使用。

SnapSpec

SnapSpec为快闪动画规格,何为快闪呢?即动画从当前状态瞬间变化为目标状态,简单的说其实就是没有动画效果。

既然是没有动画效果那为什么还要使用动画再配置SnapSpec呢?直接修改组件对应的值不就行了么?确实是这样,如果只是单一的使用 SnapSpec配置动画跟直接修改组件对应值达到的效果是一样的,但是有时候在动画有多个状态时可能需要某一个状态的变化是没有动画效果,此时就可以使用 SnapSpec来实现。

使用 SnapSpec之前先看一下其定义:

class SnapSpec<T>(val delay: Int = 0) : DurationBasedAnimationSpec<T>

SnapSpec构造函数只有一个 delay参数,即动画的延迟时间,默认为 0。

使用:

val startPadding by animateDpAsState(targetValue, animationSpec = SnapSpec(1000))

同样的 Compose 也为 SnapSpec 提供了一个简便函数 snap简化其使用,如下:

val startPadding by animateDpAsState(targetValue, animationSpec = snap(1000))

效果:

那在什么情况下会使用到 SnapSpec呢?举个简单的例子,还是以上面的动画为例,假如需求是点击方块时方块以动画效果向右移动到目标位置,再点击方块时方块直接回到起点,此时就可以借助SnapSpec来实现,代码如下:

var moveToRight by remember { mutableStateOf(false) }
val targetValue = if(moveToRight) 100.dp else 10.dp
val spec = if(moveToRight) tween<Dp>(1000) else snap<Dp>()
val startPadding by animateDpAsState(targetValue, animationSpec = spec)

运行效果:

KeyframesSpec

KeyframesSpec是关键帧动画,即可以对动画的关键帧进行配置。那到底能对动画的关键帧进行哪些配置呢?还是先来看一下KeyframesSpec的定义:

class KeyframesSpec<T>(val config: KeyframesSpecConfig<T>) : DurationBasedAnimationSpec<T> 

构造方法只有一个参数 config类型为 KeyframesSpecConfig,看看 KeyframesSpecConfig的源码:

class KeyframesSpecConfig<T> {
    /**
    * Duration of the animation in milliseconds. The minimum is `0` and defaults to
    * [DefaultDurationMillis]
    */
    /*@IntRange(from = 0)*/
    var durationMillis: Int = 
    
    /**
    * The amount of time that the animation should be delayed. The minimum is `0` and defaults
    * to 0.
    */
    /*@IntRange(from = 0)*/
    var delayMillis: Int = 0
    
    internal val keyframes = mutableMapOf<Int, KeyframeEntity<T>>()
    
    /**
    * Adds a keyframe so that animation value will be [this] at time: [timeStamp]. For example:
    *     0.8f at 150 // ms
    *
    * @param timeStamp The time in the during when animation should reach value: [this], with
    * a minimum value of `0`.
    * @return an [KeyframeEntity] so a custom [Easing] can be added by [with] method.
    */
    // TODO: Need a IntRange equivalent annotation
    infix fun T.at(/*@IntRange(from = 0)*/ timeStamp: Int): KeyframeEntity<T> {
        return KeyframeEntity(this).also {
            keyframes[timeStamp] = it
        }
    }
    
    /**
    * Adds an [Easing] for the interval started with the just provided timestamp. For example:
    *     0f at 50 with LinearEasing
    *
    * @sample androidx.compose.animation.core.samples.KeyframesBuilderWithEasing
    * @param easing [Easing] to be used for the next interval.
    */
    infix fun KeyframeEntity<T>.with(easing: Easing) {
        this.easing = easing
    }
    
    override fun equals(other: Any?): Boolean {
        return other is KeyframesSpecConfig<*> && delayMillis == other.delayMillis &&
        durationMillis == other.durationMillis && keyframes == other.keyframes
    }
    
    override fun hashCode(): Int {
        return (durationMillis * 31 + delayMillis) * 31 + keyframes.hashCode()
    }
}

源码中定义了三个属性:

  • durationMillis:动画时长
  • delayMillis:动画延迟
  • keyframes:关键帧集合

动画时长、延时

durationMillis delayMillis 这两个属性我们通过上一篇文章已经很熟悉了,跟 TweenSpec的前两个参数一样,在 KeyframesSpec 中使用方式如下:

// 创建 KeyframesSpecConfig
val config = KeyframesSpec.KeyframesSpecConfig<Dp>()
// 设置动画时长
config.durationMillis = 1000
// 设置动画延时
config.delayMillis = 1000
// 创建 KeyframesSpec
val spec = KeyframesSpec<Dp>(config)
// 使用 KeyframesSpec
val startPadding by animateDpAsState(targetValue, animationSpec = spec)

上面的代码看着很繁琐,可以使用 kotlin 的特性简化上面的代码:

val spec = KeyframesSpec<Dp>(KeyframesSpec.KeyframesSpecConfig<Dp>().apply { 
    durationMillis = 1000
    delayMillis = 1000
})
val startPadding by animateDpAsState(targetValue, animationSpec = spec)

代码简洁多了,但是还不够简洁,前面介绍 TweenSpecSnapSpec时发现 Compose 都为其提供了简便的方法 tweensnap供开发者使用,同样的 Compose 也为 KeyframesSpec提供了keyframes方法方便大家使用,实际上 keyframes方法就是对上面代码的封装,源码如下:

fun <T> keyframes(
    init: KeyframesSpec.KeyframesSpecConfig<T>.() -> Unit
): KeyframesSpec<T> {
    return KeyframesSpec(KeyframesSpec.KeyframesSpecConfig<T>().apply(init))
}

使用方式如下:

val spec = keyframes<Dp> {
    durationMillis = 1000
    delayMillis = 1000
}

这样使用起来就更加简洁方便了,运行一下看看效果:

关键帧值

如果只是配置这两个属性,其实跟使用 TweenSpec的效果没什么区别,而 KeyframesSpec 的关键就在于 keyframes ,那 keyframes 怎么用呢?通过源码发现keyframes是一个 Map 类型 mutableMapOf<Int, KeyframeEntity<T>>key 是 Int 类型,表示当前关键帧在动画中的哪个时间点,value 为 KeyframeEntity为关键帧的动画配置, KeyframeEntity 定义如下:

class KeyframeEntity<T> internal constructor(
    internal val value: T,
    internal var easing: Easing = LinearEasing
)

其中 value 为动画在当前帧的值,而 easing 则是动画速率曲线,相信看了前一篇文章的同学一定不会陌生。

那么 keyframes 怎么用呢?直接向 Map 里添加对应的 key-value 么?好像不行,源码中 keyframes属性被定义为internal,即在 module 外无法调用,那该怎么添加这个关键帧呢?回到上面 KeyframesSpecConfig的源码,除了三个属性以外还有一个 at方法:

    infix fun T.at(/*@IntRange(from = 0)*/ timeStamp: Int): KeyframeEntity<T> {
        return KeyframeEntity(this).also {
            keyframes[timeStamp] = it
        }
    }

该方法是一个扩展方法,其中 T就是动画作用的数值类型,参数 timeStamp为动画帧的时间,然后创建一个 KeyframeEntity实例,value 为动画值,并将创建的 KeyframeEntity添加到 keyframes的 Map 中, key 为传入的动画时间,所以可以通过这个 at方法来实现关键帧的配置,使用如下:

val targetValue = if(moveToRight) 100.dp else 10.dp
val spec = keyframes<Dp> {
    durationMillis = 1000
    delayMillis = 1000
    // 关键帧设置
    50.dp.at(300)
})
val startPadding by animateDpAsState(targetValue, animationSpec = spec)

上面的代码设置了当动画时间为 300ms 的关键帧时动画的数值为 50.dp,看一下运行效果:

效果确实跟我们设置的一样,符合预期。

细心的同学会发现其实 at方法是一个使用 infix修饰的中缀函数,我们可以使用更简单的方法进行调用,如下:

val spec = keyframes<Dp> {
  ...
    // 关键帧设置
    50.dp at 300
})

这样让使用更加简单好理解,代表动画数值达到 50.dp 时的动画时间为 300ms。

多个关键帧

上面介绍了如何添加关键帧并设置关键帧的动画值,那么是否可以设置多个关键帧呢?答案是肯定的, keyframes本身就是一个 Map 类型,可以为其添加多个 key-value 值。

前面设置了关键帧在 300ms 时动画值为 50.dp ,如果想再设置一个在 600ms 时动画值为 150.dp 的关键帧,代码实现如下:

var moveToRight by remember { mutableStateOf(false) }
val targetValue = if(moveToRight) 100.dp else 10.dp
val spec = keyframes<Dp> {
    durationMillis = 1000
    50.dp at 300
    150.dp at 600
}
val startPadding by animateDpAsState(targetValue, animationSpec = spec)

运行一下看看效果:

因为动画的目标值为 100.dp 但是又在 600ms 时设置了关键帧为 150.dp,所以动画会出现回退的现象。

动画速率

KeyframeEntity还有个 easing 属性,即动画速率曲线又该怎么设置呢?KeyframesSpecConfig提供了一个 with方法,源码如下:

infix fun KeyframeEntity<T>.with(easing: Easing) {
        this.easing = easing
}

KeyframeEntity添加了一个 with 的扩展方法传入 easing 参数,在方法实现里直接将参数 easing 赋值给 KeyframeEntity 的 easing 属性。既然是 KeyframeEntity的扩展方法,那就需要通过KeyframeEntity来进行调用,那么KeyframeEntity哪里来呢?巧了,前面的 at方法返回的不就是KeyframeEntity类型吗,所以可以在 at 方法后接着使用 with 方法来设置动画速率曲线。而 with 方法也是一个通过 infix修饰的中缀函数,所以可以如下使用:

    val spec = keyframes<Dp> {
        durationMillis = 1000
        // 关键帧设置
        50.dp at 300 with FastOutSlowInEasing
    })

通过上面的代码我们就设置了一个 FastOutSlowInEasing的动画速率曲线,既然是一个速率曲线,那肯定是对一个时间段的动画生效,但是这里只是设置的一个关键帧,那这个速率曲线是为哪段动画生效呢?是关键帧的前一段还是后一段呢?答案是后一段,以上面的例子为例,这里设置的是 300 ~ 1000ms 的动画时段的速率曲线。

那如果需要对前一段动画设置动画速率曲线怎么办呢?可以在前一段的起点位置再添加一个关键帧的配置,还是以上面代码为例,如果需要对 0 ~ 300ms 的动画时段设置速率曲线,可以添加如下关键帧:

    val spec = keyframes<Dp> {
        durationMillis = 1000
        // 添加起始关键帧并设置速率曲线
        10.dp at 0 with FastOutLinearInEasing
        // 关键帧设置
        50.dp at 300 with FastOutSlowInEasing
    })

如果添加了关键帧不设置 easing 参数,即后面不添加 with 设置速率曲线则使用 LinearEasing作为默认速率曲线。

缺陷

KeyframesSpec有一个不是缺陷的缺陷,那就是不能反着执行,实际上这并不是KeyframesSpec的缺陷,而是其特性如此,比如下面的动画代码:

var moveToRight by remember { mutableStateOf(false) }
val targetValue = if(moveToRight) 100.dp else 10.dp
val spec = keyframes<Dp> {
    // 动画时长 1000ms
    durationMillis = 1000
    // 关键帧1:300ms 时动画值为 50.dp
    50.dp at 300 
    // 关键帧2:600ms 时动画值为 150.dp
    150.dp at 600
}
val startPadding by animateDpAsState(targetValue, animationSpec = spec)
Box(
    Modifier
    .padding(start = startPadding, top = 10.dp)
    .size(100.dp, 100.dp)
    .background(Color.Blue)
    .clickable {
        moveToRight = !moveToRight
    }
)

为动画创建了 2 个关键帧,当 moveToRight 的状态从 false 变为 true 时动画效果是符合预期的,当 moveToRight 状态从 ture 变为 false 时我们期望的动画效果应该是之前的反向执行效果,但是实际运行效果却并不是这样,来看一下反向的运行的效果:

可以发现动画的执行是先从 100.dp 动画到 50.dp 然后再变为 150.dp 最后回到 10.dp ,为什么会这样呢?是因为反向执行的时候还是运用的两个相同的关键帧,即 300ms 时动画值为 50.dp、600ms 时动画值为 150.dp,且反向时动画的目标值为 10.dp 所以最后又会从 150.dp 动画到 10.dp。

那么如果要实现真正的反向效果应该怎么办呢?答案是重新定义一个反向的 spec,如下:

...
// 正向 sepc
val spec = keyframes<Dp> {
    durationMillis = 1000
    50.dp at 300
    150.dp at 600
}
// 反向 spec
val reverseSpec = keyframes<Dp> {
    durationMillis = 1000
    // 正向为 150.dp 时动画时间为 600ms 而总时长为 1000ms 即距离终点 400ms,
    // 所以反向时 150.dp 位置应该为 400ms
    150.dp at 400
    // 正向为 50.dp 时动画时间为 300ms 即距离终点 700ms,
    // 所以反向时 50.dp 位置应该为 700ms
    50.dp at 700
}
val targetSpec = if(moveToRight) spec else reverseSpec
val startPadding by animateDpAsState(targetValue, animationSpec = targetSpec)
...

这样就实现了真正的反向动画,效果如下:

DurationBasedAnimationSpec

DurationBasedAnimationSpec是一个基于动画时长配置的基类,上一篇以及本篇介绍的TweenSpecSnapSpecKeyframesSpec都是实现自 DurationBasedAnimationSpec接口。

他们有如下特点:

  • 都有明确的动画时长,TweenSpecKeyframesSpec是可以设置动画时长,SnapSpec的动画时长为 0
  • 都有 delayMillis 参数可以设置动画延时
  • KeyframesSpec实际上就是多段的 TweenSpec

最后

到目前为止我们了解了TweenSpecSnapSpecKeyframesSpec的动画配置,6 种动画配置已经探索了一半,下一篇我们将继续探索其他动画配置的使用,请持续关注本专栏了解更多 Compose 动画相关内容。