动画
主要学习内容
- 如何使用几个基础动画 API
- 何时使用哪个 API
动画原理
相比于 Compose 中的动画,对于 View 体系中的动画我们更了解一些,比如 View 动画体系中的ObjectAnimator
,其是基于动画过程的计算出的数值调用对应属性的setter
方法,在View的setter
方法中会调用invalidate(true)
进行重绘
ObjectAnimator animator = ObjectAnimator.ofFloat(tv,"alpha",1,0,1); animator.setDuration(2000); animator.start();
ObjectAnimator
内部就会通过反射机制去寻找setAlpha
方法,将动画过程中计算出的数值传递给setAlpha
方法public class View implements Drawable.Callback, KeyEvent.Callback, AccessibilityEventSource { ... public void setAlpha(@FloatRange(from=0.0, to=1.0) float alpha) { ensureTransformationInfo(); if (mTransformationInfo.mAlpha != alpha) { setAlphaInternal(alpha); if (onSetAlpha((int) (alpha * 255))) { mPrivateFlags |= PFLAG_ALPHA_SET; invalidateParentCaches(); invalidate(true); } else { mPrivateFlags &= ~PFLAG_ALPHA_SET; invalidateViewProperty(true, false); mRenderNode.setAlpha(getFinalAlpha()); } } } ... }
在 Compose 中动画原理也差不了多少,都是计算出动画过程中的数值,然后去修改属性值并通知系统重绘该区域
不过 Compose 中使用重组通知系统重新绘画,而重组通常发生在可组合函数使用的状态值发生变化的时候。所以在 Compose 中将动画中以状态方式记录需要变化的值就可以做到通知系统重新绘制组件,然后在协程中计算出要变化的值并改变状态值就可以实现动画效果
所以你可以发现Compose中动画变化的值都是State
基于上述的原理实现的最基础的动画效果,当然 Compose 中动画实现会更复杂、安全和高效
class TestActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Surface { TestAnimation() } } } @Composable fun TestAnimation() { var width by remember { mutableStateOf(80.dp) } Button(onClick = { lifecycleScope.launch { var i = 0 while (i<20) { delay(50) width+=3.dp i++ } } }, modifier = Modifier.width(width)) { Text(text = "开始动画") } } }
准备工作
因为之后的代码都是基于其中的项目进行的,所以还是推荐下载。同时也可以看一下Google人员对于的Compose的代码编写风格
因为代码过多且需要修改资源文件,此处就不将代码写出来了
因为涉及动画效果,运行效果不好进行展示。请一定要自己进行编码,在虚拟机或真机(推荐)上运行查看效果
其实是懒的制作gif (/ω\),笔记中所有的效果图为官网教程中的效果图
该项目包含多个模块:
-
start
是本 Codelab 的起始状态 -
finished
是完成本 Codelab 后应用的最终状态
我们可以选择Import Project
方式进行学习,也可以通过拷贝代码到自己项目中的方式
我使用的是拷贝代码的方式,可能之后跟
Import Project
方式有些区别请谅解
在start
项目中,在每个我们需要修改的代码段前都带有 //TODO
注释,方便我们查找修改位置和修改需求
在Android Studio
中,可以通过左下角的 TODO
工具窗口,然后浏览文件中的每个 TODO
注释
简单值动画
我们先从 Compose 中最简单的动画 API 着手
运行项目,点击顶部的“Home”和“Work”按钮,尝试切换标签页。这样操作不会真正切换标签页内容,不过可以看到,内容的背景颜色会发生变化
而我们要实现的效果就是让背景颜色的变化呈现动画效果,即增加过渡效果
我们点击 TODO 工具窗口中的 TODO 1
val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300
背景颜色可以在紫色和绿色之间切换,具体取决于backgroundColor
。我们需要为这个值的变化添加动画效果
如需为诸如此类的简单值变化添加动画效果,我们可以使用 animate*AsState
API。只需使用 animate*AsState
可组合项的相应变体(在本例中为 animateColorAsState
)封装更改值,即可创建动画值。返回的值是 State<T>
对象,因此我们可以使用包含 by
声明的本地委托属性,以将该值视为普通变量
val backgroundColor by animateColorAsState(targetValue = if (tabPage == TabPage.Home) Purple100 else Green300)
重新运行应用并尝试切换标签页。现在颜色变化会呈现动画效果
可见性动画
当我们滚动应用内容,会发现悬浮操作按钮按照滚动方向而展开和缩小
找到 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)
)
}
当指定的 Boolean
值发生变化时,AnimatedVisibility
会运行其动画。默认情况下,AnimatedVisibility
会以淡入和展开的方式显示元素,以淡出和缩小的方式隐藏元素
@Composable
fun RowScope.AnimatedVisibility(
visible: Boolean,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandHorizontally(),
exit: ExitTransition = fadeOut() + shrinkHorizontally(),
label: String = "AnimatedVisibility",
content: @Composable() AnimatedVisibilityScope.() -> Unit
) { ... }
当然我们也可以自定义动画方式
点击FloatingActionButton
后,我们会看到条内容为“Edit feature is not supported”的消息,即EditMessage
可组合项
在EditMessage
中使用AnimatedVisibility
为其出现和消失添加动画效果,我们通过自定义动画效果,让其元素出现时从顶部移出,消失时移入至顶部
找到 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)
)
}
}
想要自定义动画,就需要我们指定enter
和 exit
参数的值
enter
参数是 EnterTransition
的实例,要实现组件移动效果,我们可以使用 slideInVertically
函数创建 EnterTransition
此函数可使用 initialOffsetY
和 animationSpec
参数进一步自定义
-
initialOffsetY
是返回动画开始元素y坐标
位置的 lambda。lambda 会收到一个表示元素高度的参数,因此我们只需返回其负值即可。使用slideInVertically
时,滑入后的目标偏移量始终为0
(像素)。可使用 lambda 函数将initialOffsetY
指定为绝对值
@Stable
fun slideInVertically(
animationSpec: FiniteAnimationSpec<IntOffset> =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntOffset.VisibilityThreshold
),
initialOffsetY: (fullHeight: Int) -> Int = { -it / 2 },
): EnterTransition =
slideIn(
initialOffset = { IntOffset(x = 0, y = initialOffsetY(it.height)) },
animationSpec = animationSpec
)
slideInVertically
会将元素y方向上的偏移量值会从initialOffsetY
变为0所以要实现整个元素从顶部移出效果,我们需要让 lambda 函数返回
-元素高度
-
animationSpec
是包括EnterTransition
和ExitTransition
在内的许多动画 API 的通用参数。我们可以传递各种AnimationSpec
类型中的一种,以指定动画值应如何随时间变化在本示例中,我们使用基于时长的简单
AnimationSpec
。它可以使用tween
函数创建。时长为 150 毫秒,加/减速选项为LinearOutSlowInEasing
@Stable fun <T> tween( durationMillis: Int = DefaultDurationMillis, delayMillis: Int = 0, easing: Easing = FastOutSlowInEasing ): TweenSpec<T> = TweenSpec(durationMillis, delayMillis, easing)
easing
参数可以理解为是动画执行时的速度控制器,可以类比为View体系中的Interpolator
类(插值器)
同样,我们可以对 exit
参数使用 slideOutVertically
函数。slideOutVertically
假定初始偏移量为 0,因此只需指定 targetOffsetY
。我们对 animationSpec
参数使用相同的 tween
函数,但时长为 250 毫秒,加/减速选项为 FastOutLinearInEasing
slideOutVertically
中参数为animationSpec
和targetOffsetY
,其中targetOffsetY
是返回动画结束时元素y坐标
位置的 lambda,即元素y坐标
会从0开始变化为targetOffsetY
最后代码:
@Composable
private fun EditMessage(shown: Boolean) {
AnimatedVisibility(
visible = shown,
enter = slideInVertically(
animationSpec = tween(
durationMillis = 150,
easing = LinearOutSlowInEasing
),
initialOffsetY = { fullHeight -> -fullHeight }
),
exit = slideOutVertically(
animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing),
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)
)
}
}
}
内容大小变化动画
在实例应用中点击TopicRow
会展开并显示该主题的正文部分。当正文显示或隐藏时,包含文本的组件会展开或缩小
查看 TopicRow
可组合项中 TODO 3 的代码
@Composable
private fun TopicRow(topic: String, expanded: Boolean, onClick: () -> Unit) {
TopicRowSpacer(visible = expanded)
Surface(
...
) {
// TODO 3: Animate the size change of the content.
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.animateContentSize()
) {
...
//也可以用AnimatedVisiibility替换if,实现动画效果
if (expanded) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.lorem_ipsum),
textAlign = TextAlign.Justify
)
}
}
}
TopicRowSpacer(visible = expanded)
}
注释处的Column
可组合项会在内容发生变化时更改其大小。我们可以添加 animateContentSize
修饰符,为其大小变化添加动画效果
当然我们也可以用可见性动画实现,不过具体效果上有些差异
可见性动画:文本是从底部向上展开
内容大小变化动画:文本从顶部向下展开
多值动画
现在我们已经熟悉一些基本的动画 API,接下来我们来了解一下 Transition
API。借助该 API,我们可以制作更复杂的动画
在本示例中,我们将完成HomeTabBar
上显示的矩形的移动动画,不同于之间的简单值动画,这次将同时变化多个值实现移动和颜色变化
在 HomeTabIndicator
可组合项中找到 TODO 4,查看标签页指示器的实现方式
@Composable
private fun HomeTabIndicator(
tabPositions: List<TabPosition>,
tabPage: TabPage
) {
// TODO 4: Animate these value changes.
val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) Purple700 else Green800
Box(
Modifier
.fillMaxSize()
.wrapContentSize(align = Alignment.BottomStart)
.offset(x = indicatorLeft)
.width(indicatorRight - indicatorLeft)
.padding(4.dp)
.fillMaxSize()
.border(
BorderStroke(2.dp, color),
RoundedCornerShape(4.dp)
)
)
}
其中,indicatorLeft
表示标签页行中指示器左侧边缘的水平位置。indicatorRight
表示指示器右侧边缘的水平位置。颜色也在紫色和绿色之间变化
如需同时为多个值添加动画效果,可使用 Transition
。Transition
可使用 updateTransition
函数创建。将当前所选标签页的索引作为 targetState
参数传递
@Composable fun <T> updateTransition( targetState: T, label: String? = null ): Transition<T> { ... }
当
targetState
发生变化时,Transition
会将其所有子动画运行到为新targetState
指定的目标值
每个动画值都可以使用 Transition
的 animate*
扩展函数进行声明。在本示例中,我们使用 animateDp
和 animateColor
。它们会接受一个 lambda 块,我们可以为每个状态指定目标值
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
关联的所有动画值会开始以动画方式切换至为目标状态指定的值
此外,我们可以指定 transitionSpec
参数来自定义动画行为
@Composable
inline fun <S> Transition<S>.animateDp(
noinline transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<Dp> = {
spring(visibilityThreshold = Dp.VisibilityThreshold)
},
label: String = "DpAnimation",
targetValueByState: @Composable (state: S) -> Dp
): State<Dp>
例如,我们可以让靠近目标页面的一边比另一边移动得更快来实现指示器的弹性效果。可以在 transitionSpec
lambda 中使用 isTransitioningTo
infix 函数来确定状态变化的方向
val transition = updateTransition(
tabPage,
label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
transitionSpec = {
if (TabPage.Home isTransitioningTo TabPage.Work) {
spring(stiffness = Spring.StiffnessVeryLow)
} else {
spring(stiffness = Spring.StiffnessMedium)
}
},
label = "Indicator left"
) { page ->
tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
transitionSpec = {
if (TabPage.Home isTransitioningTo TabPage.Work) {
spring(stiffness = Spring.StiffnessMedium)
} else {
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
}
重复动画
点击当前气温旁边的刷新图标按钮。应用开始加载最新天气信息(当然只是模拟)。在加载完成之前,会看到加载指示器,即一个灰色圆圈和一个条形。我们来为该指示器的 Alpha 值添加动画效果,以便更清楚地呈现该进程正在进行
在 LoadingRow
可组合项中找到 TODO 5
@Composable
private fun LoadingRow() {
// TODO 5: Animate this value between 0f and 1f, then back to 0f repeatedly.
val alpha = 1f
Row(
modifier = Modifier
.heightIn(min = 64.dp)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.LightGray.copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(32.dp)
.background(Color.LightGray.copy(alpha = alpha))
)
}
}
我们希望Alpha值设为在 0f 和 1f 之间以动画效果方式重复呈现,为此,可以使用 InfiniteTransition
此 API 与 Transition
API 类似。两者都是为多个值添加动画效果,但 Transition
会根据状态变化为值添加动画效果,而 InfiniteTransition
则无限期地为值添加动画效果
如需创建 InfiniteTransition
,请使用 rememberInfiniteTransition
函数。然后,可以使用 InfiniteTransition
的一个 animate*
扩展函数声明每个动画值变化
在本例中,我们要为 Alpha 值添加动画效果,所以使用 animatedFloat
。initialValue
参数应为 0f
,而 targetValue
应为 1f
。我们还可以为此动画指定 AnimationSpec
,但此 API 仅接受 InfiniteRepeatableSpec
。我们可以使用 infiniteRepeatable
函数创建InfiniteRepeatableSpec
InfiniteRepeatableSpec
会封装任何基于时长的AnimationSpec
,使其可重复
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000),
repeatMode = RepeatMode.Reverse
)
)
运行应用,然后尝试点击刷新按钮。现在,您可以看到加载指示器会显示动画效果
手势动画
最后一部分中,我们将学习如何基于触控输入运行动画
要实现的效果类似于状态栏中通知的滑动事件,会根据滑动速度决定元素回到原位或被移除
在这种情况下,需要考虑几个独特的因素。首先,任何正在播放的动画都可能会被触摸事件拦截。其次,动画值可能不是唯一的可信来源。换句话说,我们可能需要将动画值与来自触摸事件的值同步
在Modifier.swipeToDismiss
修饰符中找到 TODO 6-1
我们通过创建一个修饰符,以使触摸时元素可滑动。当元素被快速滑动到屏幕边缘时,我们将调用 onDismissed
回调,以便移除该元素
Animatable
是我们目前看到的最低级别的 API。它有一些对手势场景非常有用的功能,所以我们可以创建一个 Animatable
实例,并使用它表示可滑动元素的水平偏移量
val offsetX = remember { Animatable(0f) } //新增这行代码
pointerInput {
// 用于计算动画的稳定位置
val decay = splineBasedDecay<Float>(this)
// 在协程中使用挂起函数来处理触摸事件和动画
coroutineScope {
while (true) {
// ...
TODO 6-2 是我们刚刚收到向下轻触事件的位置。如果动画当前正在运行,我们应将其拦截。可以通过对 Animatable
调用 stop
来实现此目的
当然如果动画未运行,系统会忽略该函数调用
// 等待手指按下事件
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // 新增这行代码
// 准备拖动事件,并且记录下移动的速度
val velocityTracker = VelocityTracker()
// 等待拖动事件
awaitPointerEventScope {
在 TODO 6-3 位置,我们不断接收到拖动事件。必须将触摸事件的位置同步到动画值中。为此,我们可以对 Animatable
使用 snapTo
horizontalDrag(pointerId) { change ->
// 新增下列三行代码
// 获取到触摸事件位置
val horizontalDragOffset = offsetX.value + change.positionChange().x
launch {
offsetX.snapTo(horizontalDragOffset)
}
// 记录拖动的速度
velocityTracker.addPosition(change.uptimeMillis, change.position)
// 消费掉这个手势事件,不向下传递事件
change.consumePositionChange()
}
TODO 6-4 是元素刚刚被松开和快速滑动的位置。我们需要计算快速滑动操作的最终位置,以便确定是要将元素滑回原始位置,还是滑开元素并调用回调
// 拖动结束,计算拖动的速度
val velocity = velocityTracker.calculateVelocity().x
// 新增这行代码
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
calculateTargetValue
会根据initialValue
和initialVelocity
计算浮点衰减动画的目标值
在 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) {
// 拖动的速度不够,滑动回原位
offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
} else {
// 拖动的速度足够,将元素滑出屏幕边缘
offsetX.animateDecay(velocity, decay)
// 执行回调
onDismissed()
}
}
最后,我们来到 TODO 6-7。我们已设置所有动画和手势,因此,请记得对元素应用偏移
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
最终代码:
private fun Modifier.swipeToDismiss(
onDismissed: () -> Unit
): Modifier = composed {
val offsetX = remember { Animatable(0f) }//新增这行代码
pointerInput(Unit) {
// 用于计算动画的稳定位置
val decay = splineBasedDecay<Float>(this)
// 在协程中使用挂起函数来处理触摸事件和动画
coroutineScope {
while (true) {
// 等待手指按下事件
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop()//新增这行代码
// 准备拖动事件,并且记录下移动的速度
val velocityTracker = VelocityTracker()
// 等待拖动事件
awaitPointerEventScope {
horizontalDrag(pointerId) { change ->
// 新增下列三行代码
// 获取到触摸事件位置
val horizontalDragOffset = offsetX.value + change.positionChange().x
launch {
offsetX.snapTo(horizontalDragOffset)
}
// 记录拖动的速度
velocityTracker.addPosition(change.uptimeMillis, change.position)
// 消费掉这个手势事件,不向下传递事件
change.consumePositionChange()
}
}
// 拖动结束,计算拖动的速度
val velocity = velocityTracker.calculateVelocity().x
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)//新增这行代码
//新增这行代码
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
launch {
launch {
if (targetOffsetX.absoluteValue <= size.width) {
// 拖动的速度不够,滑动回原位
offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
} else {
// 拖动的速度足够,将元素滑出屏幕边缘
offsetX.animateDecay(velocity, decay)
// 执行回调
onDismissed()
}
}
}
}
}
}.offset {
IntOffset(offsetX.value.roundToInt(), 0)
}
}
手势动画这块,官网教程没有铺垫一点手势相关的部分就开始于动画效果配合,对于初学者不是特别友好,而且动画效果也没有介绍完全,比如这里使用的
Animatable
就没有介绍清晰