Crossfade & AnimatedContent
Crossfade
AnimatedVisibility 可以为同一内容的出现和消失添加动画效果,那内容切换(一个内容消失,另一个内容出现)该怎么做动画呢?总不能写两遍 AnimatedVisibility
吧,这时候就需要用到 Crossfade 了。Crossfade 能在两个布局内容切换时添加淡出淡入动画效果。使用也非常简单:
kotlin
var currentShape by remember { mutableStateOf(Shape.Circle) }
Crossfade(targetState = currentShape) { currentShape ->
when (currentShape) {
Shape.Circle -> SmallCircle()
Shape.Square -> BigBox()
}
}
第一个必填参数是目标状态,第二个必填参数是内容,可以根据不同状态来加载不同的内容,状态改变导致内容切换时,Crossfade 会为消失的内容添加淡出动画,为出现的内容添加淡入动画。
less
@Composable
fun <T> Crossfade(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit
) {
val transition = updateTransition(targetState, label)
transition.Crossfade(modifier, animationSpec, content = content)
}
而且从源码也能看出原来 Crossfade 也是基于 Transition
实现的,调用的是 transition.Crossfade()
方法。
淡入淡出说白了就是透明度动画,我们可以利用参数 animationSpec: FiniteAnimationSpec<Float>
来自定义透明度动画的规格,例如动画时长、动画曲线等等。
呃...产品那边说不想要淡入淡出的切换效果,能不能整个别的切换效果,右滑出左滑入的那种也行啊...... 不好意思,不行,Crossfade 只能做淡入淡出的切换效果,毕竟它的名字就叫 Cross Fade 嘛,不过我们可以用 AnimatedContent
来实现其他的内容切换效果。
AnimatedContent
AnimatedContent 会在内容根据目标状态发生变化时,为内容添加动画效果。
可以把 AnimatedContent 看作是一个高级版本的 Crossfade,Crossfade 只能做淡入淡出的切换效果,而 AnimatedContent 可以做更多的切换效果。用法和 Crossfade 差不多,直接把上面代码的 Crossfade
换成 AnimatedContent
:
kotlin
var currentShape by remember { mutableStateOf(Shape.Circle) }
AnimatedContent(targetState = currentShape) { currentShape ->
when (currentShape) {
Shape.Circle -> SmallCircle()
Shape.Square -> BigBox()
}
}
kotlin
@Composable
fun <S> AnimatedContent(
targetState: S,
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
(fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90))
).togetherWith(fadeOut(animationSpec = tween(90)))
},
contentAlignment: Alignment = Alignment.TopStart,
label: String = "AnimatedContent",
contentKey: (targetState: S) -> Any? = { it },
content: @Composable() AnimatedContentScope.(targetState: S) -> Unit
) {
// 一样是基于 Transition 实现的
val transition = updateTransition(targetState = targetState, label = label)
transition.AnimatedContent(modifier, transitionSpec, contentAlignment, contentKey, content = content)
}
重点看一下 AnimatedContent()
的参数 transitionSpec
,毕竟我们就是奔着"更多的内容切换效果"来的,而这个参数很明显就是用来配置内容的切换效果。它的类型是 AnimatedContentTransitionScope<S>.() -> ContentTransform
,感觉有点眼熟,和 Transition.animateXxx()
的参数 transitionSpec 差不多,都是函数参数类型,不过这个函数参数要求返回类型是 ContentTransform
:
kotlin
class ContentTransform(
val targetContentEnter: EnterTransition,
val initialContentExit: ExitTransition,
targetContentZIndex: Float = 0f,
sizeTransform: SizeTransform? = SizeTransform()
)
从 ContentTransform
的构造函数可以看出,这是一个内容切换动画的配置类,能够配置 4 个方面的参数:
-
targetContentEnter: EnterTransition
入场动画; -
initialContentExit: ExitTransition
出场动画; -
targetContentZIndex: Float
用于指定入场内容的 z-index 值。默认情况下,出场的内容和入场的内容的 z-index 都是 0f,不过 Compose 规定:两个 z-index 值一样的内容,后被添加到界面上的内容会更靠前,所以默认的一次内容切换动画中,入场内容会显示在出场内容之上。 -
sizeTransform: SizeTransform
当入场内容和出场内容的大小不一致时就会涉及到 Size 的转换动画。这个 Size 动画,到底是属于出场动画还是入场动画的一部分呢?好像都属于,又好像都不属于。算了,干脆用一个独立参数来对这个 Size 动画过程进行配置吧。Crossfade 的淡出淡入是没有大小转换动画的,可以对比一下:
可以通过构造函数创建一个 ContentTransform 实例,不过更常见的做法是使用 infix 函数 togetherWith()
:
kotlin
infix fun EnterTransition.togetherWith(exit: ExitTransition) = ContentTransform(this, exit)
// ContentTransform = EnterTransition togetherWith ExitTransition
另外顺带提一下,ContentTransform
有一个 infix 函数 using()
,用于配置 SizeTransform
:
kotlin
override infix fun ContentTransform.using(sizeTransform: SizeTransform?) = this.apply {
this.sizeTransform = sizeTransform
}
现在可以回头看一下 AnimatedContent()
可选参数 transitionSpec
的默认值了:
kotlin
transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
(fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90))
).togetherWith(fadeOut(animationSpec = tween(90)))
}
入场动画是 [淡入 fadeIn] + [放大 scaleIn],出场动画是 [淡出 fadeOut],出入场延迟都是 90 ms。
Sample
现在我们来动手试一试,现在有一个根据 ascill 码来切换字母方块的 AnimatedContent 代码块。
kotlin
var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(targetState = currentAsciiCode) { asciiCode ->
val letterChar = Char(code = asciiCode)
LetterBox(letter = letterChar)
}
想把这个默认效果改为 "左边滑入,右边滑出",像纸张一样层层堆叠递进:
kotlin
var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(
targetState = currentAsciiCode,
transitionSpec = {
(fadeIn() + slideInHorizontally { fullWidth -> -fullWidth }) togetherWith
(fadeOut() + slideOutHorizontally { fullWidth -> fullWidth })
}) { asciiCode ->
val letterChar = Char(code = asciiCode)
LetterBox(letter = letterChar)
}
入场动画设为 fadeIn() + slideInHorizontally { fullWidth -> -fullWidth }
,淡入,而且初始偏移位置就在容器左侧,出场动画就是相反的。
不过!我们这个场景是字母切换,A -> Z,这个切换场景是有顺序的,当我们反过来的时候就会发现问题了:
反过来从 Z -> A 的时候,入场出场动画还是按照之前的设置,动画效果就不符合预期的,感觉很怪异。解决办法也很简单,我们根据动画的初始状态和目标状态来判断是正向切换还是反向切换,然后根据切换方向来设置不同的入场出场动画就 OK 了:
kotlin
var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(
targetState = currentAsciiCode,
transitionSpec = { // 拥有 AnimatedContentTransitionScope 上下文,可以获取 initialState 和 targetState
if (targetState > initialState) { // A -> Z
(fadeIn() + slideInHorizontally { fullWidth -> -fullWidth }) togetherWith
(fadeOut() + slideOutHorizontally { fullWidth -> fullWidth })
} else { // Z -> A
(fadeIn() + slideInHorizontally { fullWidth -> fullWidth }) togetherWith
(fadeOut() + slideOutHorizontally { fullWidth -> -fullWidth })
}
}) { asciiCode ->
val letterChar = Char(code = asciiCode)
LetterBox(letter = letterChar)
}
哎,Okey,现在完美了。原来参数 transitionSpec 类型设计成函数参数,就是为了让我们可以根据不同的场景来动态配置不同的动画效果啊。
另外,有两个很有用的辅助函数 slideIntoContainer(towards = ...)
和 slideOutOfContainer(towards = ...)
,这两个是 AnimatedContentTransitionScope 的拓展函数,可以快速创建滑入滑出容器的动画效果,只需我们提供方向,而不用我们手动计算初始和目标偏移量。
使用它俩可以替代上面我们写的 slideInHorizontally { }
和 slideOutHorizontally { }
:
kotlin
var currentAsciiCode by remember { mutableStateOf(65) }
...
AnimatedContent(
targetState = currentAsciiCode,
transitionSpec = { // 拥有 AnimatedContentTransitionScope 上下文,可以获取 initialState 和 targetState
if (targetState > initialState) { // A -> Z
(fadeIn() + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right)) togetherWith
(fadeOut() + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right))
} else { // Z -> A
(fadeIn() + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left)) togetherWith
(fadeOut() + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left))
}
}) { asciiCode ->
...
}
上面的例子中,出场内容和入场内容都是等大小的,如果入场内容和出场内容的大小不一致,使用传统方式 slideInHorizontally { fullWidth -> -fullWidth }
计算偏移量就会出现问题:
入场内容是大方块,出场内容是小方块,入场内容的初始位置就在容器左侧,出场内容的初始位置就在容器右侧,而 slideInHorizontally 传入的 fullWidth 就是当前容器的宽,也就是小方块的宽,这样的话,偏移量就会偏小。
如果使用 slideIntoContainer()
和 slideOutOfContainer()
,就不会出现这个问题,因为这两个函数会自动计算出场内容和入场内容的大小,取其中较大的那个作为偏移量的基准值,这样就不会出现偏移量偏小的问题了。