Jetpack Compose 动画

ea1442f28b3c3b39.png

上次更新日期:2021 年 1 月 25 日

在本 Codelab 中,您将学习如何在 Jetpack Compose 中使用一些动画 API。

Jetpack Compose 是新款界面工具包,旨在简化界面开发。如果您不熟悉 Jetpack Compose,不妨在本 Codelab 之前先尝试学习下面这几个 Codelab。

学习内容

  • 如何使用几个基础动画 API
  • 何时使用哪个 API

前提条件

所需条件

下载 Codelab 代码。您可以按如下所示克隆代码库:

$ git clone git@github.com:googlecodelabs/android-compose-codelabs.git

或者,您也可以下载相应的 zip 文件

在 Android Studio 中导入 AnimationCodelab 项目。

7a7c10526864d5c2.png

该项目包含多个模块:

  • start 是本 Codelab 的起始状态。
  • finished 是完成本 Codelab 后应用的最终状态。

请确保下拉菜单中的 start

已针对相应运行配置选中

39b7acb33706a9b.png

在下一章中,我们将处理几个动画场景。在本 Codelab 中,我们使用的每个代码段均标有 // TODO 注释。一个小技巧是在 Android Studio 中打开 TODO 工具窗口,然后浏览该章节的每个 TODO 注释。

c4a2180b956cad9f.png

我们先从 Compose 中最简单的动画 API 着手。

运行 start 配置,然后点击顶部的“Home”和“Work”按钮,尝试切换标签页。这样操作不会真正切换标签页内容,不过您可以看到,内容的背景颜色会发生变化。

点击 TODO 工具窗口中的 TODO 1,了解其实现方式。它位于 Home 可组合项中。

val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300

其中,tabPage 是由 State 对象支持的一项 Int。背景颜色可以在紫色和绿色之间切换,具体取决于其值。我们想为这个值的变化添加动画效果。

如需为诸如此类的简单值变化添加动画效果,我们可以使用 animate*AsState API。只需使用 animate*AsState 可组合项的相应变体(在本例中为 animateColorAsState)封装更改值,即可创建动画值。返回的值是 State<T> 对象,因此我们可以使用包含 by 声明的本地委托属性,以将该值视为普通变量。

val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)

重新运行应用并尝试切换标签页。现在颜色变化会呈现动画效果。

6946feb47acc2cc6.gif

如果您滚动应用内容,会发现悬浮操作按钮按照滚动方向而展开和缩小。

找到 TODO 2-1 并查看这背后的机制。它位于 HomeFloatingActionButton 可组合项中。使用 if 语句显示或隐藏表示“EDIT”的文本。

if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

为此项可见性变化添加动画效果非常简单,只需将 if 替换为 AnimatedVisibility 可组合项即可。

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

现在运行应用,了解悬 FAB 如何展开和缩小。

37a613b87156bfbe.gif

每次指定的 Boolean 值发生变化时,AnimatedVisibility 会运行其动画。默认情况下,AnimatedVisibility 会以淡入和展开的方式显示元素,以淡出和缩小的方式隐藏元素。此行为对于使用 FAB 的本示例而言非常适用,不过我们也可以自定义行为。

尝试点击 FAB,您应该会看到一条内容为“Edit feature is not supported”的消息。它还使用 AnimatedVisibility 为其外观和消失添加动画效果。我们来看看如何自定义此动画,从而让元素从顶部滑入,然后滑出到顶部。

11d77a9c6af0309c.png

找到 TODO 2-2 并查看 EditMessage 可组合项中的代码。

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

如需自定义动画,请将 enterexit 参数添加到 AnimatedVisibility 可组合项中。

enter 参数应该是 EnterTransition 的实例。对于本示例,我们可以使用 slideInVertically 函数创建 EnterTransition。此函数可使用 initialOffsetYanimationSpec 参数进一步自定义。initialOffsetY 应该是返回初始位置的 lambda。lambda 会收到一个表示元素高度的参数,因此我们只需返回其负值即可。使用 slideInVertically 时,滑入后的目标偏移量始终为 0(像素)。可使用 lambda 函数将 initialOffsetY 指定为绝对值或元素全高度的百分比。

animationSpec 是包括 EnterTransitionExitTransition在内的许多动画 API 的通用参数。我们可以传递各种 AnimationSpec 类型中的一种,以指定动画值应如何随时间变化。在本示例中,我们使用基于时长的简单 AnimationSpec。它可以使用 tween 函数创建。时长为 150 毫秒,加/减速选项为 LinearOutSlowInEasing

