使用 Jetpack Compose 实现精美动画

使用 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 构建的内容,如需了解更多信息,请参阅:

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

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
Android Developer | https://androiddevtools.cn and https://androidweekly.io WebMaster | GDG Zhengzhou Funder & Ex Organizer | http://Toast.show(∞) Podcast Host

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
Unable to sign you in. Please try again.
成功!您的帐户已完全激活,您现在可以访问所有内容。
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.