使用 Jetpack Compose 实现精美动画
我们将通过本文介绍 Compose 中的一些动画 API,并探讨如何有效地使用它们。Compose 中的动画 API 是我们构想的全新 API,这些 API 中有许多是声明式的,您可以利用声明式的方式简洁地定义动画。
这些动画 API 支持中断,当运行中的动画被另一个动画打断时,运行中动画的值会带入到新动画中。新 API 简单易用,配置了合理的默认行为,可开箱即用,也可高度定制。同时 Android Studio 还提供了强大的工具,可以帮助您制作复杂动画。
如果您更喜欢通过视频了解此内容,请在此处查看:
Bilibili 视频链接 https://www.bilibili.com/video/BV1J94y1S7sm/
Compose 动画概览
我们先从一个简单例子开始。下图是一个猫咪图标,当我们点击按钮时,它会在隐藏和显示这两种状态间进行切换:
△ 点击按钮,小猫图标会随之隐藏或显示
在 Compose 中,实现这一效果非常简单。首先我们声明一个布尔类型的 State 变量——visible,在每次点击按钮时,它的值都会被切换,而它的任何变化都会触发重组,猫咪图标也会随之出现或消失:
var visible by remember { mutableStateOf(true) }
Column {
Button(onClick = { visible = !visible }) {
Text("Click")
}
if (visible) {
CatIcon( )
}
}
现在,如果我们想将此过程转变为动画,则只需将 if 语句替换为 AnimatedVisibility 可组合项即可。当 State 的值发生改变时,AnimatedVisibility 可组合项会以其状态运行动画:
…
AnimatedVisibility (visible) {
CatIcon( )
}
…
还有一个 API 与 AnimatedVisibility 非常相似,那就是 AnimatedContent。AnimatedVisibility 的运行基于内容的进入和退出,而 AnimatedContent 则可为内容的变化生成过渡动画。
在下面的例子中,当我们点击按钮时,计数会随淡出和淡入效果而增加:
△ 点击按钮时计数随淡出淡入效果增加
AnimatedContent 的 State 参数可以是任何类型,在本示例中,我们使用名为 count 的整型 State,在点击按钮时,其数值会随之增加。而每次 State 发生变化时,AnimatedContent 就会运行动画。
Row {
var count by remember { mutableStateOf (0) }
Button(onClick = { count++ }) {
Text("Add")
}
AnimatedContent (targetState = count) { targetCount ->
Text("Count: $targetCount")
}
}
我们可以使用 lambda 参数,基于输入的 State 切换内容。AnimatedVisibility 和 AnimatedContent 都提供了合理的默认动画样式,但我们也可对其进行自定义。对于 AnimatedVisibility,可以自定义其进入和退出的过渡动画;对于 AnimatedContent,则可以使用 transitionSpec 参数自定义进入、退出过渡动画的组合。
AnimatedVisibility (
visible = visible,
enter = fadeIn()+ scaleIn(),
exit = fadeOut() + scaleOut()
) {
// ……
}
AnimatedContent(
targetState = … ,
transitionSpec = {
fadeIn() + scaleIn() with fadeOut() + scaleOut()
}
) { targetState ->
// ……
}
下图中列出了一些进入和退出的过渡动画,其中包括 fadeIn、fadeOut、slideIn、slideOut 以及 scaleIn 和 scaleOut,这些过渡动画效果如下:
△ 进入动画演示 (如左) 和退出动画演示 (如右)
我们还提供了更多过渡动画选项,您可以在文档中查看完整列表:
AnimatedVisibility 和 AnimatedContent 已经可以应对诸多场景,不过我们还提供了一些更为通用的 API。animate*AsState API 可用于为单个值制作动画,您只需将各种数据类型与 animate*AsState 函数组合,即可将其转换为对应的动画值。在本示例中,我们为 dp 值制作动画,所以我们使用 animateDpAsState。
val offsetX by animateDpAsState(
if (isOn) 512.dp else 0.dp
)
△ 利用 animateDpAsState API 实现的动画效果
我们开始时有提到,基于 State 的 API 支持中断。也就是说,如果播放中动画的状态发生变化,新动画将从当前的中间值和速度开始,并基于弹簧的物理效果继续播放。我们将这样的动画行为称为 AnimationSpec。
Spring 是默认的 AnimationSpec。Compose 还提供了其他类型的 AnimationSpec。例如,tween 是基于持续时间的 AnimationSpec,它根据动画由始至终的持续时间来定义运动效果。
△ spring 与 tween 两种 AnimationSpec
我们还提供了其他各种 AnimationSpecs,请参阅文档——动画:
https://developer.android.google.cn/jetpack/compose/animation
我们可以通过下面的例子了解如何为 animate*AsState 指定 AnimationSpec。在这个例子中,我们指定动画的播放时长为三秒钟:
val offsetX by animateDpAsState(
if (isOn) 512.dp else 0.dp,
animationSpec = tween(durationMillis = 3000)
)
△ 指定动画播放时长为 3 秒钟
那么,如果需要同时为多个值制作动画,应该怎么做?您可以使用 updateTransition API,它对构建非常复杂的动画大有助益。我们来看一个简单的例子,下图是一个填充了颜色的方块,我们要为方块的大小和颜色这两个值同时制作动画:
△ 对方块的大小和颜色同时进行动画
首先,我们需要定义 BoxState。这是一个枚举类型,代表动画的目标,可以是 Small 或者 Large:
private enum class BoxState (
Small,
Large
}
然后,我们为其创建一个 State 对象,改变 State 的值会触发动画:
var boxState by remember { mutableStateOf (BoxState.Small) }
然后我们使用 updateTransition 创建 Transition 对象。注意,最好为 Transition API 中所使用的对象附上标签,以便 Android Studio 可以更好地展示动画,这点我们稍后再介绍:
val transition = updateTransition(
targetState = boxState,
label = "Box Transition"
)
之后,我们就可以使用 animateColor 和 animateDp 等扩展函数创建动画值了。这些函数的返回值都是 State 对象,因此其使用方式与其他 State 相同:
val color by transition.animateColor(label = "Color") { state ->
when (state) {
BoxState.Small -> Blue
BoxState.Large -> Orange
}
}
val size by transition.animateDp (label = "Size") { state ->
when (state) {
BoxState.Small -> 32.dp
BoxState.Large -> 128.dp
}
}
将目前为止我们了解的所有内容结合,便可以实现非常复杂的动画,如下图所示:
△ 使用多种效果复合的复杂动画
示例中使用了 updateTransition 为多个值制作动画,例如表格的高度、位置及其内容的透明度。同时还使用了 AnimatedVisibility 自定义进入和退出过渡动画,从而实现了理想的淡入和淡出效果。
Android Studio 动画检查工具
现在我们已经知道了如何创建复杂的动画,接下来,我们看看 Android Studio 如何帮助我们实现精美的动画效果。Android Studio 提供了动画预览功能来帮您快速验证动画效果,它会自动检测动画的使用,您可以在 Android Studio 中直接播放动画;Android Studio 还可以图形化动画的值,以便您可以快速浏览这些值是如何随时间变化的:
△ 在 Android Studio 预览动画效果
这里要注意的是,我们在前面生成 Transition 对象时添加的标签,会在检测到的动画列表中,作为选项卡的名称展示出来。
如下图所示,Compose 预览上的对应图标按钮表示界面中存在可检查的动画,点击按钮即可启用动画检查:
△ 启用动画检查按钮
该工具目前支持 AnimatedVisibility 和 updateTransition,但我们正计划添加对 AnimatedContent 和 animate*AsState 的支持。
如下图所示,我们可以使用动画检查窗口来播放、浏览和慢放 AnimatedVisibility:
△ 使用动画检查窗口检查动画
此工具还可绘制动画曲线,以便您将其与设计师所设计的运动参数进行对比,这有助于确保动画值的正确编排:
△ 对比和检查动画曲线
使用协程完成复杂动画
现在,我们已经了解了基于 State 的各种动画 API,它们十分有助于我们在常见用例中为 State 变化制作动画。而如果是更为复杂的场景,比如需要为动画指定自定义行为时又该怎么做呢?
例如,在某些情况下需要对动画进行更多控制,您可能需要对动画或动画集进行排序;又或者,您可能希望在动画中断时执行自定义行为。
正如我们所知,当动画中断时,基于 State 的动画 API 会保持动画值和速度的连续性。但在某些情况下,为了强调手势或响应,您可能并不需要连续性。例如,在下图中双击点赞这一动画中,再次双击时,播放中的动画会从头播放:
△ 在点赞动画的过程中再次双击,动画重新播放
这种情况下,您可能需要使用目标不明确的不确定动画。我们将这种动画称之为投掷行为 (Fling),投掷行为的目标仅来自起始条件及其衰减函数。
当我们为了应对复杂的场景,而需要协调动画的编排时,就要用到 Kotlin 的一项强大功能——协程。下面的示例中是一个基础的协程动画 API——animate。使用它创建的动画,会以 initialValue 参数和可选的 initialVelocity 参数所确定的开始条件运行至 targetValue 所指定的值;可选的 animationSpec 可用于自定义运动参数,该参数的默认值为 spring();最后,我们传入函数参数 block,animate 会在每帧动画上使用最新的动画值和速度调用此参数。
suspend fun animate(
initialValue: Float,
targetValue: Float,
initialVelocity: Float = 0f,
animationSpec: AnimationSpec<Float> = spring(),
block: (value: Float, velocity: Float) -> Unit
)
注意 animate 函数的 suspend 修饰符,这意味着此函数可在协程中使用,并且可以挂起协程直到动画完成。这是对动画进行排序的关键。下图展示了在协程中执行 animate 函数的过程。您会注意到,一旦调用了 animate 函数,调用动画的协程就会被挂起,直到动画结束。之后,协程将恢复并执行后续工作。
△ 使用协程执行 animate() 的过程
这有助于我们对操作进行排序,以及在动画后执行任务。以往,我们会将此类任务置于动画结束监听器中,而有了协程,便无需结束监听器。
下面是生成上图所示工作流的代码。我们首先使用 rememberCoroutineScope 在组合内部创建 coroutineScope,然后使用 launch 函数在该作用域内创建一个新的协程。在新的协程中,首先调用 animate。animate 只会在动画结束后返回,因此,动画结束后需要完成的任何任务,如更新状态或者启动另一个动画都可以放在 animate 后面。而如果需要取消动画,我们可以直接取消执行动画的协程。
val scope = rememberCoroutineScope()
…
scope.launch { // 创建新的协程
animate(...)
// 更新状态、开启另一个动画,等等
subsequentWork()
}
如下图所示,如果用另一个 animate 函数替换 subsequentWork 函数,就可以得到两个连续运行的动画。如果查看代码,您会发现我们仅使用了两个连续的 animate 函数便可以实现连续动画。
val scope = rememberCoroutineScope()
…
scope.launch { // 创建新的协程
animate(...)
animate(...)
}
△ 使用协程顺序执行动画
现在我们已经了解如何构建连续动画,那么如果我们想同时运行动画的话,该怎么做?
我们可以将动画分别放在单独的协程中并行运行。为此,我们需要使用 CoroutineScope。CoroutineScope 定义了在其作用域内所创建的新协程的生命周期。在该作用域内,可使用协程构建器函数 launch 来创建新的协程。launch 是非阻塞函数,所以我们可以并行创建多个协程,并在其中同时运行动画。
△ 使用 launch 函数创建多个协程
除了高亮的 launch 函数外,下面的示例代码与之前展示的连续动画代码相同,都可以创建新的协程。如前所述,launch 是非阻塞函数,所以,新的协程可以并行创建,并且动画将在同一帧开始运行。
val scope = rememberCoroutineScope()
…
scope.launch {
launch { // 创建新的协程
animate(...)
}
launch { // 创建新的协程
animate(...)
}
}
现在,我们完成了同时运行的动画。一言以蔽之,协程有助于极其灵活地协调动画。我们可以在同一个协程中轻松执行两个 animate 函数来创建连续的动画;我们还可以在不同的协程中运行动画,从而同时运行这些动画。这些都是更为复杂动画的组成部分。
在接下来的示例中,我们要创建双击点赞的心形动画:
△ 双击点赞的心形动画
如下图所示,这个动画包含两个阶段: 首先,我们需要在心形进入时,淡入并放大心形;进入动画完成后,启动退出动画以淡出,同时进一步放大心形。
为此,我们可以创建两个 CoroutineScope,一个用于进入动画,另一个用于退出动画。当作用域内的所有动画运行完成后,CoroutineScope 才会返回,因此,进入和退出动画将连续运行。在每个 CoroutineScope 中,我们使用 launch 函数创建新的协程,使淡入淡出和缩放动画可以同时运行。
△ 动画中所包含的协程任务
在使用代码构建此动画时,首先要为 alpha 和 scale 创建 MutableState 对象,以便在动画过程中更新它们的值。然后需要创建两个 CoroutineScopes,以便连续运行进入动画和退出动画。在每个 CoroutineScope 中,我们将使用 launch 函数分别创建单独的协程,从而使淡入淡出和缩放动画可以同时运行。在动画运行期间,我们使用 animate 函数中的 lambda 更新 alpha 或 scale。
var alpha by remember { mutableStateOf(0f) }
var scale by remember { mutableStateOf(0f) }
…
scope.launch {
coroutineScope {
launch { // 淡入
animate(0f, 1f) { value, _ -> alpha = value }
}
launch { // 放大
animate(0f, 2f) { value, _ -> scale = value }
}
}
caroutineScope (
launch { // 淡出
animate(1f, 0f) { value, _ -> alpha = value }
}
launch { // 放大
animate(2f, 4f) { value, _ -> scale = value }
}
}
}
在了解协程动画的基础知识之后,接下来我们讲解一个更为复杂的用例。这是一个表示内容正在加载的动画,在等待内容加载时,有一个渐变条从上到下反复扫描。内容加载后,如果渐变条仍在扫描中,我们将等待该次扫描动作完成,然后再次从上到下,执行最后一次扫描并显示内容:
△ 内容加载动画
为了实现这一效果,我们首先需要创建一个 Animatable 对象,它将跟踪动画的值和速度。在使用 Animatable 对象创建新动画时,我们只需提供新的目标值,当前值和速度会默认转为新动画的开始条件。
@Composable
fun LoadingOverlay(isLoading: State<Boolean>) {
val fraction = remember { Animatable(0f) }
…
然后在 LaunchedEffect 创建的 coroutineScope 中,我们会使用 Animatable 的两个挂起函数: 一个是 animateTo,另一个是 snapTo。AnimateTo 将从 Animatable 的当前值和速度开始,向新的目标值运行动画;snapTo 会在不使用任何动画的情况下取消任何正在运行的动画,并更新 Animatable 的值。
var reveal = { mutableStateOf(false) }
LaunchedEffect(Unit) {
while(isLoading.value) {
fraction.animateTo(1f, tween (2000))
fraction, snapTo(Of)
}
…
}
由于我们要让渐变条从上到下移动,随后返回顶部,所以需要首先以 1 为目标调用 animateTo,同时使用 2,000 毫秒的补间动画。然后通过 snapTo 让渐变条返回顶部。由于 animateTo 和 snapTo 均为挂起函数,所以我们可对其排序,并在 while 循环中重复该序列,直到加载完成。
由于我们只在每次扫描之前检查加载状态,所以任何对加载状态的更改只会在当前扫描完成后生效。这样一来,我们就创建了一个自定义的中断处理行为。它的功能不同于基于 State 的动画 API,内容加载完成后,我们便退出 while 循环,并在执行最后一次扫描前,更改显示状态、制作渐变条移动至底部的动画。
reveal = true
fraction.animateTo(1f, tween(1000))
最后,当 reveal 的值变为 true 时,我们停止在此叠加层中绘制不透明的封面,以便在最后一次扫描时显示下方的内容:
…
if (!reveal) {
// 渐变条下的不透明覆盖
Box(Modifier.background(backgroundColor))
}
…
这样一来,我们就完成了这个动画效果。完整的代码示例如下:
@Composable
fun LoadingOverlay(isLoading: State<Boolean>) {
val fraction = remember { Animatable(0f) }
var reveal = { mutableStateOf(false) }
LaunchedEffect(Unit) {
while(isLoading.value) {
fraction.animateTo(1f, tween (2000))
fraction. snapTo(0f)
}
reveal = true
fraction.animateTo(1f, tween(1000))
}
if (!reveal) {
// 渐变条下的不透明覆盖
Box(Modifier.background(backgroundColor))
}
……
}
尾声
最后,让我们一同欣赏由社区开发者所构建的精彩动画:
△ 社区利用 Jetpack Compose API 所实现的精彩动画
我们期待看到您使用 Compose 构建的内容,如需了解更多信息,请参阅:
动画教程:
https://developer.android.google.cn/jetpack/compose/animation
Android 上的 Kotlin 协程:
https://developer.android.google.cn/kotlin/coroutines
推荐阅读