同样,我们可以对 exit 参数使用 slideOutVertically 函数。slideOutVertically 假定初始偏移量为 0,因此只需指定 targetOffsetY。我们对 animationSpec 参数使用相同的 tween 函数,但时长为 250 毫秒,加/减速选项为 FastOutLinearInEasing

生成的代码应如下所示。

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

运行应用,然后再次点击 FAB。您会看到,消息现在是从顶部滑入和滑出。

76895615b43b9263.gif

该应用会在内容中显示多个主题。尝试点击其中一个主题,此时系统会打开并显示该主题的正文。当正文显示或隐藏时,包含文本的卡片会展开或缩小。

查看 TopicRow 可组合项中 TODO 3 的代码。

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... the title and the body
}

此处的这个 Column 可组合项会在内容发生变化时更改其大小。我们可以添加 animateContentSize 修饰符,为其大小变化添加动画效果。

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... the title and the body
}

运行应用,然后点击其中一个主题。您可以看到,该主题展开和缩小时具有动画效果。

c0ad7381779fcb09.gif

现在我们已经熟悉一些基本的动画 API,接下来我们来了解一下 Transition API。借助该 API,我们可以制作更复杂的动画。在本示例中,我们自定义了标签页指示器。它是当前所选标签页上显示的一个矩形。

HomeTabIndicator 可组合项中找到 TODO 4,查看标签页指示器的实现方式。

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) Purple700 else Green800

其中,indicatorLeft 表示标签页行中指示器左侧边缘的水平位置。indicatorRight 表示指示器右侧边缘的水平位置。颜色也在紫色和绿色之间变化。

如需同时为多个值添加动画效果,可使用 TransitionTransition 可使用 updateTransition 函数创建。将当前所选标签页的索引作为 targetState 参数传递。

每个动画值都可以使用 Transitionanimate* 扩展函数进行声明。在本示例中,我们使用 animateDpanimateColor。它们会接受一个 lambda 块,我们可以为每个状态指定目标值。我们已经知道它们的目标值应该是什么,所以只需按如下所示封装这些值。请注意,我们可以使用 by 声明并在此时再次将其设为本地委托属性,因为 animate* 函数会返回 State 对象。

val transition = updateTransition(tabPage)
val indicatorLeft by transition.animateDp { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

现在运行应用,您会发现标签页切换现在更有趣了。点击标签页会更改 tabPage 状态的值,这时与 transition 关联的所有动画值会开始以动画方式切换至为目标状态指定的值。

3262270d174e77bf.gif

此外,我们可以指定 transitionSpec 参数来自定义动画行为。例如,我们可以让靠近目标页面的一边比另一边移动得更快来实现指示器的弹性效果。可以在 transitionSpec lambda 中使用 isTransitioningTo infix 函数来确定状态变化的方向。

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

再次运行应用,并尝试切换标签页。

2ad4adbefce04ae2.gif

Android Studio 支持在 Compose 预览中检查过渡效果。如需使用动画预览,请在预览中点击可组合项右上角的“Start interactive mode”图标,以开始交互模式。如果您找不到该图标,则应按照此处的说明,在实验设置中启用此功能。尝试点击 PreviewHomeTabBar 可组合项的图标。然后点击交互模式右上角的“Start animation inspection”图标。系统随即会打开一个新的“Animations”窗格。

您可以点击“Play”图标按钮来播放动画,也可以拖动拖动条来查看各个动画帧。为了更好地描述动画值,可在 updateTransitionanimate* 方法中指定 label 参数。

2d3c5020ae28120b.png

尝试点击当前气温旁边的刷新图标按钮。应用开始加载最新天气信息(它会假装加载)。在加载完成之前,您会看到加载指示器,即一个灰色圆圈和一个条形。我们来为该指示器的 Alpha 值添加动画效果,以便更清楚地呈现该进程正在进行。

c2912ddc2d73bdfc.png

LoadingRow 可组合项中找到 TODO 5

val alpha = 1f

我们希望将此值设为在 0f 和 1f 之间重复呈现动画效果。为此,可以使用 InfiniteTransition。此 API 与上一部分中的 Transition API 类似。两者都是为多个值添加动画效果,但 Transition 会根据状态变化为值添加动画效果,而 InfiniteTransition 则无限期地为值添加动画效果。

如需创建 InfiniteTransition,请使用 rememberInfiniteTransition 函数。然后,可以使用 InfiniteTransition 的一个 animate* 扩展函数声明每个动画值变化。在本例中,我们要为 Alpha 值添加动画效果,所以使用 animatedFloatinitialValue 参数应为 0f,而 targetValue 应为 1f。我们还可以为此动画指定 AnimationSpec,但此 API 仅接受 InfiniteRepeatableSpec。请使用 infiniteRepeatable 函数创建一个。此 AnimationSpec 会封装任何基于时长的 AnimationSpec,使其可重复。例如,生成的代码应如下所示。

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    )
)

