在 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 是指示器右边缘的水平位置。颜色也会在桃红色和绿色之间变化。

要同时为这些多个值设置动画,我们可以使用 TransitionTransition 可以使用 updateTransition 函数创建。将当前选定标签的索引作为 targetState 参数传递。

每个动画值都可以使用 Transitionanimate* 扩展函数声明。在此示例中,我们使用 animateDpanimateColor。它们接受 lambda 块,我们可以为每个状态指定目标值。我们已经知道它们的预期目标值是什么,因此我们可以像下面这样包装这些值。请注意,我们也可以再次使用 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参数应为0ftargetValue1f。我们还可以为此动画指定一个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,动画将从initialValuetargetValue,然后从targetValueinitialValue。动画从 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 动画以及这些参考文档