Jetpack Compose 动画笔记2——Animatable

使用 ObjectAnimator 时,ObjectAnimator.ofFloat(circleView, "radius", 100f),只填一个 value 值,执行动画时会先调用 circleView.getRadius() 获取当前 radius 值作为动画起点,再运动至终点 100f。这和 animateFloatAsState 是非常相似的,执行运动时,都是以当前状态值为起点,运动至目标值。

如果使用 ObjectAnimator 时填入多个 value 值:ObjectAnimator.ofFloat(circleView, "radius", 20f, 100f),那么第一个值会被作为起点(初始值),最后一个值会被作为终点。注意了,初始值不一定就是当前状态值:例如,当前一刻圆的半径为 50f,我要在下一帧开始动画,要求动画初始值是 20f,目标值是 100f。与 ObjectAnimator.ofFloat(circleView, "radius", 20f, 100f) 对应的 Compose 动画应该怎么写呢?

animateFloatAsState() 是无法指定动画起始值的,它的动画起点只能是当前状态值。也就是说 animateFloatAsState 适用场景仅限于状态之间的来回切换(一个状态的终点是另一个状态的起点)。想要对动画做更多的定制,指定动画起始值,就要使用更底层的动画 API ------ Animatable。

kotlin 复制代码
class Animatable<T, V : AnimationVector>(
    initialValue: T,
    val typeConverter: TwoWayConverter<T, V>,
    private val visibilityThreshold: T? = null,
    val label: String = "Animatable"
)

可以看到 Animatable 的构造函数有两个必填参数:初始值 initialValue 与 类型转换器 typeConverter。我们先不讨论怎么用 Animatable 指定动画初始值,从简单的开始,用 Animatable 来实现 animateDpAsState 在两个状态间来回切换的效果:

kotlin 复制代码
var isSmall by remember { mutableStateOf(true) }
// val size by animateDpAsState(targetValue = if (small) 100.dp else 200.dp)
val animatableSize = remember {
    Animatable(
        initialValue = if (isSmall) 100.dp else 200.dp,
        typeConverter = Dp.VectorConverter
    )
}

Box(
    Modifier
    .size(animatableSize.value)
    .background(MaterialTheme.colorScheme.primary)
    .clickable { isSmall = !isSmall }
)
  • 首先,用 animateXxxAsState 就是自动挡,它的内部已经包装了一层 remember,Animatable 是手动挡,需要自己包装一层 remember;

  • 其次,创建 Animatable 对象实例时,不能也不应该能用 by 属性委托,应该直接用 =。想在这里使用 by 的人,无非是想着后续使用变量时,能直接写 animatable 获取到动画值 而不用 animatable.value,不过我们后续是要用到 Animatable 的一些方法的,所以这里不要用 by

    kotlin 复制代码
    // 假设这里可以用 by 属性委托
    val animatableSize: Dp by remember { Animatable(...) }
    // 注意 animatableSize 现在是一个 Dp 实例,而不是 Animatable 实例
    // 后续会需要用到 Animatable 实例,以调用 Animatable 的一些方法,
    // 但因为属性委托的原因,已经没机会获取到 Animatable 实例了

现在运行代码:

没有效果,回过头看填入数值的地方,你会发现 animateDpAsState(targetValue = ...) 的形参是 targetValue,而构造函数 Animatable(initialValue = ...) 构造函数的参数名是 initialValue。点击导致 isSmall 状态变化,只是重新设置了 Animatable 的初始值,而没有设置动画的目标值。

animateTo()

我们需要手动调用 Animatable.animateTo(targetValue = ...) 来设置动画的目标值,第一次使用时你会发现这个方法居然还是个挂起函数

kotlin 复制代码
.clickable {
    small = !small
    lifecycleScope.launch {
        animatableSize.animateTo(if (small) 100.dp else 200.dp)
    }
}

即使套上一层 lifecycleScope.launch{} 也是不行的,在 Compose 中不能直接使用 lifecycleScope.launch{},运行时会报错,即使不报错,也不应该这么写,在 .clickable{} 里用 animateTo() 设置动画本身就是不合理的。为什么呢?单纯的从设计理念的角度看,Compose 是声明式 UI 框架,状态驱动界面。事件产生处 .clickable{} 不应该直接和界面/动画打交道,而应该是修改状态,让状态驱动界面/动画

kotlin 复制代码
var isSmall by remember { mutableStateOf(true) }
val animatableSize = remember {
    Animatable(
        initialValue = if (isSmall) 100.dp else 200.dp,
        typeConverter = Dp.VectorConverter
    )
}

// 状态驱动动画
remember(isSmall) {
    协程.launch {
        animatableSize.animateTo(if (isSmall) 100.dp else 200.dp)
    }
}

Box(
    Modifier
    .size(animatableSize.value)
    .background(MaterialTheme.colorScheme.primary)
    .clickable {
        // 修改状态
        isSmall = !isSmall
    }
)

LaunchedEffect()

在 Compose 里面,有一个函数专门用于启动协程:LaunchedEffect,它的作用是在 Compose 的生命周期中启动协程

kotlin 复制代码
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
)

参数是 key 用于标识这个协程,当 key 变化时,会取消之前的协程,启动新的协程。所以我们可以在 LaunchedEffect 里面调用挂起函数 animateTo(),完整代码如下:

kotlin 复制代码
var isSmall by remember { mutableStateOf(true) }
val size = remember(isSmall) { if (isSmall) 100.dp else 200.dp }
val animatableSize = remember {
    Animatable(
        initialValue = size,
        typeConverter = Dp.VectorConverter
    )
}

// 状态驱动动画
LaunchedEffect(isSmall) {
    animatableSize.animateTo(targetValue = size)
}

Box(
    Modifier
    .size(animatableSize.value)
    .background(MaterialTheme.colorScheme.primary)
    .clickable {
        // 修改状态
        isSmall = !isSmall
    }
)

snapTo()

animateTo() 会让动画从当前值运动至目标值,如果想让动画直接跳到目标值,可以使用 snapTo()。那如果我要设置动画起始值,在调用 animateTo() 之前先调用 snapTo(),不就可以让动画从指定的起始值运动至目标值了吗。

kotlin 复制代码
...
LaunchedEffect(isSmall) {
    if (!isSmall) {
        // 要变大时,先让动画跳到 0.dp,再运动至目标值
        animatableSize.snapTo(targetValue = 0.dp)
    }
    animatableSize.animateTo(targetValue = size)
}
...

可以看到两个状态虽然是 100.dp 和 200.dp,不过在 100dp -> 200dp 的过程中,我们指定了动画的起始值是 0.dp,所以动画是会从 100.dp 跳到 0.dp,再运动至 200.dp。

相关推荐
千里马学框架12 小时前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台12 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone13 小时前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc13 小时前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo15 小时前
Kotlin 协程中的 Job 结构化并发与取消
android
sun00770015 小时前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work16 小时前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼16 小时前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work16 小时前
ch21 签名、校验与发版
kotlin
AFinalStone17 小时前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui