在 Jetpack Compose 中为元素设置动画

1. 简介

Jetpack Compose Logo

上次更新 2023-11-21

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

Jetpack Compose 是一款旨在简化 UI 开发的现代 UI 工具包。如果您不熟悉 Jetpack Compose,可以先尝试几个 Codelab,然后再尝试这个。

您将学到什么

  • 如何使用几个基本的动画 API

先决条件

您需要什么

2. 设置

下载 Codelab 代码。您可以按如下方式克隆存储库

$ git clone https://github.com/android/codelab-android-compose.git

或者,您可以将存储库下载为 zip 文件

导入 Android Studio 中的AnimationCodelab 项目。

Importing Animation Codelab into Android Studio

该项目包含多个模块

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

确保在运行配置的下拉列表中选择了start

Showing start selected in Android Studio

我们将在下一章开始处理几个动画场景。我们在本 Codelab 中处理的每个代码片段都用// TODO 注释标记。一个巧妙的技巧是在 Android Studio 中打开 TODO 工具窗口,然后导航到本章的每个 TODO 注释。

TODO list shown in Android Studio

3. 为简单的值更改设置动画

让我们从 Compose 中最简单的动画 API 之一入手:animate*AsState API。当为State 更改设置动画时,应使用此 API。

运行start 配置,然后尝试通过点击顶部的“首页”和“工作”按钮来切换标签。它实际上并没有切换标签内容,但您可以看到内容的背景颜色发生了变化。

Home Tab Selected

Work Tab Selected

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

val backgroundColor = if (tabPage == TabPage.Home) Seashell else GreenLight

这里,tabPage 是一个由State 对象支持的TabPage。根据其值,背景颜色会在桃色和绿色之间切换。我们希望为这个值更改设置动画。

为了为像这样的简单值更改设置动画,我们可以使用animate*AsState API。您可以通过使用animate*AsState 可组合项(在本例中为animateColorAsState)的相应变体来包装更改的值来创建动画值。返回值是一个State<T> 对象,因此我们可以使用带有by 声明的局部委托属性将其视为普通变量。

val backgroundColor by animateColorAsState(
        targetValue = if (tabPage == TabPage.Home) Seashell else GreenLight,
        label = "background color")

重新运行应用并尝试切换标签。颜色更改现在已设置动画。

Color Change animation in action between tabs

4. 为可见性设置动画

如果您滚动应用的内容,您会注意到浮动操作按钮会根据滚动的方向进行展开和缩小。

Edit Floating action button expanded

Edit Floating action button small

找到TODO 2-1 并查看其工作原理。它位于HomeFloatingActionButton 可组合项中。“编辑”文本是使用if 语句显示或隐藏的。

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

为这个可见性更改设置动画就像用AnimatedVisibility 可组合项替换if 一样简单。

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

运行应用,查看 FAB 如何展开和缩小。

Floating action edit button animation

AnimatedVisibility 每当指定的Boolean 值发生变化时都会运行其动画。默认情况下,AnimatedVisibility 通过淡入和展开元素来显示元素,并通过淡出和缩小来隐藏元素。这种行为非常适合此 FAB 示例,但我们也可以自定义行为。

尝试点击 FAB,您应该会看到一条消息,提示“不支持编辑功能”。它还使用AnimatedVisibility 为其外观和消失设置动画。接下来,您将自定义此行为,以便消息从顶部滑入,并从顶部滑出。

Message detailing that the edit feature is not supported

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

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

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

enter 参数应为EnterTransition 的实例。对于此示例,我们可以使用slideInVertically 函数来创建EnterTransition,并使用slideOutVertically 来创建退出转换。请按如下方式更改代码

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(),
    exit = slideOutVertically()
)

再次运行应用,点击编辑按钮,您可能会注意到动画看起来更好,但并不完全正确,这是因为slideInVerticallyslideOutVertically 的默认行为使用项目高度的一半。

Slide out vertically cuts off halfway

对于进入转换:我们可以调整默认行为,以便使用项目的整个高度来正确设置动画,方法是设置initialOffsetY 参数。该initialOffsetY 应为返回初始位置的 lambda。

lambda 接收一个参数,即元素的高度。为了确保项目从屏幕顶部滑入,我们返回其负值,因为屏幕顶部值为 0。我们希望动画从-height0(其最终静止位置)开始,以便它从上方开始并动画进入。

使用slideInVertically 时,滑入后的目标偏移量始终为0(像素)。initialOffsetY 可以指定为绝对值,也可以通过 lambda 函数指定为元素完整高度的百分比。

类似地,slideOutVertically 假设初始偏移量为 0,因此只需要指定targetOffsetY

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

再次运行应用,我们可以看到动画更符合我们的预期

Slide in animation with offset working

