动画

Jetpack Compose 提供了一些功能强大且可扩展的 API,可用于在应用界面中轻松实现各种动画效果。本文档将介绍如何使用这些 API,以及根据您的动画场景应使用哪种 API。

概览

动画在现代移动应用中至关重要,其目的是实现自然流畅、易于理解的用户体验。许多 Jetpack Compose 动画 API 可以提供可组合函数,就像布局和其他界面元素一样;它们由使用 Kotlin 协程挂起函数构建的较低级别 API 提供支持。本指南将首先介绍可用于许多实际场景的高级别 API,接着介绍可为您提供进一步控制和自定义功能的低级别 API。

下面的图表可以帮助您确定要使用哪种 API 来实现您的动画效果。

  • 如果您要为布局中的内容变化添加动画效果:
    • 如果您要为出现和消失添加动画效果:
      • 使用 AnimationVisibility
    • 根据状态交换内容:
      • 如果您要为内容添加淡入淡出效果:
        • 使用 Crossfade
      • 否则使用 AnimatedContent
    • 否则使用 Modifier.contentSize
  • 如果动画效果基于状态:
    • 如果在组合期间呈现动画效果:
      • 如果动画效果无限循环:
        • 使用 rememberInfiniteTransition
      • 如果您要同时为多个值添加动画效果:
        • 使用 updateTransition
      • 否则使用 animate*AsState
  • 如果您要对动画播放时间进行精细控制:
    • 使用 Animation
  • 如果动画是唯一可信来源:
    • 使用 Animatable
  • 否则,请使用 AnimationStateanimate

描述如何选择适当动画 API 的决策树流程图

高级别动画 API

Compose 为许多应用中常用的几种动画模式提供了高级别动画 API。这些 API 经过专门设计,符合 Material Design 运动的最佳做法。

AnimatedVisibility(实验性)

AnimatedVisibility 可组合项可为内容的出现和消失添加动画效果。

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}

默认情况下,内容以淡入和扩大的方式出现,以淡出和缩小的方式消失。您可以通过指定 EnterTransitionExitTransition 来自定义这种过渡效果。

var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically(
        // Slide in from 40 dp from the top.
        initialOffsetY = { with(density) { -40.dp.roundToPx() } }
    ) + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

如上面的示例所示,您可以使用 + 运算符组合多个 EnterTransitionExitTransition 对象,并且每个对象都接受可选参数以自定义其行为。如需了解详情,请参阅相关参考资料。

EnterTransition 个示例

fadeIn

slideIn

slideInHorizontally

slideInVertically

scaleIn

expandIn

expandHorizontally

expandVertically

ExitTransition 个示例

fadeOut

slideOut

slideOutHorizontally

slideOutVertically

scaleOut

shrinkOut

shrinkHorizontally

shrinkVertically

AnimatedVisibility 还提供了接受 MutableTransitionState 的变体。这样,只要将 AnimatedVisibility 添加到组合树中,您就可以立即触发动画。该属性还有助于观察动画状态。

// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
    MutableTransitionState(false).apply {
        // Start the animation immediately.
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }

    // Use the MutableTransitionState to know the current animation state
    // of the AnimatedVisibility.
    Text(
        text = when {
            state.isIdle && state.currentState -> "Visible"
            !state.isIdle && state.currentState -> "Disappearing"
            state.isIdle && !state.currentState -> "Invisible"
            else -> "Appearing"
        }
    )
}

为子项添加进入和退出动画效果

AnimatedVisibility(直接或间接子项)中的内容可以使用 animateEnterExit 修饰符为每个子项指定不同的动画行为。其中每个子项的视觉效果均由 AnimatedVisibility 可组合项中指定的动画与子项自己的进入和退出动画构成。

AnimatedVisibility(
    visible = visible,
    // Fade in/out the background and the foreground.
    enter = fadeIn(),
    exit = fadeOut()
) {
    Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
        Box(
            Modifier
                .align(Alignment.Center)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                .background(Color.Red)
        ) {
            // Content of the notification…
        }
    }
}

在某些情况下,您可能希望 AnimatedVisibility 完全不应用任何动画,这样子项就可以通过 animateEnterExit 拥有各自的不同动画。如需实现此目标,请在 AnimatedVisibility 可组合项中指定 EnterTransition.NoneExitTransition.None

添加自定义动画

