引言
- Jetpack Compose 作为 Google 近期主推的 Android 开发 UI 框架,得益于其声明式编程的思想以及协程的加持,让 Compose 在开发过程中非常的舒适。
- 前段时间对 Compose 进行了较系统的学习,特地抽出其中动画相关内容,结合官方文档和自身实践经验和大家一起交流。
文章目的:
- 本系列文章分为上下两篇,也是希望在读完文章之后能覆盖 Compose 动画中的 80% 的开发 API 需要以及容易遇到的问题;
- 上篇想跟大家说说 Compose 动画的优点,并着重介绍官方封装好的高级别 API 设计及使用;
- 下篇会聊聊 Compose 动画偏底层的 API 及简单说说动画的触发流程,同时聊聊多个动画的监听及并发执行写法。
知识储备:
- 我希望你在阅读本文前对 Kotlin 协程、Jetpack Compose 基础都有一定的了解~
一、我为什么喜欢用 Compose 写动画?
1.1 声明式编程
-
得益于声明式编程的优势,在大多数的动画类型的选择上,你不需要像原来那样在帧动画、补间动画和属性动画中选择太久;也不需要纠结用
XML
动画还是使用Animation
类; -
Compose 的动画都用代码的方式写在
@Compose
方法里面,通常只需要确认需要修改的属性(大小、位置、透明度)等,再用合适的动画类型去修改这些属性就可以了。 -
我们举个例子:比如下面这个非常简单的动画,点击时会切换图片的尺寸和透明度。
-
如果是命令式编程的写法,我们需要去思考需要使用
ScaleAnimation
、AlphaAnimation
,再用AnimationSet
来将动画合并;并且还要同时写出toBigAnimateSet
和toSmallAnimateSet
,最后“命令”指定的视图去执行这个动画。 -
而在 Compose 声明式编程的世界里,你只需要在原来的代码基础上,对指定的属性做动画就可以了,让我们来感受一下。
enum class HeartState { SMALL, BIG } // 定义红心的两种类型
var action by remember { mutableStateOf(HeartState.SMALL) } // 指定红心状态(也是动画的触发器)
val animationTransition = updateTransition(targetState = action, label = "") // 创建动画类
// 定义不同状态下的尺寸
val size by animationTransition.animateDp(label = "改变大小") { state ->
if (state == HeartState.SMALL) 40.dp else 60.dp
}
// 定义不同状态下的透明度
val alpha by animationTransition.animateFloat(label = "改变透明度") { state ->
if (state == HeartState.SMALL) 0.5f else 1f
}
// 绘制红心
Image(
modifier = Modifier.size(size = size).alpha(alpha = alpha),
painter = painterResource(id = R.drawable.heart),
contentDescription = "heart"
)
// 点击触发状态切换
Text(
text = "切换",
modifier = Modifier
.padding(top = 10.dp, bottom = 50.dp)
.clickable { action = if (action == HeartState.SMALL) HeartState.BIG else HeartState.SMALL }
)
1.2 封装好的高级API
- Compose 提供了部分高级别、开箱即用的动画API,让我们可以通过少量的代码就可以实现想要的动画。比如一个动画的出现和消失,我们可以通过 Compose 提供的
AnimatedVisibility
来实现。甚至再加一两行代码,控制出场和退场的方式。
var isVisible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically() + fadeIn(), // 水平方向划入 + 渐变展示
exit = slideOutHorizontally() + fadeOut() // 垂直方向划出 + 渐变隐藏
) {
Image(
modifier = Modifier.size(size = 50.dp),
painter = painterResource(id = R.drawable.heart),
contentDescription = "heart"
)
}
- 那么它就会有这样的效果(很方便吧!)
1.3 工具的支持
-
IDE 对 Compose 动画进行了工具上的支持。通过 Arctic Fox 版本的 Android Studio,我们可以对动画进行逐帧的检查和调试,播放视图从不同状态间切换的动画,并且能非常直观的观察到视图的具体数据,做出精益求精的效果。
-
ps:目前版本的 AS 对动画并未完全支持,不过相信后续会继续完善。
- ✅ AnimateVisibility / updateTransition
- ❎ AnimatedContent / animate*AsState
二、如何选择合适的动画实现
除了上面提到的
AnimatedVisibility
,Compose还提供了很多封装好的动画,这些 API 经过专门设计,符合 Material Design 运动的最佳做法。接下来,我将会一一介绍。
- 具体可以参考下面的这张图,对不同的场景使用不同的 API;
- 本篇文章会带着大家一起学习一下高级别 API 以及相关的内容。
三、基于内容变化的动画
3.1 出现和消失 → 改变内容
- 上面的例子有提到,我们可以直接使用 Compose 提供的
AnimatedVisibility
动画,现在我们来看下具体使用:传送门
@Composable
fun AnimatedVisibility(
visible: Boolean,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = shrinkOut() + fadeOut(),
content: @Composable() AnimatedVisibilityScope.() -> Unit
)
- 参数解析:
-
visible
:动画的触发器。当数值从false
→true
时,会执行enter
动画;相反,会执行exit
动画; -
enter
:对象的进入动画,传入EnterTransition
的子类。Compose 已经封装好高度易用的动画类,如fade
、slide
、scale
、expand
等; -
exit
:对象的退出动画,传入ExitTransition
的子类。上述的进入动画均有一一对应的退出动画; -
content
:需要执行动画的内容。值得注意的是,当前content
是定义在AnimatedVisibilityScope
中的,其中提供了transition
对象可直接使用,可以理解成时刻同步动画状态的对象,通过transition
对象,我们可以高度定制化地自定义动画过程中的其他动画。(关于Transition类后面会详细介绍) -
使用
AnimatedVisibilityScope
的transition
来添加自定义动画效果: 例子:在红心出现和消失的同时,我们需要同时改变红心的颜色
-
var isVisible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutHorizontally() + fadeOut()
) {
// 此处 transition 是 AnimatedVisibilityScope 中的成员
val customColor by transition.animateColor(label = "颜色变化") { state ->
if (state == EnterExitState.Visible) Color.Blue else Color.Red
}
Icon(
modifier = Modifier.size(size = 50.dp),
painter = painterResource(id = R.drawable.heart),
tint = customColor,
contentDescription = "heart"
)
}
- 一些补充:
- 如果需要自定义
AnimatedVisibility
内子项的进出动画,可以使用Modifier.animateEnterExit
来重新定制动画; - 出现和消失动画对应的是 Native 中的
Visible
和Gone
状态,在视图消失的时候会带来布局容器的改变;
- 如果需要自定义
3.2 淡入和淡出 → 切换内容
- 我们可以优先使用
AnimatedContent
动画,我们来看下具体的使用:传送门
@ExperimentalAnimationApi
@Composable
fun <S> AnimatedContent(
targetState: S,
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = {
fadeIn(animationSpec = tween(220, delayMillis = 90)) with fadeOut(animationSpec = tween(90))
},
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
)
- 参数解析:
- 从
<S>
可以看出这是一个可以适配不同内容类型的泛型方法,可以使用S
来定义其类型; targetState
:动画的触发器,传入下个阶段的状态。比如内容从 “Hello” 切换到 “world”,“world” 就是此时传入的targetState
;transitionSpec
:执行动画的规范。当前参数所需要传入的是能返回ContentTransform
对象的方法;ContentTransform
的生成一般是调用with
infix方法生成,如下:
// ContentTransform 的作用是会记录视图的 进入/退出 动画 infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)
- 从签名上看到的
AnimatedContentScope<S>.() -> ContentTransform
,指的是在当前方法内,使用者可以根据AnimatedContentScope
内的对象返回自己需要的动画规范;
contentAlignment
:内容的对齐方式。默认会从向着布局的左上方进入和退出;
- 从
- 关于
AnimatedContentScope
:- 对于内容切换的动画,在其 Scope 内部,除了会提供监听动画状态的
transition
对象,还会给调用方提供initialState
(变化前的状态) 和targetState
(变化后的状态) ,供我们使用;
- 对于内容切换的动画,在其 Scope 内部,除了会提供监听动画状态的
- 接下来我们会简单写一个数字切换的动画:
var count by remember { mutableStateOf(0) } // 初始值为 0
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
// 数字变大时,进入的数字从下往上变深划入,退出的数字从下往上变浅划出
slideInVertically({ height -> height }) + fadeIn() with slideOutVertically({ height -> -height }) + fadeOut()
} else {
// 数字变小时,进入的数字从上往下变深划入,退出的数字从上往下变浅划出
slideInVertically({ height -> -height }) + fadeIn() with slideOutVertically({ height -> height }) + fadeOut()
}
}
) { targetCount ->
Text(text = "$targetCount")
}
// 启动协程切换数字,达到每 1 秒自动切换的效果
LaunchedEffect(Unit) {
while (true) {
if (count == 0) count++ else count--
delay(1000)
}
}
- 一些补充:
- 你仍可以使用其他高度封装的API来实现内容的切换动画,如
animateContentSize
(动画效果实现视图的尺寸变化) 和Crossfade
(淡入淡出效果实现布局切换); - 你还可以结合
using
和SizeTransform
来定义切换过程中的尺寸变化,此处不作详述。
- 你仍可以使用其他高度封装的API来实现内容的切换动画,如
infix fun ContentTransform.using(sizeTransform: SizeTransform?)
四、基于效果状态的动画
4.1 视图单个属性的变化
animate*AsState
是一个非常简单的 API,只需要提供最终值,API 就会从当前值开始播放动画;- Compose 对
Float
、Color
、Dp
、Size
、Offset
、Rect
、Int
、IntOffset
和IntSize
等基本类型都提供了animate*AsState
方法,我们举animateFloatAsState
为例
@Composable
fun animateFloatAsState(
targetValue: Float,
animationSpec: AnimationSpec<Float> = defaultAnimation,
visibilityThreshold: Float = 0.01f,
finishedListener: ((Float) -> Unit)? = null
): State<Float>
-
参数解析:
targetValue
:动画的触发器。当这个值发生变化时,就会触发动画的执行;animationSpec
:执行动画的规范。后续会细讲。visibilityThreshold
:判断是否已经靠近目标数值的阈值。finishedListener
:动画结束监听器。
-
从参数上都是非常容易理解的,篇幅原因不作例子介绍。
4.2 视图多个属性的变化
- 对于一个需要同时修改多个属性的视图,我们建议采用
updateTransition
。 - 这里的思路是,将视图划分为不同的状态,然后通过状态的变化计算出不同状态下的属性值。
@Composable
fun <T> updateTransition(targetState: T, label: String? = null): Transition<T>
- 同样的,
targetState
是动画的触发器,其类型可以是我们自定义的不同状态; - 可以看到,这个方法返回的是
Transition<T>
对象,而此类型也提供了返回不同类型的animate*
方法,和上述的animate*AsState
类似。 - 代码例子可见本文开头。
4.3 对于自定义类型的属性变化
- 无论是
animate*AsState
还是Transition.animate*
,我们都会遇到变化的属性为自定义类型(非基本类型) 的情况。Compose 提供了便捷的 API 供我们自定义变化规则。而他就是TwoWayConverter
; - 先来看下他会在哪用到
@Composable
fun <T, V : AnimationVector> animateValueAsState(
targetValue: T,
typeConverter: TwoWayConverter<T, V>,
// ...
): State<T>
@Composable
inline fun <S, T, V : AnimationVector> Transition<S>.animateValue(
typeConverter: TwoWayConverter<T, V>,
// ...
): State<T>
convert
的含义是“转换”,而TwoWayConverter
的作用是定义一个 自定义类型 转成 N个浮点值 来执行动画,再将动画返回的 N个浮点值 转成 自定义类型 的过程。- 我们看下
animateRectAsState
的源码,就可以很容易理解上面这句话。
// 1.可以看到 animate*AsState 内部执行的都是 animateValueAsState
@Composable
fun animateRectAsState( /* ... */): State<Rect> {
return animateValueAsState(
targetValue, Rect.VectorConverter, animationSpec, finishedListener = finishedListener
)
}
// 2.对于当前方法,传入的 TwoWayConverter 是 Rect.VectorConverter
private val RectToVector: TwoWayConverter<Rect, AnimationVector4D> =
TwoWayConverter(
convertToVector = { AnimationVector4D(it.left, it.top, it.right, it.bottom) },
convertFromVector = { Rect(it.v1, it.v2, it.v3, it.v4) }
)
// 3.TwoWayConverter<T, V>的类型接收两个参数,T指的是原来的类型,V指的是需要中转的类型;
// AnimationVector4D 指的是使用持有 4 个浮点值的 Vector 进行中转;
// 整个过程就是 Rect -> AnimationVector4D -> Rect,非常容易理解。
4.4 AnimationSpec
- AnimationSpec 指的是动画规范,定义了动画以怎样的规则运行。官方定义了以下几种常用的 API,我们可以简单看一下。传送门
API | 含义 | 属性 |
---|---|---|
spring | 弹窗动画 | dampingRatio :定义弹簧的弹性,可选参数如Spring.DampingRatioHighBouncy ;stiffness :定义弹簧向结束值移动的速度,可选参数如 Spring.StiffnessMedium |
tween | 定时动画 | durationMillis :定义动画的持续时间;delayMillis :定义动画开始的延迟时间;easing :定义起始值和结束值之间的动画效果,如LinearOutSlowInEasing |
keyframe | 关键帧动画 | 同 tween |
repeatable | 重复动画 | iterations :迭代次数;animation :需要重复执行的动画;repeatMode :重复的模式,如从头开始 (RepeatMode.Restart ) 还是从结尾开始 (RepeatMode.Reverse ) |
五、对相同的动画进行封装的最佳实践
在一些相同的场景下,对于不同的视图执行的对象是一样的,这时候我们就应该对相同的部分进行抽离。当然,这一切都是有办法的,并不是直接抽函数那么简单。
- 我们将用文章开头的例子进行实践。
- 我们可以对动画需要修改的属性进行封装。比如红心的尺寸(size)和透明度(alpha)
// 注意这里传入的是 State 对象,这样才能保证视图能够在重组过程中被持续刷新
private class AnimateTransitionData(size: State<Dp>, alpha: State<Float>) {
val size by size // kotlin 的语法糖,同名委托表示取值
val alpha by alpha
}
- 创建返回上述对象的方法,将动画逻辑进行封装
@Composable
private fun updateTransitionData(targetState: HeartState): AnimateTransitionData {
val transition = updateTransition(targetState = targetState, label = "")
// 定义不同状态下的尺寸
val size = transition.animateDp(label = "改变大小") { state ->
if (state == HeartState.SMALL) 40.dp else 60.dp
}
// 定义不同状态下的透明度
val alpha = transition.animateFloat(label = "改变透明度") { state ->
if (state == HeartState.SMALL) 0.5f else 1f
}
// 此处将 transition 对象作为 remember 的入参,表明每当 transitio 更新,都会触发 AnimateTransitionData 的更新和返回
return remember(transition) { AnimateTransitionData(size, alpha) }
}
- 将方法执行套入原来的结构中
var action by remember { mutableStateOf(HeartState.SMALL) }
val data = updateTransitionData(action)
// 绘制红心
Image(
modifier = Modifier
.size(size = data.size)
.alpha(alpha = data.alpha),
painter = painterResource(id = R.drawable.heart),
contentDescription = "heart"
)
六、小结
- 了解了 Compose 开发动画的一些优点:基于声明式编程的 API 设计;提供了基于 Material Design 的高级 API 封装;IDE 新功能的支持;
- 了解了怎么写基于内容变化的动画:如控制内容出现隐藏的
AnimatedVisibility
、控制内容变化的AnimatedContent
和Crossfade
等; - 了解了怎么写基于状态变化的动画;如控制单状态变化的
animate*AsState
、控制多状态变化的updateTransition
等; - 了解了提供给非基本类型使用的类型转换器
TwoWayConverter
; - 了解了对相同动画逻辑代码进行封装的最佳实践;