我们可以使用animationSpec 参数进一步自定义我们的动画。 animationSpec 是许多动画 API(包括EnterTransitionExitTransition)的常用参数。我们可以传递各种AnimationSpec 类型来指定动画值随时间推移的变化方式。在此示例中,让我们使用一个简单的基于持续时间的AnimationSpec。可以使用tween 函数创建它。持续时间为 150 毫秒,缓动为LinearOutSlowInEasing。对于退出动画,让我们对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.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

运行应用并再次点击 FAB。您可以看到消息现在使用不同的缓动函数和持续时间从顶部滑入和滑出

Animation showing the edit message sliding in from the top

5. 为内容大小更改设置动画

该应用在内容中显示了几个主题。尝试点击其中一个,它应该会打开并显示该主题的正文文本。包含文本的卡片在显示或隐藏正文时会展开和收缩。

Collapsed topic list

Topic list expanded

查看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
}

运行应用并点击其中一个主题。您可以看到它会随着动画展开和收缩。

Topic list expanded and collapse animation

animateContentSize 也可以使用自定义animationSpec 进行自定义。我们可以提供选项来更改动画类型,例如从弹簧更改为补间等。有关更多信息,请参阅自定义动画文档

6. 为多个值设置动画

现在我们已经熟悉了一些基本的动画 API,让我们看看Transition API,它允许我们创建更复杂的动画。使用Transition API 允许我们跟踪Transition 上所有动画何时完成,这在使用我们之前看到的单个animate*AsState API 时是不可能的。该Transition API 还允许我们在不同状态之间转换时定义不同的transitionSpec。让我们看看如何使用它

在此示例中,我们自定义了选项卡指示器。它是在当前选定的选项卡上显示的矩形。

Home tab selected

Work tab selected

HomeTabIndicator 可组合项中找到TODO 4,并查看选项卡指示器是如何实现的。

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

这里,indicatorLeft 是选项卡行中指示器左边缘的水平位置。 indicatorRight 是指示器右边缘的水平位置。颜色也在桃色和绿色之间变化。

为了同时对这些多个值进行动画处理,我们可以使用 Transition。可以使用 updateTransition 函数创建 Transition。将当前选定的选项卡的索引作为 targetState 参数传递。

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

val transition = updateTransition(tabPage, label = "Tab indicator")
val indicatorLeft by transition.animateDp(label = "Indicator left") { page ->
   tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(label = "Indicator right") { page ->
   tabPositions[page.ordinal].right
}
val color by transition.animateColor(label = "Border color") { page ->
   if (page == TabPage.Home) PaleDogwood else Green
}

现在运行应用程序,您会发现选项卡切换现在变得更有意思了。当点击选项卡更改 tabPage 状态的值时,与 transition 关联的所有动画值都开始动画到为目标状态指定的值。

Animation between home and work tabs

此外,我们可以指定 transitionSpec 参数来自定义动画行为。例如,我们可以通过让更靠近目标边缘的边缘移动速度快于另一边缘来实现指示器的弹性效果。我们可以在 transitionSpec lambda 中使用 isTransitioningTo 中缀函数来确定状态更改的方向。

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) PaleDogwood else Green
}

再次运行应用程序并尝试切换选项卡。

Custom elastic effect on tab switching

Android Studio 支持在 Compose 预览中检查 Transition。要使用**动画预览**,请点击预览中 Composable 右上角的“启动动画预览”图标(动画预览图标 图标)。尝试点击 PreviewHomeTabBar 可组合项的图标。这将打开一个新的“动画”窗格。

您可以点击“播放”图标按钮运行动画。您还可以拖动滑块以查看每个动画帧。为了更好地描述动画值,您可以在 updateTransitionanimate* 方法中指定 label 参数。

Seek animations in Android Studio

7. 重复动画

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

Static image of placeholder info card that is not animated yet.

LoadingRow 可组合项中找到**TODO 5**。

val alpha = 1f

我们希望使该值在 0f 和 1f 之间重复动画。为此,我们可以使用 InfiniteTransition。此 API 类似于上一节中的 Transition API。它们都对多个值进行动画处理,但 Transition 基于状态更改对值进行动画处理,而 InfiniteTransition 无限期地对值进行动画处理。

要创建 InfiniteTransition,请使用 rememberInfiniteTransition 函数。然后,可以使用 InfiniteTransitionanimate* 扩展函数之一声明每个动画值更改。在这种情况下,我们正在对 alpha 值进行动画处理,因此让我们使用 animatedFloatinitialValue 参数应为 0f,而 targetValue1f。我们还可以为此动画指定 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
    ),
    label = "alpha"
)

默认的 repeatModeRepeatMode.Restart。它从 initialValue 过渡到 targetValue,然后从 initialValue 重新开始。通过将 repeatMode 设置为 RepeatMode.Reverse,动画将从 initialValue 发展到 targetValue,然后从 targetValue 发展到 initialValue。动画从 0 到 1,然后从 1 到 0。