如果您想在内置进入和退出动画之外添加自定义动画效果,请通过 AnimatedVisibility 的内容 lambda 内的 transition 属性访问底层 Transition 实例。添加到 Transition 实例的所有动画状态都将与 AnimatedVisibility 的进入和退出动画同时运行。AnimatedVisibility 会等到 Transition 中的所有动画都完成后再移除其内容。对于独立于 Transition 创建的退出动画(例如,使用 animate*AsState),AnimatedVisibility 将无法解释这些动画,因此可能会在完成之前移除内容可组合项。

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) { // this: AnimatedVisibilityScope
    // Use AnimatedVisibilityScope.transition() to add a custom animation
    // to the AnimatedVisibility.
    val background by transition.animateColor { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
    Box(modifier = Modifier.size(128.dp).background(background))
}

如需了解关于 Transition 的详细信息,请参阅 updateTransition

AnimatedContent(实验性)

AnimatedContent 可组合项在内容根据目标状态发生变化时,添加内容的动画效果。

Row {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(targetState = count) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

请注意,您应始终使用 lambda 参数并将其反映到内容中。API 会将此值用作键,以标识当前显示的内容。

默认情况下,初始内容淡出,然后目标内容淡入(此行为称为淡出后淡入)。您可以为 transitionSpec 参数指定 ContentTransform 对象,以自定义此动画行为。您可以使用 with infix 函数来组合 EnterTransitionExitTransition,以创建 ContentTransform。您可以将 SizeTransform 应用于 ContentTransform,方法是使用 using infix 函数附加该修饰符。

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // Compare the incoming number with the previous number.
        if (targetState > initialState) {
            // If the target number is larger, it slides up and fades in
            // while the initial (smaller) number slides up and fades out.
            slideInVertically({ height -> height }) + fadeIn() with
                slideOutVertically({ height -> -height }) + fadeOut()
        } else {
            // If the target number is smaller, it slides down and fades in
            // while the initial number slides down and fades out.
            slideInVertically({ height -> -height }) + fadeIn() with
                slideOutVertically({ height -> height }) + fadeOut()
        }.using(
            // Disable clipping since the faded slide-in/out should
            // be displayed out of bounds.
            SizeTransform(clip = false)
        )
    }
) { targetCount ->
    Text(text = "$targetCount")
}

EnterTransition 定义了目标内容应如何显示,ExitTransition 则定义了初始内容应如何消失。除了可用于 AnimatedVisibility 的所有 EnterTransitionExitTransition 函数之外,AnimatedContent 还提供了 slideIntoContainerslideOutOfContainer。这些是 slideInHorizontally/VerticallyslideOutHorizontally/Vertically 的便捷替代方案,它们可根据初始内容的大小和 AnimatedContent 内容的目标内容计算滑动距离。

SizeTransform 定义了大小应如何在初始内容与目标内容之间添加动画效果。在创建动画时,您可以访问初始大小和目标大小。SizeTransform 还可控制在动画播放期间是否应将内容裁剪为组件大小。

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colors.primary,
    onClick = { expanded = !expanded }
) {
    AnimatedContent(
        targetState = expanded,
        transitionSpec = {
            fadeIn(animationSpec = tween(150, 150)) with
                fadeOut(animationSpec = tween(150)) using
                SizeTransform { initialSize, targetSize ->
                    if (targetState) {
                        keyframes {
                            // Expand horizontally first.
                            IntSize(targetSize.width, initialSize.height) at 150
                            durationMillis = 300
                        }
                    } else {
                        keyframes {
                            // Shrink vertically first.
                            IntSize(initialSize.width, targetSize.height) at 150
                            durationMillis = 300
                        }
                    }
                }
        }
    ) { targetExpanded ->
        if (targetExpanded) {
            Expanded()
        } else {
            ContentIcon()
        }
    }
}

为子项添加进入/退出动画

就像 AnimatedVisibility 一样,animateEnterExit 修饰符可以在 AnimatedContent 的内容 lambda 内使用。使用此修饰符可将 EnterAnimationExitAnimation 分别应用于每个直接或间接子项。

添加自定义动画

就像 AnimatedVisibility 一样,transition 字段可以在 AnimatedContent 的内容 lambda 内使用。使用此字段可创建与 AnimatedContent 过渡同时运行的自定义动画效果。如需了解详情,请参阅 updateTransition

animateContentSize

animateContentSize 修饰符可为大小变化添加动画效果。

var message by remember { mutableStateOf("Hello") }
Box(
    modifier = Modifier.background(Color.Blue).animateContentSize()
) {
    Text(text = message)
}

Crossfade

