使用 Jetpack Compose 实现精美动画

我们将通过本文介绍 Compose 中的一些动画 API,并探讨如何有效地使用它们。Compose 中的动画 API 是我们构想的全新 API,这些 API 中有许多是声明式的,您可以利用声明式的方式简洁地定义动画。

这些动画 API 支持中断,当运行中的动画被另一个动画打断时,运行中动画的值会带入到新动画中。新 API 简单易用,配置了合理的默认行为,可开箱即用,也可高度定制。同时 Android Studio 还提供了强大的工具,可以帮助您制作复杂动画。

如果您更喜欢通过视频了解此内容,请在此处查看:

△ 使用 Jetpack Compose 实现精美动画

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。animateAsState API 可用于为单个值制作动画,您只需将各种数据类型与 animateAsState 函数组合,即可将其转换为对应的动画值。在本示例中,我们为 dp 值制作动画,所以我们使用 animateDpAsState。

val offsetX by animateDpAsState(
        if (isOn) 512.dp else 0.dp
)

△ 利用 animateDpAsState API 实现的动画效果

△ 利用 animateDpAsState API 实现的动画效果

我们开始时有提到,基于 State 的 API 支持中断。也就是说,如果播放中动画的状态发生变化,新动画将从当前的中间值和速度开始,并基于弹簧的物理效果继续播放。我们将这样的动画行为称为 AnimationSpec。

△ animateDpAsState 动画的中断效果

△ animateDpAsState 动画的中断效果

Spring 是默认的 AnimationSpec。Compose 还提供了其他类型的 AnimationSpec。例如,tween 是基于持续时间的 AnimationSpec,它根据动画由始至终的持续时间来定义运动效果。

△ spring 与 tween 两种 AnimationSpec

△ spring 与 tween 两种 AnimationSpec

我们还提供了其他各种 AnimationSpecs,请参阅文档——动画

我们可以通过下面的例子了解如何为 animate*AsState 指定 AnimationSpec。在这个例子中,我们指定动画的播放时长为三秒钟:

val offsetX by animateDpAsState(
        if (isOn) 512.dp else 0.dp,
        animationSpec = tween(durationMillis = 3000)
)

△ 指定动画播放时长为 3 秒钟

△ 指定动画播放时长为 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 预览动画效果

△ 在 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() 的过程

△ 使用协程执行 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 函数外,下面的示例代码与之前展示的连续动画代码相同,都可以创建新的协程。如前所述,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 所实现的精彩动画

△ 社区利用 Jetpack Compose API 所实现的精彩动画

上面这些动画只是开发者社区创造力的冰山一角。在我们重新构想并为 Compose 构建动画 API 的过程中,我们收到了很多来自社区的反馈。这些反馈帮助我们打造出直观又实用的 API,我们非常感谢大家所有的反馈,欢迎继续提出。

我们期待看到您使用 Compose 构建的内容,如需了解更多信息,请参阅:

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!