Android Compose 动画使用详解(八)Animatable的使用

前言

前面介绍了 Compose 的 animateXxxAsState动画 Api 的使用,以及如何通过 animateValueAsState实现自定义 animateXxxAsState动画 Api ,如何对动画进行详细配置从而达到灵活的实现各种动画效果。

本篇将为大家介绍更底层的动画 Api :Animatable

Animatable

在前面介绍 animateXxxAsState的时候我们跟踪源码发现其内部调用的是 animateValueAsState,那么 animateValueAsState 内部又是怎么实现的呢?来看看 animateValueAsState 的源码:

fun <T, V : AnimationVector> animateValueAsState(
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: AnimationSpec<T> = remember {
        spring(visibilityThreshold = visibilityThreshold)
    },
    visibilityThreshold: T? = null,
    finishedListener: ((T) -> Unit)? = null
): State<T> {

    val animatable = remember { Animatable(targetValue, typeConverter) }
    val listener by rememberUpdatedState(finishedListener)
    val animSpec by rememberUpdatedState(animationSpec)
    val channel = remember { Channel<T>(Channel.CONFLATED) }
    SideEffect {
        channel.trySend(targetValue)
    }
    LaunchedEffect(channel) {
        for (target in channel) {
            val newTarget = channel.tryReceive().getOrNull() ?: target
            launch {
                if (newTarget != animatable.targetValue) {
                    animatable.animateTo(newTarget, animSpec)
                    listener?.invoke(animatable.value)
                }
            }
        }
    }
    return animatable.asState()
}

可以发现,animateValueAsState 的内部其实就是通过 Animatable 来实现的。实际上 animateValueAsState 是对 Animatable 的上层使用封装,而 animateXxxAsState 又是对 animateValueAsState 的上层使用封装,所以 Animatable 是更底层的动画 api。

下面就来看一下如何使用 Animatable实现动画效果。首先还是通过其构造方法定义了解创建 Animatable需要哪些参数以及各个参数的含义,构造方法定义如下:

class Animatable<T, V : AnimationVector>(
    initialValue: T,
    val typeConverter: TwoWayConverter<T, V>,
    private val visibilityThreshold: T? = null
)

构造方法有三个参数,参数解析如下:

  • initialValue:动画初始值,类型是泛型,即动画作用的数值类型,如 Float、Dp 等
  • typeConverter:类型转换器,类型是 TwoWayConverter,在 《自定义animateXxxAsState动画》一文中我们对其进行了详细介绍,作用是将动画的数值类型与 AnimationVector进行互相转换。
  • visibilityThreshold:可视阈值,即动画数值达到设置的值时瞬间到达目标值停止动画,可选参数,默认值为 null

了解了构造方法和参数,下面就来看一下怎么创建一个 Animatable,假设我们要对一个 float 类型的数据做动画,那么 initialValue就应该传入 float 的数值,那typeConverter传啥呢?要去自定义实现 TwoWayConverter么?大多数情况下并不用,因为 Compose 为我们提供了常用类型的转换器,如下:

// package : androidx.compose.animation.core.VectorConverters

// Float 类型转换器
val Float.Companion.VectorConverter: TwoWayConverter<Float, AnimationVector1D>

// Int 类型转换器
val Int.Companion.VectorConverter: TwoWayConverter<Int, AnimationVector1D>

// Rect 类型转换器
val Rect.Companion.VectorConverter: TwoWayConverter<Rect, AnimationVector4D>

// Dp 类型转换器
val Dp.Companion.VectorConverter: TwoWayConverter<Dp, AnimationVector1D>

// DpOffset 类型转换器
val DpOffset.Companion.VectorConverter: TwoWayConverter<DpOffset, AnimationVector2D>

// Size 类型转换器
val Size.Companion.VectorConverter: TwoWayConverter<Size, AnimationVector2D>

// Offset 类型转换器
val Offset.Companion.VectorConverter: TwoWayConverter<Offset, AnimationVector2D>

// IntOffset 类型转换器
val IntOffset.Companion.VectorConverter: TwoWayConverter<IntOffset, AnimationVector2D>

// IntSize 类型转换器
val IntSize.Companion.VectorConverter: TwoWayConverter<IntSize, AnimationVector2D>

// package : androidx.compose.animation.ColorVectorConverter 
// Color 类型转换器
val Color.Companion.VectorConverter: (colorSpace: ColorSpace) -> TwoWayConverter<Color, AnimationVector4D>

注意: Color 的转换器与其他转换器不是在同一个包下。

我们要作用于 Float 类型时就可以直接使用 Float.VectorConverter即可,代码如下:

 val animatable = remember { Animatable(100f, Float.VectorConverter) }

