查看原文
其他

使用 Jetpack Compose 实现精美动画

Android Android 开发者 2022-08-18

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


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


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

△ 使用 Jetpack Compose 实现精美动画
  • 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,这些过渡动画效果如下:

△ 进入动画演示 (如左) 和退出动画演示 (如右)

我们还提供了更多过渡动画选项,您可以在文档中查看完整列表:

https://developer.android.google.cn/jetpack/compose/animation


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。

△ animateDpAsState 动画的中断效果

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 = truefraction.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 构建动画 API 的过程中,我们收到了很多来自社区的反馈。这些反馈帮助我们打造出直观又实用的 API,我们非常感谢大家所有的反馈,欢迎继续提出。

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

  • 动画教程:

    https://developer.android.google.cn/jetpack/compose/animation

  • Android 上的 Kotlin 协程:

    https://developer.android.google.cn/kotlin/coroutines


您可以通过下方二维码或在文章底部私信,向我们提交反馈,分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!




推荐阅读

如页面未加载,请刷新重试

 点击屏末 | 阅读原文 | 即刻了解更多使用 Jetpack Compose 实现动画的内容




对方块的大小和颜色同时进行

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存