keyFrames 动画是另一种类型的 animationSpec(其他一些是 tweenspring),它允许在不同的毫秒数下更改正在进行的值。我们最初将 durationMillis 设置为 1000 毫秒。然后我们可以在动画中定义关键帧,例如,在动画的 500 毫秒处,我们希望 alpha 值为 0.7f。这将改变动画的进程:它将在动画的 500 毫秒内从 0 快速发展到 0.7,并在动画的 500 毫秒到 1000 毫秒内从 0.7 发展到 1.0,在结束时减速。

如果我们想要多个关键帧,我们可以定义多个 keyFrames,如下所示

animation = keyframes {
   durationMillis = 1000
   0.7f at 500
   0.9f at 800
}

运行应用程序并尝试点击刷新按钮。您现在可以看到加载指示器正在动画。

Repeating animated placeholder content

8. 手势动画

在本节中,我们将了解如何根据触摸输入运行动画。我们将从头开始构建一个 swipeToDismiss 修饰符。

swipeToDismiss 修饰符中找到**TODO 6-1**。在这里,我们试图创建一个修饰符,使元素可以通过触摸滑动。当元素被甩到屏幕边缘时,我们调用 onDismissed 回调,以便可以删除该元素。

要构建一个 swipeToDismiss 修饰符,我们需要了解一些关键概念。首先,用户将手指放在屏幕上,生成一个具有 x 和 y 坐标的触摸事件,然后他们会将手指向右或向左移动 - 根据他们的移动来移动 x 和 y。他们正在触摸的项目需要随他们的手指移动,因此我们将根据触摸事件的位置和速度更新项目的位置。

我们可以使用 Compose 手势文档 中描述的一些概念。使用 pointerInput 修饰符,我们可以获取传入指针触摸事件的低级访问权限,并使用相同的指针跟踪用户拖动的速度。如果他们在项目超过解除操作边界之前松开手指,则项目将弹回原位。

在这种情况下,有几个独特的事项需要考虑。首先,任何正在进行的动画都可能被触摸事件拦截。其次,动画值可能不是唯一的真相来源。换句话说,我们可能需要将动画值与来自触摸事件的值同步。

Animatable 是我们迄今为止看到的最低级别的 API。它具有在手势场景中非常有用的几个特性,例如能够立即捕捉来自手势的新值并停止任何正在进行的动画,当触发新的触摸事件时。让我们创建一个 Animatable 的实例,并用它来表示可滑动元素的水平偏移量。请确保从 androidx.compose.animation.core.Animatable 而不是 androidx.compose.animation.Animatable 导入 Animatable

val offsetX = remember { Animatable(0f) } // Add this line
// used to receive user touch events
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 来完成。请注意,如果动画未运行,则会忽略该调用。 VelocityTracker 将用于计算用户从左到右移动的速度。 awaitPointerEventScope 是一个挂起函数,可以等待用户输入事件并对其做出响应。

// Wait for a touch down event. Track the pointerId based on the touch
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line to cancel any on-going animations
// Prepare for drag events and record velocity of a fling gesture
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

在**TODO 6-3** 中,我们正在连续接收拖动事件。我们必须将触摸事件的位置同步到动画值中。我们可以为此在 Animatable 上使用 snapTosnapTo 必须在另一个 launch 块中调用,因为 awaitPointerEventScopehorizontalDrag 是受限的协程作用域。这意味着它们只能 suspend 用于 awaitPointerEventssnapTo 不是指针事件。

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    // Get the drag amount change to offset the item with
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    // Need to call this in a launch block in order to run it separately outside of the awaitPointerEventScope
    launch {
        // Instantly set the Animable to the dragOffset to ensure its moving
        // as the user's finger moves
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)

    // Consume the gesture event, not passed to external
    if (change.positionChange() != Offset.Zero) change.consume()

}

**TODO 6-4** 是元素刚刚被释放并甩出的地方。我们需要计算弹动最终稳定到的位置,以便确定我们是否应该将元素滑回原始位置,或者将其滑走并调用回调。我们使用之前创建的 decay 对象来计算 targetOffsetX

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line to calculate where it would end up with
// the current velocity and position
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

TODO 6-5处,我们即将开始动画。但在那之前,我们想为Animatable设置上下限值,以便它在达到边界时停止(-size.widthsize.width,因为我们不希望offsetX能够超出这两个值)。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) }
}

运行应用程序并尝试滑动其中一个任务项。您可以看到,元素要么滑回默认位置,要么根据抛掷速度滑开并被移除。您还可以在元素动画时捕捉它。

Gesture Animation swipe to dismiss items

9. 恭喜!

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

在本 Codelab 中,我们学习了如何使用

高级动画 API

  • animatedContentSize
  • AnimatedVisibility

低级动画 API

  • animate*AsState 用于动画化单个值
  • updateTransition 用于动画化多个值
  • infiniteTransition 用于无限动画化值
  • Animatable 用于使用触摸手势构建自定义动画

下一步是什么?

查看 Compose 学习路径上的其他 Codelab:Compose 学习路径

要了解更多信息,请参阅Compose 动画以及这些参考文档