在 Compose 函数里创建 Animatable 对象时必须使用 remember进行包裹,否则会报错。

创建其他数值类型的动画则传对应的 VectorConverter即可,如 Dp、Size、Color,创建代码如下:

val animatable1 = remember { Animatable(100.dp, Dp.VectorConverter) }
val animatable2 = remember { Animatable(Size(100f, 100f), Size.VectorConverter) }
val animatable3 = remember { Animatable(Color.Blue, Color.VectorConverter(Color.Blue.colorSpace)) }

除此之外,Compose 还为 Float 和 Color 提供了简便方法 Animatable,只需要传入初始值即可,使用如下:

// Float 简便方法使用
import androidx.compose.animation.core.Animatable

val animatableFloat = remember { Animatable(100f) }

// Color 简便方法使用
import androidx.compose.animation.Animatable

val animatable5 = remember { Animatable(Color.Blue) }

需要注意的是虽然都是叫 Animatable,但是引入的包是不一样的,且这里的 Animatable 不是构造函数而是一个方法,在方法的实现里再调用的 Animatable 构造函数创建真正的 Animatable实例,源码分别如下:

Animatable(Float)

package androidx.compose.animation.core

fun Animatable(
    initialValue: Float,
    visibilityThreshold: Float = Spring.DefaultDisplacementThreshold
) = Animatable(
    initialValue,
    Float.VectorConverter,
    visibilityThreshold
)

Animatable(Color) :

package androidx.compose.animation

fun Animatable(initialValue: Color): Animatable<Color, AnimationVector4D> =
    Animatable(initialValue, (Color.VectorConverter)(initialValue.colorSpace))

Animatable创建好后下面看看怎么触发动画执行。

animateTo

Animatable提供了一个 animateTo方法用于触发动画执行,看看这个方法的定义:

suspend fun animateTo(
        targetValue: T,
        animationSpec: AnimationSpec<T> = defaultSpringSpec,
        initialVelocity: T = velocity,
        block: (Animatable<T, V>.() -> Unit)? = null
    ): AnimationResult<T, V>

首先animateTo 方法是用 suspend修饰的,即只能在协程中调用;其次方法有四个参数,对应解析如下:

  • targetValue:动画目标值
  • animationSpec:动画规格配置,这个前面几篇文件进行了详细介绍,共有 6 种动画规格可进行设置
  • initialVelocity:初始速度
  • block:函数类型参数,动画运行的每一帧都会回调这个 block 方法,可用于动画监听

最后返回值为 AnimationResult类型,包含动画结束时的状态和原因。

执行动画

我们还是以前面文章熟悉的方块移动动画来看一下 animateTo的使用效果,代码如下:

// 创建状态 通过状态驱动动画
var moveToRight by remember { mutableStateOf(false) }
// 动画实例
val animatable = remember { Animatable(10.dp, Dp.VectorConverter) }

// animateTo 是 suspend 方法,所以需要在协程中进行调用
LaunchedEffect(moveToRight) {
    // 根据状态确定动画移动的目标值
    animatable.animateTo(if (moveToRight) 200.dp else 10.dp)
}
Box(
    Modifier
        // 使用动画值
        .padding(start = animatable.value, top = 30.dp)
        .size(100.dp, 100.dp)
        .background(Color.Blue)
        .clickable {
            // 修改状态
            moveToRight = !moveToRight
        }
)

animateTo 需要在协程中进行调用,这里使用的是 LaunchedEffect来开启协程,他是 Compose 提供的专用协程开启方法,其特点是不会在每次 UI 重组时都重新启动协程,只会在 LaunchedEffect 参数发生变化时才会重新启动协程执行协程中的代码。

因为本篇主要介绍 Compose 动画的使用,关于 Compose 协程相关内容这里就不做过多赘述,有兴趣的同学可自行查阅相关资料。

除了通过状态触发 animateTo 外,也可以直接在按钮事件中触发,代码如下:

val animatable = remember { Animatable(10.dp, Dp.VectorConverter) }
// 获取协程作用域
val scope = rememberCoroutineScope()

Box(
    Modifier
        .padding(start = animatable.value, top = 30.dp)
        .size(100.dp, 100.dp)
        .background(Color.Blue)
        .clickable {
            // 开启协程
            scope.launch {
                // 执行动画
                animatable.animateTo(200.dp)
            }
        }
)

因为 LaunchedEffect 只能在 Compose 函数中使用,而点击事件并不是 Compose 函数,所以这里需要使用 rememberCoroutineScope()获取协程作用域后再用其启动协程。

动画监听

animateTo的最后一个参数是一个函数类型 (Animatable<T, V>.() -> Unit)?,可以用来对动画进行监听,在回调方法里可以通过 this 获取到当前动画 Animatable的实例,通过其可以获取到动画当前时刻的值、目标值、速度等。使用如下:

animatable.animateTo(200.dp){
    // 动画当前值
    val value = this.value
    // 当前速度
    val velocity = this.velocity
    // 动画目标值
    val targetValue = this.targetValue
}

可以通过监听动画实现界面的联动操作,比如让另一个组件跟随动画组件一起运动等。

返回结果

animateTo方法是有返回结果的,类型为AnimationResult,通过返回结果可以获取到动画结束时的状态和原因,AnimationResult 源码如下:

class AnimationResult<T, V : AnimationVector>(

    // 结束状态
    val endState: AnimationState<T, V>,

    // 结束原因
    val endReason: AnimationEndReason
)

只有两个属性 endStateendReason分别代表动画结束时的状态和原因

endStateAnimationState类型,通过其可以获取到动画结束时的值、速度、时间等数据,源码定义如下:

class AnimationState<T, V : AnimationVector>(
    val typeConverter: TwoWayConverter<T, V>,
    initialValue: T,
    initialVelocityVector: V? = null,
    lastFrameTimeNanos: Long = AnimationConstants.UnspecifiedTime,
    finishedTimeNanos: Long = AnimationConstants.UnspecifiedTime,
    isRunning: Boolean = false
) : State<T> {

    // 动画值
    override var value: T by mutableStateOf(initialValue)
        internal set

    // 动画速度矢量
    var velocityVector: V =
        initialVelocityVector?.copy() ?: typeConverter.createZeroVectorFrom(initialValue)
        internal set

    // 最后一帧时间(纳秒)
    @get:Suppress("MethodNameUnits")
    var lastFrameTimeNanos: Long = lastFrameTimeNanos
        internal set

    // 结束时的时间(纳秒)
    @get:Suppress("MethodNameUnits")
    var finishedTimeNanos: Long = finishedTimeNanos
        internal set

    // 是否正在运行
    var isRunning: Boolean = isRunning
        internal set

    // 动画速度
    val velocity: T
        get() = typeConverter.convertFromVector(velocityVector)
}

注意这里的 lastFrameTimeNanosfinishedTimeNanos是基于 System.nanoTime获取到的纳秒值,不是系统时间。

endReason是一个 AnimationEndReason类型的枚举,只有两个枚举值:

enum class AnimationEndReason {

    // 动画运行到边界时停止结束
    BoundReached,

    // 动画正常结束
    Finished
}

Finished很好理解,就是动画正常执行完成;那 BoundReached到达边界停止是什么意思呢?Animatable是可以为动画设置边界的,当动画运行到边界时就会立即停止,此时返回结果的停止原因就是 BoundReached,关于动画的边界设置以及动画停止的更多内容会在后续文章中进行详细介绍。

那么返回值在哪些情况下会用到呢?比如一个动画被打断时另一个动画需要依赖上一个动画的值、速度等继续执行,或者动画遇到边界停止时需要重新进行动画此时就可以通过上一个动画的返回值获取到需要的数据后进行相关处理。

snapTo

除了 animateToAnimatable还提供了 snapTo执行动画,看到 snapTo我们自然想到了前面介绍动画配置时的快闪动画 SnapSpec,即动画时长为 0 瞬间执行完成,snapTo也是同样的作用,可以让动画瞬间达到目标值,方法定义如下:

suspend fun snapTo(targetValue: T) 

同样是一个被 suspend修饰的挂起函数,即必须在协程里执行;参数只有一个 targetValue即目标值,使用如下:

val animatable = remember { Animatable(10.dp, Dp.VectorConverter) }
val scope = rememberCoroutineScope()

Box(
    Modifier
        .padding(start = animatable.value, top = 30.dp)
        .size(100.dp, 100.dp)
        .background(Color.Blue)
        .clickable {
            scope.launch {
                // 通过 snapTo 瞬间到达目标值位置
                animatable.snapTo(200.dp)
            }
        }
)

通过 snapTo 我们可以实现先让动画瞬间达到某个值,再继续执行后面的动画,比如上面的动画我们可以通过 snapTo让方块瞬间到 100.dp 位置然后使用 animateTo 动画到 200.dp,代码如下:

scope.launch {
    // 先瞬间到达 100.dp
    animatable.snapTo(100.dp)
    // 再从 100.dp 动画到 200.dp
    animatable.animateTo(200.dp, animationSpec = tween(1000))
}

实战

《Android Compose 动画使用详解(三)自定义animateXxxAsState动画》一文中我们通过 animateValueAsState自定义 animateUploadAsState实现了上传按钮的动画,现在我们看看如何通过 Animatable自定义实现同样的动画效果。

