1、Block 参数:监听每一帧
animateTo() 与 animateDecay() 中都有一个函数类型的 block 参数:
suspend fun animateDecay(
initialVelocity: T,
animationSpec: DecayAnimationSpec<T>,
block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V>
block 会在动画执行的每一帧刷新时被调用,相当于是对动画的监听。举个简单例子,让红色的方块跟随绿色方块一起滑动相同的距离:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
var paddingRed by remember { mutableStateOf(anim.value) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
anim.animateDecay(1000.dp, decay) { // this: Animatable
paddingRed = value
}
}
Row {
Box(
Modifier
.padding(0.dp, anim.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
Box(
Modifier
.padding(0.dp, paddingRed, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Red)
)
}
}
}
2、动画的边界限制、结束和取消
本节介绍动画的非正常终止,即还没结束就被叫停了的情况,大致有如下三种:
- 新动画的执行会导致正在执行的动画被打断:同一个 Animatable 对象在第一个动画还没执行结束时就开启了第二个动画,Compose 会直接结束第一个动画。
- 主动结束一个动画的执行
- 动画的值到达边界后结束,特殊场景,前两种属于打断,而这一种是动画边界的触发,属于正常结束
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
try {
anim.animateDecay(2000.dp, decay)
} catch (e: CancellationException) {
// 协程取消时会抛出 CancellationException,为了演示效果才捕获这个异常,
// 正常开发中一定不要捕获它,因为一旦捕获了会影响协程的结构化取消
println("糟糕!被打断了")
}
}
// 第二个协程比第一个协程多延时一些时间以打断第一个动画的执行
LaunchedEffect(Unit) {
delay(1500)
anim.animateDecay((-1000).dp, decay)
}
Box(
Modifier
.padding(0.dp, anim.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
运行代码发现 Box 在向下移动过程中会向上移动,并且打印"糟糕!被打断了"。说明第一个协程内抛出了 CancellationException,证明第一个协程确实被取消了,协程取消就会导致协程内部执行的动画取消。
animateDecay()、animateTo() 以及 snapTo() 都会导致正在执行的动画被打断。
业务开发中一定会有需要主动停止动画执行的场景,调用 Animatable 的 stop() 即可停止动画,该函数是一个挂起函数,也应该在协程环境中调用。使用时需要注意它不应该紧接着一个执行动画的函数后面调用,例如:
LaunchedEffect(Unit) {
delay(1500)
anim.animateDecay((-1000).dp, decay)
anim.stop()
}
因为执行动画的函数也是挂起函数,在该挂起函数运行完毕之前,stop() 得不到执行。也就是说,这种情况下,stop() 得以执行时,动画已经运行完毕,也就失去调用 stop() 的意义了。因此通常情况下 stop() 应该是在另一个协程中,执行一些代码之后被调用。
此外,还需注意,stop() 一般是与业务逻辑相关的,而不是与 Compose 界面相关的。比如点击了界面的某个按钮,触发了相应的业务流程后结束动画,这时使用传统的启动协程的方式就可以了,比如 lifecycleScope.launch(),在这里面调用 stop(),而不是在 LaunchedEffect() 中。
调用 stop() 也会引发正在执行中的动画所在的协程抛出 CancellationException,示例代码:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
try {
anim.animateDecay(2000.dp, decay)
} catch (e: CancellationException) {
println("糟糕!被打断了")
}
}
// 调用 stop() 也会引发正在执行的动画所在的协程抛 CancellationException
LaunchedEffect(Unit) {
delay(1200)
anim.stop()
}
Box(
Modifier
.padding(0.dp, anim.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
Compose 认为的动画正常停止有两种情况:动画正常运行完毕以及动画运行到边界条件,这个信息可以通过 animateTo() 和 animateDecay() 的返回值类型 AnimationResult 中查看到:
class AnimationResult<T, V : AnimationVector>(
val endState: AnimationState<T, V>,
val endReason: AnimationEndReason
)
enum class AnimationEndReason {
/**
* 动画值到达上限或下限时会被强制结束,这种状态下结束通常会比初始目标值短一些,
* 并且剩余速度通常不为零。可以通过 [AnimationResult] 获取结束值和剩余速度。
*/
BoundReached,
/**
* 动画已经成功完成,没有任何中断。
*/
Finished
}
代码示例:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// 用以获取整个屏幕的最大宽度从而设置动画边界
BoxWithConstraints {
val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
val decay = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
anim.animateDecay(2000.dp, decay)
}
// 更新动画的边界,上边界应该是最大宽度减去 Box 的宽度,即是 Box 左边的最大位移值
anim.updateBounds(upperBound = maxWidth - 100.dp)
Box(
Modifier
.padding(anim.value, 0.dp, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
}
上面是一维动画,由于 Compose 支持多维动画,最多是到四维。在多维空间中,只要有一个维度到达边界,Compose 就会停止动画。但假如需求是多维动画的所有维度均到达边界后才停止动画,该如何实现呢?
一种比较直观的想法是在一个维度到达边界后,通过 AnimationResult 的 endReason 先判断是因为到达边界引发的动画停止,然后获取 endState 从中获取尚未到达边界的维度数据,然后用这个数据在该维度重新开启一段动画,直到其到达边界。以二维 Offset 为例的代码如下:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BoxWithConstraints {
// Offset 动画
val anim = remember { Animatable(DpOffset.Zero, DpOffset.VectorConverter) }
val decay = remember { exponentialDecay<DpOffset>() }
// Y 轴动画
val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
// Offset 动画是否已经结束并开始 Y 轴动画了
var startY by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(1000)
val result = anim.animateDecay(DpOffset(2000.dp, 3000.dp), decay)
if (result.endReason == AnimationEndReason.BoundReached) {
// 开始 Y 轴动画,用 Offset 动画结束时 Y 轴速度作为 Y 轴衰减动画的初速度
startY = true
val initVelocityY = result.endState.velocity.y
animY.animateDecay(initVelocityY, decayY)
}
}
// 更新动画的边界,上边界应该是最大宽度减去 Box 的宽度,即是 Box 左边的最大位移值
anim.updateBounds(upperBound = DpOffset(maxWidth - 100.dp, maxHeight - 100.dp))
// Y 轴动画边界最大值应该是屏幕高度减去 Box 高度再减去 Offset 动画在 Y 轴上的位移
animY.updateBounds(upperBound = maxHeight - 100.dp - anim.value.y)
Box(
Modifier
.padding(
anim.value.x,
if (!startY) anim.value.y else (anim.value.y + animY.value),
0.dp,
0.dp
)
.size(100.dp)
.background(Color.Green)
)
}
}
}
经过上述一通操作实现了如下效果:
上述操作实际上是有些繁琐的,还有一种比较简便的思路,就是将多个维度的动画分开实现。实际上,原生的 OverScroller 就是这样做的:
public class OverScroller {
private final SplineOverScroller mScrollerX;
@UnsupportedAppUsage
private final SplineOverScroller mScrollerY;
public void fling(int startX, int startY, int velocityX, int velocityY,
int minX, int maxX, int minY, int maxY, int overX, int overY) {
// Continue a scroll or fling in progress
if (mFlywheel && !isFinished()) {
float oldVelocityX = mScrollerX.mCurrVelocity;
float oldVelocityY = mScrollerY.mCurrVelocity;
if (Math.signum(velocityX) == Math.signum(oldVelocityX) &&
Math.signum(velocityY) == Math.signum(oldVelocityY)) {
velocityX += oldVelocityX;
velocityY += oldVelocityY;
}
}
mMode = FLING_MODE;
// 两个方向上分别惯性滑动
mScrollerX.fling(startX, velocityX, minX, maxX, overX);
mScrollerY.fling(startY, velocityY, minY, maxY, overY);
}
}
因此上面的例子也可以采用这种方式,代码会方便很多:
@Composable
fun BoundsSample2() {
BoxWithConstraints {
// X 轴动画
val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayX = remember { exponentialDecay<Dp>() }
// Y 轴动画
val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
animX.animateDecay(2000.dp, decayX)
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(3000.dp, decayY)
}
animX.updateBounds(upperBound = maxWidth - 100.dp)
animY.updateBounds(upperBound = maxHeight - 100.dp)
Box(
Modifier
.padding(animX.value, animY.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
下面再看一个复杂一点的需求,让 Box 碰到边缘后反弹:
示例代码如下:
@Composable
fun BoundsSample3() {
BoxWithConstraints {
// X 轴动画
val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayX = remember { exponentialDecay<Dp>() }
// Y 轴动画
val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
var result = animX.animateDecay(4000.dp, decayX)
// 每次碰到边界都反速运行动画
while (result.endReason == AnimationEndReason.BoundReached) {
result = animX.animateDecay(-result.endState.velocity, decayX)
}
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(2000.dp, decayY)
}
// 需要更新一下下界,因为动画 value 要作为 padding 不能是负值
animX.updateBounds(upperBound = maxWidth - 100.dp, lowerBound = 0.dp)
animY.updateBounds(upperBound = maxHeight - 100.dp)
Box(
Modifier
.padding(animX.value, animY.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
这样做能实现一个大致准确的动画,之所以说大致准确,是因为速度 AnimationResult.endState.velocity 的精度不够高。Compose 在动画的每一帧中去采集图形的位置,再通过计算时间段进行相除来计算出实时速度。这个计算出来的速度显然与 Box 解除到边界的一瞬间的速度是有毫厘之差的。
我们可以自己进行数学计算,得到一个没有误差的精确值,这里仅以 X 轴方向为例,主要的改进在于 paddingX 的计算上:
@Composable
fun BoundsSample4() {
BoxWithConstraints {
// X 轴动画
val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayX = remember { exponentialDecay<Dp>() }
// Y 轴动画
val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
val decayY = remember { exponentialDecay<Dp>() }
LaunchedEffect(Unit) {
delay(1000)
var result = animX.animateDecay(4000.dp, decayX)
while (result.endReason == AnimationEndReason.BoundReached) {
result = animX.animateDecay(-result.endState.velocity, decayX)
}
}
LaunchedEffect(Unit) {
delay(1000)
animY.animateDecay(2000.dp, decayY)
}
animY.updateBounds(upperBound = maxHeight - 100.dp)
// 自己计算 X 轴的距离
val paddingX = remember(animX.value) {
var usedValue = animX.value
// 将从左到右,再从右到左这一个来回视为一次循环,碰撞后的位移,就是总的动画值
// 这个位移对一次循环求模的结果
while (usedValue >= (maxWidth - 100.dp) * 2) {
usedValue -= (maxWidth - 100.dp) * 2
}
if (usedValue < maxWidth - 100.dp) {
// 从左到右移动过程中,paddingX 应该就是求模后的 usedValue
usedValue
} else {
// 从右向左移动过程中,paddingX 应该是一个来回的距离减去已经移动过的距离
(maxWidth - 100.dp) * 2 - usedValue
}
}
Box(
Modifier
// 使用 paddingX 作为 start 值
.padding(paddingX, animY.value, 0.dp, 0.dp)
.size(100.dp)
.background(Color.Green)
)
}
}
实测发现精确值与前面通过 AnimationResult 提供的速度值计算出的结果确实存在一定偏差。
3、Transition
Compose 中的 Transition 是指 Compose 内部的转场动画,这个词在 Android 原生中也是有的,只不过是负责 Activity 或 Fragment 的转场动画。由于 Activity 与 Fragment 是 View 以外更高层次的结构,因此 View 中的动画是无法应用到 Activity 与 Fragment 上的,只能借助 Transition 实现它们的转场动画。
实际上前面我们已经讲过一种转场动画的实现方式:【状态转移动画?】 animateXxxAsState(),那为什么还要单独再提供一种 Transition?下面我们来详细介绍。
3.1 多属性的状态切换
作为对比,我们还是先用状态转移动画 API animateXxxAsState() 实现一个简单的动画:
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
val size by animateDpAsState(if (big) 96.dp else 48.dp)
Box(modifier = Modifier
.size(size)
.background(Color.Green)
.clickable { big = !big }
)
}
它的效果是点击绿色的 Box 就会变大或变小,这个效果如果通过 Transition 实现,应该是下面这样的:
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
// val size by animateDpAsState(if (big) 96.dp else 48.dp)
val bigTransition = updateTransition(targetState = big)
val size by bigTransition.animateDp { // it 是 state 对象,也就是 Boolean
if (it) 96.dp else 48.dp
}
Box(modifier = Modifier
.size(size)
.background(Color.Green)
.clickable { big = !big }
)
}
Transition 实现同样的动画效果要比使用 animateDpAsState() 多出一步,你需要先创建一个 Transition 对象,然后调用相应的函数创建动画。
我们先看这两步的具体内容。
Transition 的创建与使用
首先是 updateTransition() 创建 Transition 对象:
@Composable
fun <T> updateTransition(
targetState: T,
label: String? = null
): Transition<T> {
// 创建 Transition 对象并通过 remember 缓存,无参 remember 只会在第一次调用时执行,
// 从而保证了 Transition 只被创建一次
val transition = remember { Transition(targetState, label = label) }
// 动画转移到目标状态
transition.animateTo(targetState)
DisposableEffect(transition) {
onDispose {
// Clean up on the way out, to ensure the observers are not stuck in an in-between
// state.
transition.onTransitionEnd()
}
}
return transition
}
源码表明,updateTransition() 实际上是做两件事:创建 Transition 与更新状态。注意它只更新了状态,让 Transition 处于旧状态与新状态之间的中间状态,但是不负责根据状态的转移生成动画。你需要调用它的返回值 Transition 提供的对应的函数以展示状态转移动画,比如 animateDp()、animateInt()、animateFloat() 等等。
接下来我们再看 animateXxx() 的内容,以示例代码使用的 animateDp() 为例:
@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 // 泛型 S 表示状态类型
): State<Dp> =
animateValue(Dp.VectorConverter, transitionSpec, label, targetValueByState)
第三个参数 targetValueByState 是一个 Composable 函数,作用是根据传入的目标状态参数 state 计算出动画的目标值。
第二个参数 label 是在 Android Studio 的预览模式下,区分同一个 Transition 下不同动画的标识。具体说来,就是给可组合函数加上 @Preview 注解进入模式时,可以点击如下图所示的按钮进入动画预览模式:
在动画预览模式下,可以看到 Transition 下的状态以及所有动画:
当然,由于我们目前使用的是一个非常简单的例子,因此你可以很容易的判断出,左侧方框内的 Boolean 表示的是 big 状态,而右侧方框内的 DpAnimation : 48.0dp 表示的是 size(DpAnimation 是参数默认值)。但倘若一个非常复杂的动画,它的 Transition 内部可能会包含很多的状态和动画值,如果没有标识就很难区分它们。所以,第二个参数的 label 此时就派上用场了,当你调用 updateTransition() 和 animateDp() 时传入一个特定的 label,在预览时就能区分它们了:
@Preview
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
// val size by animateDpAsState(if (big) 96.dp else 48.dp)
val bigTransition = updateTransition(big, "big")
val size by bigTransition.animateDp(label = "size") {
if (it) 96.dp else 48.dp
}
Box(modifier = Modifier
.size(size)
.background(Color.Green)
.clickable { big = !big }
)
}
效果如下:
最后说第一个参数 transitionSpec,它是一个返回有限动画 FiniteAnimationSpec 的 Composable 函数,animateDp() 的动画曲线就是用这个函数生成的。它要求你传一个函数而不是直接提供一个 FiniteAnimationSpec 对象,是为了方便在不同的状态下提供不同的 FiniteAnimationSpec,比如:
val size by bigTransition.animateDp({if (!initState && targetState) spring() else tween() }, label = "size") { if (it) 96.dp else 48.dp }
initState 与 targetState 是 Segment 提供的。Segment 是指状态转移中的某一段状态,可以提供这一段的状态信息。Segment 提供了简便方法 isTransitioningTo():
infix fun S.isTransitioningTo(targetState: S): Boolean {
return this == initialState && targetState == this@Segment.targetState
}
因此上面的等价写法是:
val size by bigTransition.animateDp({if (false isTransitioningTo true) spring() else tween() }, label = "size") { if (it) 96.dp else 48.dp }
这样更加直观的表达出状态从 false 转移到 true。
如果想创建一个无限循环类型的 Transition,可以使用 rememberInfiniteTransition(),它会返回一个 InfiniteTransition。
Transition 与 animateXxxAsState() 的区别
既然二者能实现相同的效果,那么问题来了,为啥还要多提供一个 Transition?它用起来还比 animateXxxAsState() 多一些步骤。因为二者的关注点不同:animateDpAsState() 面向属性本身,而 Transition 面向属性背后的状态,这样就容易建立多属性的状态模型。
比如,给上例增加一个功能,在点击时改变图形的圆角大小:
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(false) }
val bigTransition = updateTransition(targetState = big)
val size by bigTransition.animateDp { if (it) 96.dp else 48.dp }
val corner by bigTransition.animateDp { if (it) 0.dp else 18.dp }
Box(modifier = Modifier
.size(size)
.clip(RoundedCornerShape(corner))
.background(Color.Green)
.clickable { big = !big }
)
}
假如使用 animateDpAsState() 来计算 size 和 corner,那么 Compose 会为每个属性启动一个协程进行计算动画,而使用 Transition 则只开一个协程,在 Transition 内部对所有的属性动画做统一管理。因此属性越多,Transition 的性能优势越明显。
此外还有一个好处,就是前面介绍 animateDp() 的第二个参数 label 时说到可以进行动画的预览,这个是 animateXxxAsState() 所不具备的功能。
还有,经过前面对 Transition 的介绍,你会发现它与 animateXxxAsState() 并没有本质上的区别,都是针对状态转移的。只不过一个是瞄准的是具体的属性,一个瞄准的是所有状态。因此,animateDpAsState() 具有的弱点 —— 不能设置动画的初始值,Transition 也是有的。虽然不能直接设置初始值,但是 Transition 可以通过一种比较迂回的方式去设置动画的初始值。先观察 updateTransition() 的源码:
@Composable
fun <T> updateTransition(
targetState: T,
label: String? = null
): Transition<T> {
val transition = remember { Transition(targetState, label = label) }
transition.animateTo(targetState)
DisposableEffect(transition) {
onDispose {
// Clean up on the way out, to ensure the observers are not stuck in an in-between
// state.
transition.onTransitionEnd()
}
}
return transition
}
通过 Transition 的次构造函数传入 targetState:
@Stable
class Transition<S> @PublishedApi internal constructor(
private val transitionState: MutableTransitionState<S>,
val label: String? = null
) {
internal constructor(
initialState: S,
label: String?
) : this(MutableTransitionState(initialState), label)
}
次构造调用主构造时会将 targetState 包装进 MutableTransitionState 对象中,实际上真正进行状态管理的就是 MutableTransitionState。
既然如此,我们可以在创建 Transition 时,使用另一个 updateTransition() 直接传入 MutableTransitionState:
@Composable
fun <T> updateTransition(
transitionState: MutableTransitionState<T>,
label: String? = null
): Transition<T> {
val transition = remember(transitionState) {
Transition(transitionState = transitionState, label)
}
transition.animateTo(transitionState.targetState)
DisposableEffect(transition) {
onDispose {
// Clean up on the way out, to ensure the observers are not stuck in an in-between
// state.
transition.onTransitionEnd()
}
}
return transition
}
这样做的好处是,可以在界面刚开始展示时,就直接开始动画:
@Preview
@Composable
fun TransitionSample() {
var big by remember { mutableStateOf(true) }
val bigState = remember { MutableTransitionState(!big) }
bigState.targetState = big
val bigTransition = updateTransition(bigState, "big")
val size by bigTransition.animateDp(label = "size") { if (it) 96.dp else 48.dp }
val corner by bigTransition.animateDp(label = "corner") { if (it) 0.dp else 18.dp }
Box(modifier = Modifier
.size(size)
.clip(RoundedCornerShape(corner))
.background(Color.Green)
.clickable { big = !big }
)
}
这一点是 animateXxxAsState() 做不到的。
3.2 AnimatedVisibility()
AnimatedVisibility() 会让其内容的显示与隐藏以动画的方式呈现,而不是突然的出现或消失。
举一个简单的代码示例,点击按钮切换方块的显示或隐藏:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
if (shown) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
上述代码完成的是没有动画效果的显示与隐藏,将 if 修改为 AnimatedVisibility() 即可实现动画效果:
可以看到,消失的动画是从下至上的,而显示的动画是由上至下的。这是因为,我们在 Column 中调用的 AnimatedVisibility() 实际上是 ColumnScope 的扩展函数:
@Composable
fun ColumnScope.AnimatedVisibility(
visible: Boolean,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandVertically(),
exit: ExitTransition = fadeOut() + shrinkVertically(),
label: String = "AnimatedVisibility",
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
val transition = updateTransition(visible, label)
AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}
该扩展函数的参数 enter 指定了入场动画,默认值是 fadeIn() + expandVertically(),即淡入 + 垂直展开动画,exit 指定出场动画,默认值是 fadeOut() + shrinkVertically(),即淡出 + 垂直收缩。演示效果正是因为使用了这两个参数的默认值。
既然 Column 有扩展函数,那么 Row 也会有相应的扩展函数:
@Composable
fun RowScope.AnimatedVisibility(
visibleState: MutableTransitionState<Boolean>,
modifier: Modifier = Modifier,
enter: EnterTransition = expandHorizontally() + fadeIn(),
exit: ExitTransition = shrinkHorizontally() + fadeOut(),
label: String = "AnimatedVisibility",
content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
val transition = updateTransition(visibleState, label)
AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}
而如果不是以上两种布局,就会使用通用的 AnimatedVisibility():
@Composable
fun AnimatedVisibility(
visibleState: MutableTransitionState<Boolean>,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = fadeOut() + shrinkOut(),
label: String = "AnimatedVisibility",
content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
val transition = updateTransition(visibleState, label)
AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}
AnimatedVisibility() 在内部先用 updateTransition() 创建出一个 Transition 对象并使用该对象进行后续的动画操作,因此 AnimatedVisibility() 可以视为对 updateTransition() 的扩展。
下面来详细看一下 enter 与 exit 的类型 EnterTransition 与 ExitTransition,由于二者相似,因此我们只详解 EnterTransition:
@Immutable
sealed class EnterTransition {
internal abstract val data: TransitionData
}
@Immutable
private class EnterTransitionImpl(override val data: TransitionData) : EnterTransition()
EnterTransition 是一个密封类,它只有一个子类 EnterTransitionImpl,实现了父类的抽象属性 data,类型为 TransitionData:
@Immutable
internal data class TransitionData(
val fade: Fade? = null, // 淡入淡出
val slide: Slide? = null, // 滑动
val changeSize: ChangeSize? = null, // 尺寸改变,裁切方式
val scale: Scale? = null // 缩放
)
TransitionData 定义了四个属性,实际上就是四种类型的动画。在使用时,不用自己创建这四种类型的对象再传进来,可以直接使用 Compose 提供的函数,比如淡入效果 fadeIn() 会完成整个创建流程并返回一个 EnterTransitionImpl:
@Stable
fun fadeIn(
animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
initialAlpha: Float = 0f
): EnterTransition {
// 为 EnterTransitionImpl 传入 TransitionData,该 TransitionData 指定了 fade 属性
return EnterTransitionImpl(TransitionData(fade = Fade(initialAlpha, animationSpec)))
}
我们可以传入定制的 FiniteAnimationSpec 以及初始透明度:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
AnimatedVisibility(shown, enter = fadeIn(tween(4000), 0.3f)) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
为了让动画效果显得明显一些,特意将动画时长设置为 4 秒,效果如下:
同理,slide 对应的入场效果动画是 slideIn():
@Stable
fun slideIn(
animationSpec: FiniteAnimationSpec<IntOffset> =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntOffset.VisibilityThreshold
),
initialOffset: (fullSize: IntSize) -> IntOffset,
): EnterTransition {
return EnterTransitionImpl(TransitionData(slide = Slide(initialOffset, animationSpec)))
}
必须要给 initialOffset 这个函数参数赋值,因为需要通过它来确定从哪个位置滑入:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
AnimatedVisibility(shown, enter = slideIn { IntOffset(-it.width, -it.height) }) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
initialOffset() 在参数中提供了组件的尺寸 fullSize,因此可以很容易地获取到左上顶点的偏移量,据此创建 IntOffset 并返回即可。效果如下:
此外,slideIn() 还有只进行垂直方向滑入或水平方向滑入的衍生版本 slideInVertically() 与 slideInHorizontally(),只需要传入对应方向的一维偏移量即可,不多赘述。
changeSize 的入场动画由 expandIn() 提供:
@Stable
fun expandIn(
animationSpec: FiniteAnimationSpec<IntSize> =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntSize.VisibilityThreshold
),
expandFrom: Alignment = Alignment.BottomEnd,
clip: Boolean = true,
initialSize: (fullSize: IntSize) -> IntSize = { IntSize(0, 0) },
): EnterTransition {
return EnterTransitionImpl(
TransitionData(
changeSize = ChangeSize(expandFrom, initialSize, animationSpec, clip)
)
)
}
这种是指裁切方式入场,默认是保留组件右下角的区域,然后在组件左上角的位置,慢慢展开保留区域直到展示出整个组件。我们先只给 animationSpec 传一个时长为 5 秒的 TweenSpec 来观察 expandIn() 的默认效果,并且为了证明默认保留右下角区域,我们在绿色方块的右下角放了一个小的红色方块作为参照,代码如下:
@Composable
fun AnimatedVisibilitySample() {
Column {
var shown by remember { mutableStateOf(true) }
AnimatedVisibility(shown, enter = expandIn(tween(5000))) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Green)
) {
Box(
modifier = Modifier
.size(30.dp)
.background(Color.Red)
.align(Alignment.BottomEnd)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
效果图如下:
可以看到,是右下角保留的红色方块现在左上角的位置显现,然后慢慢扩大直到整个组件完全展示。
接下来再看 expandIn() 参数的含义:
- expandFrom:扩展边界的起点,默认为 Alignment.BottomEnd,这解释了为何默认情况下组件是从右下角展开的
- clip:是否应剪切动画边界之外的内容,默认为 true,如果设置为 false,则不进行裁切,只进行简单的位移
- initialSize:扩展边界的起始大小,默认返回 IntSize(0, 0)
我们对以上参数逐个进行测试。首先修改 expandFrom 的位置,比如让它从左上角开始展开:
AnimatedVisibility(shown, enter = expandIn(tween(5000), Alignment.TopStart))
效果如下,右下角的红色方块最后才展示出来:
然后设置 initialSize,让宽高都是一半的位置作为动画开始的初始位置:
AnimatedVisibility(
shown,
enter = expandIn(tween(5000), Alignment.TopStart) {
IntSize(it.width / 2, it.height / 2)
},
)
动画初始就会展示左上角的 1/4,然后慢慢展开,效果如下:
再看 clip,将其修改为 false 查看效果:
AnimatedVisibility(
shown,
enter = expandIn(tween(5000), Alignment.TopStart, false) {
IntSize(it.width / 2, it.height / 2)
},
)
效果如下:
expandIn() 也有两个在单一方向上的衍生函数 expandHorizontally() 与 expandVertically()。
最后,通过 scaleIn() 提供缩放入场:
@Stable
@ExperimentalAnimationApi
fun scaleIn(
animationSpec: FiniteAnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
initialScale: Float = 0f,
transformOrigin: TransformOrigin = TransformOrigin.Center,
): EnterTransition {
return EnterTransitionImpl(
TransitionData(scale = Scale(initialScale, transformOrigin, animationSpec))
)
}
默认以组件中心 TransformOrigin.Center 为缩放中心,默认效果如下:
最后我们来说一下这四种函数之间的加号,是 EnterTransition 重写了 plus 操作符:
@Stable
operator fun plus(enter: EnterTransition): EnterTransition {
return EnterTransitionImpl(
TransitionData(
fade = data.fade ?: enter.data.fade,
slide = data.slide ?: enter.data.slide,
changeSize = data.changeSize ?: enter.data.changeSize,
scale = data.scale ?: enter.data.scale
)
)
}
plus 是创建一个新的 EnterTransitionImpl,里面的 TransitionData 的四个参数,优先选择等号左侧的,如果等号左侧没有配置某种动画,才会选择等号右侧的。
出场动画 ExitTransition 的内容与 EnterTransition 大同小异,只不过入场动画的 expandIn() 对应出场动画的 shrinkOut():
AnimatedVisibility(
shown,
enter = fadeIn() + expandIn(),
exit = fadeOut() + shrinkOut()
)
此外,Transition 还有一个 AnimatedVisibility() 的扩展函数:
@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.AnimatedVisibility(
visible: (T) -> Boolean,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = shrinkOut() + fadeOut(),
content: @Composable() AnimatedVisibilityScope.() -> Unit
) = AnimatedEnterExitImpl(this, visible, modifier, enter, exit, content)
第一个参数 visible 是返回 Boolean 类型的函数,其参数 T 是 Transition 的状态,你需要根据这个状态决定 AnimatedVisibility() 的内容是否可见并作为 visible 的返回值。
无论使用哪一种 AnimatedVisibility,它里面都只能存放一个 Composable 组件,放多了行为不正常。如果有多个 Composable 组件想使用 AnimatedVisibility,那么就为每一个组件配一个 AnimatedVisibility。
3.3 Crossfade()
Crossfade() 会以动画方式切换显示内容,动画是透明度渐变动画,也就是淡入淡出动画,与上一节介绍的 fadeIn()、fadeOut() 完全相同。我们直接来看 Crossfade() 的参数,因为这些参数在前面介绍其他 API 时已经见过很多次了:
@OptIn(ExperimentalAnimationApi::class)
@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)
}
targetState 是代表目标布局状态的键,每当更改一个键时,动画将被触发,使用旧键调用的 content 将淡出,而使用新键调用的 content 将淡入。
假设在未使用动画的情况下,根据不同状态显示相应组件的代码如下:
@Composable
fun CrossfadeSample() {
Column {
var shown by remember { mutableStateOf(false) }
if (shown) {
Box(
Modifier
.size(48.dp)
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
如果想实现动画切换效果,可以用 Crossfade() 包住负责组件切换的 if - else 代码块,并且将 if 的判断条件修改为参数传入的 state 对象:
@Composable
fun CrossfadeSample() {
Column {
var shown by remember { mutableStateOf(false) }
Crossfade(targetState = shown, animationSpec = tween(5000)) { state ->
if (state) {
Box(
Modifier
.size(48.dp)
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
这里照例是为了让动画效果更明显,将动画时间设置为 5 秒了,效果如下:
可以看到,在渐变过程中,就是两个组件同时存在的时间段内,它会让两个组件都完整显示,但是在渐变完成之后,就只保留新组件的尺寸范围。
由于 Crossfade() 就是一个功能很简单的函数,因此它不能修改动画类型,只能是淡入淡出动画。此外,Crossfade() 的状态值可以是任意类型,不止是上面示例的 Boolean 类型。比如 Int 类型,它可以有多个状态值,需要结合 when 使用。
3.4 AnimatedContent()
前两节我们讲了 AnimatedVisibility() 与 Crossfade(),它们有各自的适用场景:
- AnimatedVisibility():对单个组件的出现与消失的动画,可以配置多种动画规格(透明度、偏移位置、尺寸裁剪、缩放)
- Crossfade():对不同状态下的多个组件间切换施以透明度淡入淡出的动画效果,无法配置其他动画效果
AnimatedContent() 覆盖了上述两个函数的功能,可以对两个组件根据状态进行切换,并可以配置切换时的动画效果。它的使用方式也与上面的函数类似:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample() {
Column {
var shown by remember { mutableStateOf(false) }
AnimatedContent(shown) { state ->
if (state) {
Box(
Modifier
.size(48.dp)
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
效果如下:
这种效果是 AnimatedContent() 的 transitionSpec 参数的默认值配置出的动画效果:
@ExperimentalAnimationApi
@Composable
fun <S> AnimatedContent(
targetState: S,
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = {
fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) with
fadeOut(animationSpec = tween(90))
},
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
) {
val transition = updateTransition(targetState = targetState, label = "AnimatedContent")
transition.AnimatedContent(
modifier,
transitionSpec,
contentAlignment,
content = content
)
}
入场的两个动画 fadeIn() 与 scaleIn() 都是延迟 90ms 开始执行,持续 220ms。出场动画 fadeOut() 则是立即执行,时长 90ms。实际上就是 fadeOut() 执行完,让原始组件消失之后,再开始执行入场的两个动画。
再观察 transitionSpec 的返回值 ContentTransform:
@ExperimentalAnimationApi
class ContentTransform(
val targetContentEnter: EnterTransition,
val initialContentExit: ExitTransition,
targetContentZIndex: Float = 0f,
sizeTransform: SizeTransform? = SizeTransform()
) {
var targetContentZIndex by mutableStateOf(targetContentZIndex)
var sizeTransform: SizeTransform? = sizeTransform
internal set
}
ContentTransform 有四个属性:
- targetContentEnter:目标内容的入场动画
- initialContentExit:初始内容的出场动画
- targetContentZIndex:Z 轴的绘制顺序,用于配置各个组件间的覆盖关系
- sizeTransform:尺寸渐变动画
假如不想使用 AnimatedContent() 的参数 transitionSpec 的默认值,想要自己选取出入场动画,可以自己写一个返回 ContentTransform 的函数:
AnimatedContent(
shown,
transitionSpec = { ContentTransform(fadeIn(), fadeOut()) }
)
它还有一种等价的方式 —— 使用 EnterTransition 的扩展函数 with():
AnimatedContent(shown, transitionSpec = { fadeIn() with fadeOut() })
with() 实际上就是帮助我们实现了第一种方式:
@ExperimentalAnimationApi
infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)
假如还想修改出入场组件之间的覆盖关系,可以通过修改 targetContentZIndex 实现。默认情况下,targetContentZIndex 是无需配置的,因为入场的组件会在出场组件的上面,遮住出场组件,这个行为是符合预期与需求的。比如使用以下测试代码:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample1() {
Column {
var shown by remember { mutableStateOf(false) }
AnimatedContent(
shown,
transitionSpec = {
fadeIn(tween(3000)) with fadeOut(tween(3000, 3000))
}
) {
if (it) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
效果如下:
我们设置出场动画延迟 3s 再开始执行,目的是等待入场动画执行完毕后再开始出场动画,这样看的更清楚一些。上图可以明显看出,红色方块入场时先立即覆盖了即将出场的绿色方块,在入场动画结束后,绿色方块开始出场,左上角的绿色才缓缓消失。
但是有些情况下,默认行为不能满足开发需求,比如想让一个组件做背景,那么这个组件在动画过程中,不论是入场还是出场,它一定是永远在最下层的。再比如一个悬浮的按钮,在动画过程中永远都是在最上层的,不管是入场还是出场。因此我们需要酌情修改 targetContentZIndex。比如对于上面的例子,改为不论入场还是出场,红色方块永远在绿色方块下方:
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedContentSample2() {
Column {
var shown by remember { mutableStateOf(false) }
AnimatedContent(
shown,
transitionSpec = {
// targetState 就是 shown,当其为 true 时就是要显示红色方块了,此时将 targetContentZIndex 调小
if (targetState) {
(fadeIn(tween(3000)) with
fadeOut(tween(3000, 3000))).apply {
targetContentZIndex = -1f
}
} else {
fadeIn(tween(3000)) with fadeOut(tween(3000, 3000))
}
}
) {
if (it) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Red)
)
} else {
Box(
Modifier
.size(24.dp)
.background(Color.Green)
)
}
}
Button(onClick = { shown = !shown }) {
Text("切换")
}
}
}
由于 targetContentZIndex 的默认值是 0f,如果想让红色方块在下面,给它赋一个小于 0 的值即可:
最后看 sizeTransform 的设置,直接使用 SizeTransform():
@ExperimentalAnimationApi
fun SizeTransform(
clip: Boolean = true,
sizeAnimationSpec: (initialSize: IntSize, targetSize: IntSize) -> FiniteAnimationSpec<IntSize> =
{ _, _ -> spring(visibilityThreshold = IntSize.VisibilityThreshold) }
): SizeTransform = SizeTransformImpl(clip, sizeAnimationSpec)
clip 表示是否裁切,一般为 true;sizeAnimationSpec 是计算动画曲线的函数,返回 FiniteAnimationSpec,前面也说过多次了。
如果想把配置好的 SizeTransform 合并到现有的 ContentTransform 对象中,可以其扩展函数 using():
AnimatedContent(
shown,
transitionSpec = {
(fadeIn() + scaleIn(tween(500)) with fadeOut()) using SizeTransform()
}
)
两个入场动画合并通过重写 EnterTransition 的 plus 操作符实现:
sealed class EnterTransition {
@Stable
operator fun plus(enter: EnterTransition): EnterTransition {
return EnterTransitionImpl(
TransitionData(
fade = data.fade ?: enter.data.fade,
slide = data.slide ?: enter.data.slide,
changeSize = data.changeSize ?: enter.data.changeSize,
scale = data.scale ?: enter.data.scale
)
)
}
}
入场动画与出场动画合并使用 with():
@ExperimentalAnimationApi
infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)
最后将 SizeTransform 合并到已有的 ContentTransform 中,用 using():
@ExperimentalAnimationApi
class AnimatedContentScope<S> internal constructor(
@ExperimentalAnimationApi
infix fun ContentTransform.using(sizeTransform: SizeTransform?) = this.apply {
this.sizeTransform = sizeTransform
}
}
可以回头看一下 AnimatedContent() 的 transitionSpec 参数,就是通过以上方式将入场、出场效果配置在一起的。
最后,AnimatedContent() 也有一个 Transition 版本,内容与使用方式与本节讲述的通用版本类似:
@ExperimentalAnimationApi
@Composable
fun <S> Transition<S>.AnimatedContent(
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = {
fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) with
fadeOut(animationSpec = tween(90))
},
contentAlignment: Alignment = Alignment.TopStart,
contentKey: (targetState: S) -> Any? = { it },
content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
) {...}