Crossfade 可使用淡入淡出动画在两个布局之间添加动画效果。通过切换传递给 current 参数的值,可以使用淡入淡出动画切换内容。

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

低级别动画 API

上一节中提及的所有高级别动画 API 都是在低级别动画 API 的基础上构建而成。

animate*AsState 函数是最简单的 API,可将即时值变化呈现为动画值。它由 Animatable 提供支持,后者是一种基于协程的 API,用于为单个值添加动画效果。updateTransition 可创建过渡对象,用于管理多个动画值,并且根据状态变化运行这些值。rememberInfiniteTransition 与其类似,不过,它会创建一个无限过渡对象,以管理多个无限期运行的动画。所有这些 API 都是可组合项(Animatable 除外),这意味着这些动画可以在非组合期间创建。

所有这些 API 都基于更基础的 Animation API。虽然大多数应用不会直接与 Animation 互动,但 Animation 的某些自定义功能可以通过更高级别的 API 获得。如需详细了解 AnimationVectorAnimationSpec,请参阅自定义动画

各种低级别动画 API 之间的关系图

animate*AsState

animate*AsState 函数是 Compose 中最简单的动画 API,用于为单个值添加动画效果。您只需提供结束值(或目标值),该 API 就会从当前值开始向指定值播放动画。

下面举例说明了如何使用此 API 为 alpha 添加动画效果。您只需将目标值封装在 animateFloatAsState 中,alpha 值就会变成介于所提供的值(在本例中为 1f0.5f)之间的动画值。

val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
    Modifier.fillMaxSize()
        .graphicsLayer(alpha = alpha)
        .background(Color.Red)
)

请注意,您无需创建任何动画类的实例,也不必处理中断。在后台,系统会在调用点创建并记录一个动画对象(即 Animatable 实例),并将第一个目标值设为初始值。此后,只要您为此可组合项提供不同的目标值,系统就会自动开始向该值播放动画。如果已有动画在播放,系统将从其当前值(和速度)开始向目标值播放动画。在播放动画期间,这个可组合项会重组,并返回已更新的每帧动画值。

Compose 为 FloatColorDpSizeOffsetRectIntIntOffsetIntSize 提供开箱即用的 animate*AsState 函数。通过为接受通用类型的 animateValueAsState 提供 TwoWayConverter,您可以轻松添加对其他数据类型的支持。

您可以通过提供 AnimationSpec 来自定义动画规范。如需了解详情,请参阅 AnimationSpec

Animatable

Animatable 是一个值容器,它可以在通过 animateTo 更改值时为值添加动画效果。该 API 支持 animate*AsState 的实现。它可确保一致的连续性和互斥性,这意味着值变化始终是连续的,并且会取消任何正在播放的动画。

Animatable 的许多功能(包括 animateTo)以挂起函数的形式提供。这意味着,它们需要封装在适当的协程作用域内。例如,您可以使用 LaunchedEffect 可组合项针对指定键值的时长创建一个作用域。

// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
    color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))

在上面的示例中,我们创建并记住了初始值为 Color.GrayAnimatable 实例。根据布尔标记 ok 的值,颜色将以动画形式呈现 Color.GreenColor.Red。对该布尔值的任何后续更改都会使动画开始使用另一种颜色。如果更改该值时有正在播放的动画,系统会取消该动画,并且新动画将以当前速度从当前快照值开始播放。

这种动画实现支持上一部分提到的 animate*AsState API。与 animate*AsState 相比,使用 Animatable 可以直接对以下几个方面进行更精细的控制。首先,Animatable 的初始值可以与第一个目标值不同。例如,上面的代码示例首先显示一个灰色框,然后立即开始通过动画呈现为绿色或红色。其次,Animatable 对内容值提供更多操作(即 snapToanimateDecay)。snapTo 可立即将当前值设为目标值。如果动画本身不是唯一的可信来源,且必须与其他状态(如触摸事件)同步,该函数就非常有用。animateDecay 用于启动播放从给定速度变慢的动画。这有助于实现投掷行为。如需了解详情,请参阅手势和动画

AnimatableFloatColor 提供开箱即用的支持,不过,通过提供 TwoWayConverter,可使用任何数据类型。如需了解详情,请参阅 AnimationVector

您可以通过提供 AnimationSpec 来自定义动画规范。如需了解详情,请参阅 AnimationSpec

updateTransition

Transition 可管理一个或多个动画作为其子项,并在多个状态之间同时运行这些动画。

这里的状态可以是任何数据类型。在很多情况下,您可以使用自定义 enum 类型来确保类型安全,如下例所示:

private enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition 可创建并记住 Transition 的实例,并更新其状态。

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)

然后,您可以使用某个 animate* 扩展函数来定义此过渡效果中的子动画。为每个状态指定目标值。这些 animate* 函数会返回一个动画值,在动画播放过程中,当使用 updateTransition 更新过渡状态时,该值将逐帧更新。

val rect by transition.animateRect { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

您也可以传递 transitionSpec 参数,为过渡状态变化的每个组合指定不同的 AnimationSpec。如需了解详情,请参阅 AnimationSpec

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)
            else ->
                tween(durationMillis = 500)
        }
    }
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colors.primary
        BoxState.Expanded -> MaterialTheme.colors.background
    }
}

过渡到目标状态后,Transition.currentState 将与 Transition.targetState 相同。这可以用作指示是否已完成过渡的信号。

有时,我们会希望初始状态与第一个目标状态不同。我们可以通过结合使用 updateTransitionMutableTransitionState 来实现这一点。例如,它允许我们在代码进入组合阶段后立即开始播放动画。

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...

对于涉及多个可组合函数的更复杂的过渡,可使用 createChildTransition 来创建子过渡。此方法对于在复杂的可组合项中分离多个子组件之间的关注点非常有用。父过渡将会知道子过渡中的所有动画值。

enum class DialerState { DialerMinimized, NumberPad }

@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun Dialer(dialerState: DialerState) {
    val transition = updateTransition(dialerState)
    Box {
        // Creates separate child transitions of Boolean type for NumberPad
        // and DialerButton for any content animation between visible and
        // not visible
        NumberPad(
            transition.createChildTransition {
                it == DialerState.NumberPad
            }
        )
        DialerButton(
            transition.createChildTransition {
                it == DialerState.DialerMinimized
            }
        )
    }
}

将 AnimatedVisibility 与 AnimatedContent 配合使用

AnimatedVisibilityAnimatedContent 可用作 Transition 的扩展函数。Transition.AnimatedVisibilityTransition.AnimatedContenttargetState 源自 Transition,会在 TransitiontargetState 发生变化时视需要触发进入/退出过渡。这些扩展函数允许将原本位于 AnimatedVisibilityAnimatedContent 内的所有 enter/exit/sizeTransform 动画提升到 Transition 中。借助这些扩展函数,可以从外部观察 AnimatedVisibility/AnimatedContent 的状态变化。此版本的 AnimatedVisibility 接受一个 lambda,它将父过渡的目标状态转换为布尔值,而不是接受布尔值 visible 参数。

如需了解详情,请参阅 AnimatedVisibilityAnimatedContent

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { isSelected ->
    if (isSelected) 10.dp else 2.dp
}
Surface(
    onClick = { selected = !selected },
    shape = RoundedCornerShape(8.dp),
    border = BorderStroke(2.dp, borderColor),
    elevation = elevation
) {
    Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
        Text(text = "Hello, world!")
        // AnimatedVisibility as a part of the transition.
        transition.AnimatedVisibility(
            visible = { targetSelected -> targetSelected },
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Text(text = "It is fine today.")
        }
        // AnimatedContent as a part of the transition.
        transition.AnimatedContent { targetState ->
            if (targetState) {
                Text(text = "Selected")
            } else {
                Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
            }
        }
    }
}

封装 Transition 并使其可重复使用

对于简单的用例,在与界面相同的可组合项中定义过渡动画是一种非常有效的选择方案。但是,在处理具有大量动画值的复杂组件时,您可能会希望将动画实现与可组合界面分开。

为此,您可以创建一个类来保存所有动画值,同时创建一个“update”函数来返回该类的实例。过渡实现可提取到新的独立函数中。当您需要集中处理动画逻辑或使复杂动画可重复使用时,这种模式很有用。

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
    val transitionData = updateTransitionData(boxState)
    // UI tree
    Box(
        modifier = Modifier
            .background(transitionData.color)
            .size(transitionData.size)
    )
}

// Holds the animation values.
private class TransitionData(
    color: State<Color>,
    size: State<Dp>
) {
    val color by color
    val size by size
}

// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
    val transition = updateTransition(boxState)
    val color = transition.animateColor { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

工具支持

Android Studio 支持在 Compose 预览中检查过渡效果。

  • 逐帧预览过渡效果
  • 检查过渡效果中所有动画的值
  • 预览任何初始状态与目标状态之间的过渡

启动动画检查器时,您会在互动预览下方看到“Animations”窗格,您可以在其中运行预览中包含的任何过渡效果。过渡效果及其每个动画值均标有默认名称。您可以通过在 updateTransitionanimate* 函数中指定 label 参数来自定义标签。如需详细了解 Compose 预览,请参阅布局预览

rememberInfiniteTransition

InfiniteTransition 可以像 Transition 一样保存一个或多个子动画,但是,这些动画一进入组合阶段就开始运行,除非被移除,否则不会停止。您可以使用 rememberInfiniteTransition 创建 InfiniteTransition 实例。可以使用 animateColoranimatedFloatanimatedValue 添加子动画。您还需要指定 infiniteRepeatable 以指定动画规范。

val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
    initialValue = Color.Red,
    targetValue = Color.Green,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    )
)

Box(Modifier.fillMaxSize().background(color))

TargetBasedAnimation

TargetBasedAnimation 是我们目前看到的最低级别的动画 API。其他 API 可满足大多数用例的需要,但使用 TargetBasedAnimation 可以直接让您自己控制动画的播放时间。在下面的示例中,TargetAnimation 的播放时间将根据 withFrameMillis 提供的帧时间手动控制。

val anim = remember {
    TargetBasedAnimation(
        animationSpec = tween(200),
        typeConverter = Float.VectorConverter,
        initialValue = 200f,
        targetValue = 1000f
    )
}
var playTime by remember { mutableStateOf(0L) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }

    do {
        playTime = withFrameNanos { it } - startTime
        val animationValue = anim.getValueFromNanos(playTime)
    } while (someCustomCondition())
}

自定义动画

很多动画 API 通常接受用于自定义其行为的参数。

AnimationSpec

大多数动画 API 允许开发者通过可选的 AnimationSpec 参数来自定义动画规范。

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    // Configure the animation duration and easing.
    animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)

开发者可以使用不同类型的 AnimationSpec 来创建不同类型的动画。

spring

spring 可在起始值和结束值之间创建基于物理特性的动画。它接受 2 个参数:dampingRatiostiffness

dampingRatio 定义弹簧的弹性。默认值为 Spring.DampingRatioNoBouncy

展示不同阻尼系数的行为的动画图片

stiffness 定义弹簧应向结束值移动的速度。默认值为 Spring.StiffnessMedium

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

相比基于时长的 AnimationSpec 类型,spring 可以更流畅地处理中断,因为它可以在目标值在动画中变化时保证速度的连续性。spring 用作很多动画 API(如 animate*AsStateupdateTransition)的默认 AnimationSpec。

tween

tween 在指定的 durationMillis 内使用缓和曲线在起始值和结束值之间添加动画效果。如需了解详情,请参阅 Easing。您还可以指定 delayMillis 来推迟动画播放的开始时间。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)

keyframes

keyframes 会根据在动画时长内的不同时间戳中指定的快照值添加动画效果。在任何给定时间,动画值都将插值到两个关键帧值之间。对于其中每个关键帧,您都可以指定 Easing 来确定插值曲线。

您可以选择在 0 毫秒和持续时间处指定值。如果不指定这些值,它们将分别默认为动画的起始值和结束值。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

repeatable

repeatable 反复运行基于时长的动画(例如 tweenkeyframes),直至达到指定的迭代计数。您可以传递 repeatMode 参数来指定动画是从头开始 (RepeatMode.Restart) 还是从结尾开始 (RepeatMode.Reverse) 重复播放。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = repeatable(
        iterations = 3,
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

infiniteRepeatable

infiniteRepeatablerepeatable 类似,但它会重复无限次的迭代。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

在使用 ComposeTestRule 的测试中,使用 infiniteRepeatable 的动画不会运行。系统将使用每个动画值的初始值来呈现组件。

snap

snap 是特殊的 AnimationSpec,它会立即将值切换到结束值。您可以指定 delayMillis 来延迟动画播放的开始时间。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = snap(delayMillis = 50)
)

Easing

基于时长的 AnimationSpec 操作(如 tweenkeyframes)使用 Easing 来调整动画的小数值。这样可让动画值加速和减速,而不是以恒定的速率移动。小数是介于 0(起始值)和 1.0(结束值)之间的值,表示动画中的当前点。

Easing 实际上是一个函数,它取一个介于 0 和 1.0 之间的小数值并返回一个浮点数。返回的值可能位于边界之外,表示过冲或下冲。您可以使用如下所示的代码创建一个自定义 Easing。

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // … …
}