运行应用,然后尝试点击刷新按钮。现在,您可以看到加载指示器会显示动画效果。

ca4d1d5bfe87b2a9.gif

在此最后一部分中,我们将学习如何基于触控输入运行动画。在这种情况下,需要考虑几个独特的因素。首先,任何正在播放的动画都可能会被触摸事件拦截。其次,动画值可能不是唯一的可信来源。换句话说,我们可能需要将动画值与来自触摸事件的值同步。

swipeToDismiss 修饰符中找到 TODO 6-1。现在我们尝试创建一个修饰符,以使触摸时元素可滑动。当元素被快速滑动到屏幕边缘时,我们将调用 onDismissed 回调,以便移除该元素。

Animatable 是我们目前看到的最低级别的 API。它有一些对手势场景非常有用的功能,所以我们可以创建一个 Animatable 实例,并使用它表示可滑动元素的水平偏移量。

val offsetX = remember { Animatable(0f) } // Add this line
pointerInput {
    // Used to calculate a settling position of a fling animation.
    val decay = splineBasedDecay<Float>(this)
    // Wrap in a coroutine scope to use suspend functions for touch events and animation.
    coroutineScope {
        while (true) {
            // ...

TODO 6-2 是我们刚刚收到向下轻触事件的位置。如果动画当前正在运行,我们应将其拦截。可以通过对 Animatable 调用 stop 来实现此目的。请注意,如果动画未运行,系统会忽略该调用。

// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

TODO 6-3 位置,我们不断接收到拖动事件。必须将触摸事件的位置同步到动画值中。为此,我们可以对 Animatable 使用 snapTo

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    launch {
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)
    // Consume the gesture event, not passed to external
    change.consumePositionChange()
}

TODO 6-4 是元素刚刚被松开和快速滑动的位置。我们需要计算快速滑动操作的最终位置,以便确定是要将元素滑回原始位置,还是滑开元素并调用回调。

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

TODO 6-5 位置,我们将开始播放动画。但在此之前,我们需要为 Animatable 设置值的上下界限,使其在到达界限时立即停止。借助 pointerInput 修饰符,我们可以通过 size 属性访问元素的大小,因此我们可以使用它获取界限。

offsetX.updateBounds(
    lowerBound = -size.width.toFloat(),
    upperBound = size.width.toFloat()
)

最终,我们可以在 TODO 6-6 位置开始播放动画。我们首先来比较之前计算的快速滑动操作的最终位置以及元素的大小。如果最终位置低于该大小,则表示快速滑动的速度不够。可使用 animateTo 将值的动画效果设置回 0f。否则,我们可以使用 animateDecay 来开始播放快速滑动动画。当动画结束(很可能是到达我们之前设置的界限)时,我们可以调用回调。

launch {
    if (targetOffsetX.absoluteValue <= size.width) {
        // Not enough velocity; Slide back.
        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
    } else {
        // Enough velocity to slide away the element to the edge.
        offsetX.animateDecay(velocity, decay)
        // The element was swiped away.
        onDismissed()
    }
}

最后,我们来了解 TODO 6-7。我们已设置所有动画和手势,因此,请记得对元素应用偏移。

.offset { IntOffset(offsetX.value.roundToInt(), 0) }

完成本部分后,您将得到如下所示的代码:

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This `Animatable` stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the `Animatable` value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

请运行应用,并尝试滑动某个任务项。您会看到该元素要么滑回默认位置,要么滑开,然后被移除,具体取决于快速滑动的速度。您还可以在添加动画效果时捕获元素。

7cdefce823f6b9bd.png

恭喜!您已经学习了基本的 Compose 动画 API。

我们已学习如何使用诸如 animateContentSizeAnimatedVisibility 之类的高级动画 API 构建几种常见的动画模式。我们还了解到,可以使用 animate*AsState 为单个值添加动画效果,使用 updateTransition 为多个值添加动画效果,以及使用 infiniteTransition 为多个值无限期地添加动画效果。我们还使用 Animatable 构建了与触摸手势相结合的自定义动画。

接下来做什么?

请查看 Compose 衔接课程中的其他 Codelab。

如需了解详情,请参阅 Compose 动画和以下参考文档: