Jetpack Compose(第六趴)——在 Jetpack Compose 中为元素添加动画效果

617 阅读14分钟

一、为简单的值变化添加动画效果

我们先来看看Compose中最简单的动画API直译,即animate*AsStateAPI。在为State更改添加动画效果时,应使用此API。

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

image.png

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)

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

1.gif

二、为可见性添加动画效果

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

image.png

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如何展开和缩小。

2.gif

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

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

image.png

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函数为退出过渡创建EnterTransitionslideOutVertically。更改代码,如下所示:

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

再次运行应用后,点击"Edit"按钮时,您可能会注意到动画效果变得更好了,但其实并不完全符合预期,这是因为slildeInVerticallyslideOutVertically的默认行为只使用项高度的一半。

3.gif

对于进入过渡:我们可以通过设置initialOffsetY参数来调整默认行为,以便使用项的完整高度来正确添加动画效果。initialOffsetY应该是返回初始位置的lambda。

lambda会收到一个表示元素高度的参数。为确保项从屏幕顶部滑入,我们会返回其赋值,因为屏幕顶部的值为0.我们希望动画从-height开始到0(其最终精致位置),以便其从屏幕上方开始以动画形式滑入。

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

同样,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.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

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

4.gif

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

最终的代码应如下所示:

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 -fullHeigh.
        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。您可以看到,消息现在通过不同的加/减速函数和时长从顶部滑入和滑出。

8.gif

## 三、为内容大小变化添加动画效果 该应用会在内容中显示多个主题。尝试点击其中一个主题,此时系统会打开并显示该主题的征文。当征文显示或隐藏时,包含文本的卡片会展开或缩小。

image.png

查看TopicRow代码:

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
}

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

2.gif

你也可以使用自定义animationSpec来自定义animateContentSize。我们可以提供将动画类型从弹簧动画变更为补间动画等操作的选项。

四、为多个值添加动画效果

现在我们已经熟悉一些基本的动画API,接下来我们来了解一下TransitionAPI。借助该API,我们可以制作更复杂的动画。我们可以使用TransitionAPI跟踪Transition上的所有动画何时完成,而使用前述各个animate*AsStateAPI却无法做到这一点。TransitionAPI还让我们能够在不同状态之间转换时定义不同的transitionSpec

在本实例中,我们定义了标签页指示器。它是当前所选标签页上显示的一个矩形。

image.png

HomeTabIndicator代码中:

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

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

如需同时为多个添加动画效果,可使用Transition。可使用updateTransition函数创建Transition。将当前所选标签页的索引作为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.oridinal].right
}
val color by transition.animateColor(label = "Border color") { page -> 
    if (page == TabPage.Home) Purple700 else Green800
}

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

3.gif

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

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 move slower than the right egde.
            spring(stiffness = Sprinf.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
            sprint(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left
            // The right edge moves slower than the left edge
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator rignt"
) { page -> 
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

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

4.gif

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

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

5.gif

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

image.png

val alpha = 1f

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

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

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

repeatMode的默认值为RepeatMode.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
}

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

7.gif

## 六、手势动画 在此最后一部分中,我们将学习如何基于触控输入运行动画。我们将从头开始构建`swipeToDismiss`修饰符。

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

如需构建swipeToDismiss修饰符,我们需要理解几个关键概念。首先,用户将手指放在屏幕上会生成包含x和y坐标的触摸事件,随后用户将手指向右或向左移动,x和y坐标也会随之移动。用户需要通过移动手指来移动所触摸到项,因此我们将根据触摸事件的位置和速度来更新项的位置。

使用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) {
            // ...
        }
    }
}

我们刚刚收到向下触摸事件的位置。如果动画当前正在运行,我们应将其拦截。可以通过对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 relocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEcentScope {

我们会持续接受到拖动事件。必须将触摸事件位置同步到动画值中。为此,我们可以对Animatable使用snapTo。只能在另一个launch代码块内发起对snapTo的调用。因为awaitPointerEventScopehorizontalDrag是受限的协程范围。也就是说,它们只能对awaitPointerEvents执行suspend,而snapTo并不是指针事件。

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 {
        // Instanly set the Animable to the dragOffset to ensure its moving 
        // as the user's finget 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()
}

下面是元素刚刚被松开和快速滑动的位置。我们需要计算快速滑动操作的最终位置,以便确定是要将元素滑回原始位置,还是滑开元素并调用回调。我们使用之前创建的decay对象来计算targgetOffsetX:

// 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.calcutateTargetValue(offserX.value, velocity)

我们将开始播放动画,但在此之前,我们需要为Animatable设置值的上下界限,使其在到达界限时立即停止(即-size.witdhsize.width,因为我们不希望offsetX越过这两个值)。借助pointerInput修饰符,我们可以通过size属性访问元素的大小,因此我们可以使用它获取界限。

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

最终,我们开始播放动画。我们首先比较之前计算的快速滑动操作的最终位置以及元素的大小。如果最终位置低于该大小,则表示快速滑动的速度不够。可使用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()
    }
}

我们已设置所有动画和手势,因此,请记得元素应用偏移。此操作会将屏幕上的元素移至手势或动画生成的值:

.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 { Animatbale(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a floing animation.
        val decay = splineBaseDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend funcions for touch events and animation.
        coroutineScopr {
            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.
                awaitPointerEventScopr {
                    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.calculateCelocity().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) }

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

image.png