Compose 提供多种内置 Easing 函数,可满足大多数用例的需要。如需详细了解根据您的情况应使用哪种 Easing,请参阅速度 - Material Design

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

AnimationVector

大多数 Compose 动画 API 都支持将 FloatColorDp 以及其他基本数据类型作为开箱即用的动画值,但有时您需要为其他数据类型(包括您的自定义类型)添加动画效果。在动画播放期间,任何动画值都表示为 AnimationVector。使用相应的 TwoWayConverter 即可将值转换为 AnimationVector,反之亦然,这样一来,核心动画系统就可以统一对其进行处理。例如,Int 表示为包含单个浮点值的 AnimationVector1D。用于 IntTwoWayConverter 如下所示:

val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color 实际上是 red、green、blue 和 alpha 这 4 个值的集合,因此,Color 可转换为包含 4 个浮点值的 AnimationVector4D。通过这种方式,动画中使用的每种数据类型都可以根据其维度转换为 AnimationVector1DAnimationVector2DAnimationVector3DAnimationVector4D。这样可为对象的不同组件单独添加动画效果,且每个组件都有自己的速度跟踪。您可以使用 Color.VectorConverterDp.VectorConverter 等访问针对基本数据类型的内置转换器。

如需支持作为动画值的新数据类型,您可以创建自己的 TwoWayConverter 并将其提供给 API。例如,您可以使用 animateValueAsState 为自定义数据类型添加动画效果,如下所示:

data class MySize(val width: Dp, val height: Dp)

@Composable
fun MyAnimation(targetSize: MySize) {
    val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: MySize ->
                // Extract a float value from each of the `Dp` fields.
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                MySize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

手势和动画(高级)

与单独处理动画相比,当我们处理触摸事件和动画时,必须考虑几个事项。首先,当触摸事件开始时,我们可能需要中断正在播放的动画,因为用户互动应当具有最高优先级。

在下面的示例中,我们使用 Animatable 表示圆形组件的偏移位置。触摸事件由 pointerInput 修饰符处理。当检测到新的点按事件时,我们将调用 animateTo 以将偏移值通过动画过渡到点按位置。在动画播放期间也可能发生点按事件。在这种情况下,animateTo 会中断正在播放的动画,启动动画以过渡到新的目标位置,同时保持被中断的动画的速度。

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

另一种常见模式是需要将动画值与来自触摸事件(例如拖动)的值同步。在下面的示例中,我们会看到以 Modifier 的形式(而不是使用 SwipeToDismiss 可组合项)实现的“滑动关闭”。该元素的水平偏移量表示为 Animatable。此 API 具有可用于手势动画的特征。其值可由触摸事件和动画更改。收到触摸事件时,我们通过 stop 方法停止 Animatable,以便拦截任何正在播放的动画。

在拖动事件期间,我们使用 snapToAnimatable 值更新为从触摸事件计算得出的值。对于投掷动画,Compose 提供 VelocityTracker 来记录拖动事件和计算速度。速度可直接馈送至投掷动画的 animateDecay。如需将偏移值滑回原始位置,可使用 animateTo 方法指定 0f 的目标偏移值。

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

测试

Compose 提供 ComposeTestRule,可让您以确定性的方式编写动画测试,并完全控制测试时钟。这样,您就可以验证中间动画值。此外,测试的运行速度可能比动画的实际时长快。

ComposeTestRule 将其测试时钟公开为 mainClock。您可以将 autoAdvance 属性设置为 false,以控制测试代码中的时钟。启动要测试的动画后,可以使用 advanceTimeBy 将时钟提前。

有一点需要注意,advanceTimeBy 不会按指定时长精确地移动时钟,而是向上舍入为最接近的时长(帧时长的倍数)。

@get:Rule
val rule = createComposeRule()

@Test
fun testAnimationWithClock() {
    // Pause animations
    rule.mainClock.autoAdvance = false
    var enabled by mutableStateOf(false)
    rule.setContent {
        val color by animateColorAsState(
            targetValue = if (enabled) Color.Red else Color.Green,
            animationSpec = tween(durationMillis = 250)
        )
        Box(Modifier.size(64.dp).background(color))
    }

    // Initiate the animation.
    enabled = true

    // Let the animation proceed.
    rule.mainClock.advanceTimeBy(50L)

    // Compare the result with the image showing the expected result.
    // `assertAgainGolden` needs to be implemented in your code.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

了解详情

如需详细了解 Jetpack Compose 中的动画,请参阅下面列出的其他资源:

Codelab

视频