关于上传按钮动画的实现原理可查看 《Android Compose 动画使用详解(二)状态改变动画animateXxxAsState》一文的详细介绍。

首先自定义 UploadData实体类:

data class UploadData(
    val backgroundColor: Color,
    val textAlpha: Float,
    val boxWidth: Dp,
    val progress: Int,
    val progressAlpha: Float
)

然后自定义 animateUploadAsStateapi:

@Composable
fun animateUploadAsState(
    // 上传按钮动画数据
    value: UploadData,
    // 状态
    state: Any,
): UploadData {

    // 创建对应值的 Animatable 实例
    val bgColorAnimatable = remember {
        Animatable(
            value.backgroundColor,
            Color.VectorConverter(value.backgroundColor.colorSpace)
        )
    }
    val textAlphaAnimatable = remember { Animatable(value.textAlpha) }
    val boxWidthAnimatable = remember { Animatable(value.boxWidth, Dp.VectorConverter) }
    val progressAnimatable = remember { Animatable(value.progress, Int.VectorConverter) }
    val progressAlphaAnimatable = remember { Animatable(value.progressAlpha) }

    // 当状态改变时在协程里分别执行 animateTo
    LaunchedEffect(state) {
        bgColorAnimatable.animateTo(value.backgroundColor)
    }
    LaunchedEffect(state) {
        textAlphaAnimatable.animateTo(value.textAlpha)
    }
    LaunchedEffect(state) {
        boxWidthAnimatable.animateTo(value.boxWidth)
    }
    LaunchedEffect(state) {
        progressAnimatable.animateTo(value.progress)
    }
    LaunchedEffect(state) {
        progressAlphaAnimatable.animateTo(value.progressAlpha)
    }

    // 返回最新数据
    return UploadData(
        bgColorAnimatable.value,
        textAlphaAnimatable.value,
        boxWidthAnimatable.value,
        progressAnimatable.value,
        progressAlphaAnimatable.value
    )
}

使用:

val originWidth = 180.dp
val circleSize = 48.dp
// 上传状态
var uploadState by remember { mutableStateOf(UploadState.Normal) }
// 按钮文字
var text by remember { mutableStateOf("Upload") }

// 根据状态创建目标动画数据
val uploadValue = when (uploadState) {
    UploadState.Normal -> UploadData(Color.Blue, 1f, originWidth, 0, 0f)
    UploadState.Start -> UploadData(Color.Gray, 0f, circleSize, 0, 1f)
    UploadState.Uploading -> UploadData(Color.Gray, 0f, circleSize, 100, 1f)
    UploadState.Success -> UploadData(Color.Red, 1f, originWidth, 100, 0f)
}

// 通过自定义api创建动画
val upload = animateUploadAsState(uploadValue, uploadState)

Column {
    // 按钮布局
    Box(
        modifier = Modifier
            .padding(start = 10.dp, top = 20.dp)
            .width(originWidth),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .clip(RoundedCornerShape(circleSize / 2))
                .background(upload.backgroundColor)
                .size(upload.boxWidth, circleSize),
            contentAlignment = Alignment.Center,
        ) {
            Box(
                modifier = Modifier.size(circleSize).clip(ArcShape(upload.progress))
                    .alpha(upload.progressAlpha).background(Color.Blue)
            )
            Box(
                modifier = Modifier.size(40.dp).clip(RoundedCornerShape(20.dp))
                    .alpha(upload.progressAlpha).background(Color.White)
            )
            Text(text, color = Color.White, modifier = Modifier.alpha(upload.textAlpha))
        }
    }

    // 辅助按钮,用于模拟上传状态的改变
    Button(onClick = {
        when (uploadState) {
            UploadState.Normal -> {
                uploadState = UploadState.Start
            }
            UploadState.Start -> {
                uploadState = UploadState.Uploading
            }
            UploadState.Uploading -> {
                uploadState = UploadState.Success
                text = "Success"
            }
            UploadState.Success -> {
                uploadState = UploadState.Normal
            }
        }
    }, modifier = Modifier.padding(start = 10.dp, top = 20.dp)) {
        Text("改变上传状态")
    }
}

最后

本篇介绍了更底层动画 api Animatable的创建以及 animateTosnapTo 的使用,并通过一个简单的实战实例完成了如何通过 Animatable 实现自定义动画完成与 animateXxxAsState 同样的效果。除此之外 Animatable 还有 animateDecayapi 、边界值的设置以及停止动画等,由于篇幅问题我们将在后续文章中进行详细介绍,请持续关注本专栏了解更多 Compose 动画内容。

作者:loongwind
链接:https://juejin.cn/post/7170177890531688478

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容