Compose:动画

状态转移动画:animateXxxAsState

在传统的 View 系统我们想要实现动画有两种方式,一种是很早期的 Tween 补间动画,现在基本不使用,因为属性动画就能实现相关的功能,所以目前说到动画都是指的属性动画:

ObjectAnimator animator = ObjectAnimator.ofFloat(...);
animator.start();
或
view.animate().rotate(...).start();

但在 Compose 已经不是基于 View 系统,自然的也不能使用属性动画那套体系,而是另外开发了一套基于 Compose 的动画。

在讲解动画之前,如果我们在 Compose 想调整一个组件的大小,应该会这样写:

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContent {
			var size by remember { mutableStateOf(48.dp) }
			Box(
				Modifier
					.size(size)
					.background(Color.Green)
					.clickable { size = 96.dp }
			)
		}
	}
}

在这里插入图片描述

上面的例子中通过点击修改 Box 的大小从 48dp 调整到 96dp,但是效果是瞬间结束。

在 Compose 中这类状态转换的动画可以使用 animateXxxAsState 实现。比如 animateDpAsState、animateFloatAsState 等等:

在这里插入图片描述

animateXxxAsState 可以理解为它是 mutableStateOf 和 remember 的结合,返回的是 State

比如我们想要修改的是以 dp 为单位的尺寸,那么就是 animateDpAsState:

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContent {
			// 报错,提示 State 对象没有 setValue
			var size by animateDpAsState(48.dp) 
			Box(
				Modifier
					.size(size)
					.background(Color.Green)
					.clickable { size = 96.dp }
			)
		}
	}
}

但上面的代码会报错,提示 State 对象没有 setValue 方法:

在这里插入图片描述

mutableStateOf 返回的是 MutableState,MutableState 是 State 的子接口,它们不同的地方在于,State 的值是不能被修改的(不能被修改不代表值不能变化),如果要修改可以通过 Recompose,可以定义一个状态判断触发:

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		// 定义了一个 MutableState 用于状态切换
		var big by mutableStateOf(false) 
		setContent {
			// 点击 Box 时修改 big 状态触发 Recompose
			val size by animateDpAsState(if (big) 96.dp else 48.dp)
			Box(
				Modifier
					.size(size)
					.background(Color.Green)
					.clickable { big = !big }
			)
		}
	}
}

在这里插入图片描述

animateDpAsState 填入的数值即是初始值也是目标值,可以实现动画的底层逻辑是在 Recompose 时开启了一个协程,将数值渐变到达指定的数值

可以发现 Compose 的动画比属性动画在编写上还要简单,这主要归功于两点:

  • 第一点主要归功于 Compose 是声明式 UI 不需要拿操作的对象去手动处理动画

  • 第二点是 Compose 对设置初始值和目标值合为一体,进一步简化写法

既然简单易用,animateDpAsState 就会有一定的使用限制

  • 不能设置初始值:例子中初始值是 48dp,但如果想要自己设置初始值,是无法设置的

  • 不能直接跳过动画渐变过程:例子中初始值是 48dp,如果有需求要动画执行时从 120dp 到 96dp,是无法处理的

基于以上两个问题,很明显就需要能够有足够的扩展性能够自定义动画,这需要用到 Compose 提供的 Animatable。

流程定制型动画:Animatable

为什么 animateXxxAsState 能这么简单的就实现动画?它会和 Animatable 有什么联系吗?什么是 Animatable?我们带着问题先看下 animateXxxAsState 的源码:

AnimateAsState.kt

@Composable
fun animateDpAsState(
    targetValue: Dp,
    animationSpec: AnimationSpec<Dp> = dpDefaultSpring,
    finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
    return animateValueAsState(
        targetValue,
        Dp.VectorConverter,
        animationSpec,
        finishedListener = finishedListener
    )
}

@Composable
fun <T, V : AnimationVector> animateValueAsState(
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: AnimationSpec<T> = remember {
        spring(visibilityThreshold = visibilityThreshold)
    },
    visibilityThreshold: T? = null,
    finishedListener: ((T) -> Unit)? = null
): State<T> {
	// animateXxxAsState 的内部实现也是 Animatable
	val animatable = remember { Animatable(targetValue, typeConverter) }
	...
}

看源码可以发现,Animatable 是 animateXxxAsState 的动画实现,而且 Animatable 确实是有设置初始值的操作

或许你会有疑问:为什么 animateXxxAsState 是由 Animatable 扩展的,反而使用上受限了?

Animatable 要实现动画需要一定的步骤,其实 animateXxxAsState 是抛弃了 Animatable 可以设置初始值的功能,但是在使用上更便捷了,通过抛弃功能换取便捷。当然也是因为 animateXxxAsState 是场景化的,针对的就是状态切换,是 A 状态切换到 B 状态,所以是不需要初始值的

那我们尝试下怎么使用 Animatable 创建一个动画:

Animatable.kt

// 两个参数的 Animatable() 是一个函数不是构造函数
fun Animatable(
	initiaValue: Float,
	visibilityThreshold: Float = Spring.DefaultDisplacementThreshold
) = Animatable( // 这里才是 Animatable 的构造函数
	initiaValue,
	Float.VectorConverter, // 默认传入 Float.VectorConverter
	visibilityThreshold
)

// 具体使用:
// Animatable 在计算时不是用 dp,而是 float 计算的
// dp 要转成 float,所以要加上 Dp.VectorConverter
val anim = remember { Animatable(48.dp, Dp.VectorConverter) }

使用 Animatable 需要设置一个初始值,而且 Animatable 在计算时并不是使用 dp 单位,而是统一使用 float 计算的,要将 dp 转换成 float 或者 float 转换成 dp 的算法,所以还需要你提供一个 TwoWayConverter 对类型做转换,默认提供了 Float.VectorConverter:

VectorConverters.kt

interface TwoWayConverter<T, V : AnimationVector> {
    val convertToVector: (T) -> V
    val convertFromVector: (V) -> T
}

好消息是 Compose 已经提供了常用的一些 TwoWayConverter,比如 Dp.VectorConverter、Color.VectorConverter、Int.VectorConverter、Float.VectorConverter 等等。

疑惑又来了:Float.VectorConverter,float 也需要转为 float?

解答这个疑惑前我们看下 Dp.VectorConverter 的源码:

VectorConverters.kt

val Dp.Companion.VectorConverter: TwoWayConverter<Dp, AnimationVector1D>
	get() = DpToVector

private val DpToVector: TwoWayConverter<Dp, AnimationVector1D> = TwoWayConverter(
    convertToVector = { AnimationVector1D(it.value) },
    convertFromVector = { Dp(it.value) }
)

Dp.VectorConverter 返回的类型是 TwoWayConverter<Dp, AnimationVector1D>,你会很奇怪这个 AnimationVector1D 是什么东西?为什么不是 Float?

在 Compose 动画都是用 Float 计算的,而 AnimationVector1D 内部就是用的 Float 存储的,Compose 对百分比的计算它给了四种级别的选择,你可以只使用一个 float,它是一维的选择对应 AnimationVector1D,还有二维用两个 float 来显示,比如坐标是横纵两个坐标用两个 float 会很方便,对应的就是 AnimationVector2D,还有三维 AnimationVector3D、四维 AnimationVector4D

刚才说到 Compose 动画都是用 Float 计算的,我们可以看 FloatVectorConverter 的源码:

VectorConverters.kt

val Float.Companion.VectorConverter: TwoWayConverter<Float, AnimationVector1D>
	get() = FloatToVector

// Float 转 AnimationVector1D 是直接把自己的值传给 AnimationVector1D(it)
// AnimationVector1D 转 Float 就是直接把值拿出来 it.value
private val FloatToVector: TwoWayConverter<Float, AnimationVector1D> =
	TwoWayConverter({ AnimationVector1D(it), { it.value } })

可以看到 Float 要转换实际上是要告知 Compose 是用几维的,FloatToVector 的实现也是直接设值和取值。

创建了 Animatable 后,还需要让动画能够动起来,接着例子继续说明:

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		var big by mutableStateOf(false)
		setContent {
			val size = remember(big) { if (big) 96.dp else 48.dp }
			val anim = remember { Animatable(size, Dp.VectorConverter) }
			LaunchedEffect(big) { // 状态改变时重新启动协程
				// 执行动画会需要启动一个协程,会将动画计算结果同步到主线程
				anim.animateTo(size)
			}
			Box(
				Modifier
					.size(anim.value)
					.background(Color.Green)
					.clickable { big = !big }
			)
		}
	}
}

在这里插入图片描述

Animatable 要执行动画需要使用 animateTo 函数,但这个函数是需要运行在协程上的

创建协程我们都知道使用 lifecycleScope.launch{},但是在 Compose 是不能这么写的,因为这种协程的创建在 Recompose 是不带优化的,执行 Recompose 时不会跳过而是会重新启动一个新的协程,这会导致程序混乱

在 Compose 启动协程使用 LaunchedEffect,而为了能在参数状态改变的时候重新启动协程,LaunchEffect(key) 需要传入参数;只执行一次可以写成 LaunchEffect(Unit)。

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		var big by mutableStateOf(false)
		setContent {
			val size = remember(big) { if (big) 96.dp else 48.dp }
			val anim = remember { Animatable(size, Dp.VectorConverter) }
			LaunchedEffect(big) {
				// 设置初始值的效果,snapTo() 会直接设置到指定的效果,瞬时完成
				anim.snapTo(if (big) 192.dp else 0.dp)
				anim.animateTo(size)
			}
			Box(
				Modifier
					.size(anim.value)
					.background(Color.Green)
					.clickable { big = !big }
			)
		}
	}
}

在这里插入图片描述

snapTo 可以直接设置调整到指定的大小,并且是瞬时完成的

animateXxxAsState 和 Animatable 使用场景的选择:

  • 如果是状态切换转移的场景使用 animateXxxAsState

  • 如果要定制动画处理过程就使用 Animatable

动画配置:AnimationSpec

实现动画的方式都比较简单,目前主要有两种方式:一种是状态转移 animateXxxAsState,另一种是自定义动画 Animatable。接下来讲解下动画的配置。

AnimationSpec 是用于做动画配置的,默认情况下是使用的弹簧(但不回弹)效果:

AnimateAsState.kt

@Composable
fun animateDpAsState(
    targetValue: Dp,
    animationSpec: AnimationSpec<Dp> = dpDefaultSpring, // 提供了默认动画配置
    finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
    return animateValueAsState(
        targetValue,
        Dp.VectorConverter,
        animationSpec,
        finishedListener = finishedListener
    )
}

private val dpDefaultSpring = spring<Dp>(visibilityThreshold = Dp.VisibilityThreshold)

AnimationSpec.kt

@Stable
fun <T> spring(
    dampingRatio: Float = Spring.DampingRatioNoBouncy, // 默认不回弹
    stiffness: Float = Spring.StiffnessMedium,
    visibilityThreshold: T? = null
): SpringSpec<T> =
    SpringSpec(dampingRatio, stiffness, visibilityThreshold)

当然这只是其中一种动画配置效果,具体的继承树如下:

在这里插入图片描述

可以看到 AnimationSpec 是一个接口,还有相关的子接口已经子接口对应的实现类。

看着动画的配置还是很多的,那该怎么区分它们?又该怎么很好的理解掌握?

刚才有讲到默认动画使用的弹簧即 SpringSpec,但为了能更好的理解整套动画配置,我们会先从 TweenSpec 开始讲起,因为它是最简单的。

TweenSpec

虽然 TweenSpec 带有 Tween 这个单词,早期传统 View 系统是用的 Tween 补间动画,但这里的 TweenSpec 和传统 View 系统的补间动画没有任何关系。

@Immutable
class TweenSpec<T>(
	// 动画时长,默认300ms
	val durationMillis: Int = DefaultDurationMillis, 
	// 延时启动动画的时间
	val delay: Int = 0, 
	// 运动曲线模型,相当于属性动画的 Interpolator
	val easing: Easing = FastOutSlowInEasing Interpolator
): DurationBasedAnimationSpec<T> {
	...
}

TweenSpec 前两个参数很好理解,分别是配置动画时长和动画延时执行的时间,我们主要讲一下 easing 这个参数。

Easing(中文翻译为缓动,其实动画基本都是渐变的过程这很好理解)能配置动画的运动曲线模型,给定一个动画的时长和一个动画的运动曲线模型,然后 TweenSpec 就会自动帮你计算出每一时刻动画的完成度

所谓动画的运动曲线模型就是配置动画是线性的、还是先加速后减速、先减速后加速,它和属性动画 Interpolator 是一个意思。如果我们要设置它,Compose 提供了各种运动曲线模型:

Easing.kt

// 相当于属性动画的 AccelerateDecelerateInterpolator,先加速后减速
val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)

// 相当于属性动画的 DecelerateInterpolator,减速直到停止,默认值
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)

// 相当于属性动画的 AccelarateInterpolator,加速直到停止
val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)

// 相当于属性动画的 LinearInterpolator
val LinearEasing: Easing = Easing { fraction -> fraction }

在使用上也很简单:

var big by mutableStateOf(false)
setContent {
	val size = remember(big) { if (big) 96.dp else 48.dp }
	val anim = remember { Animatable(size, Dp.VectorConverter) }
	LaunchedEffect(big) {
		// anim.animateTo(size, TweenSpec(easing = FastOutLinearInEasing))
		// 将运动曲线修改为线性
    	anim.animateTo(size, tween(easing = FastOutLinearInEasing))	
	}
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

AnimationSpec 的配置提供了两种写法,一种是直接通过构造函数创建 TweenSpec,另一种是简便写法 tween()。

如果我们想自定义曲线模型,也可以自定义一个 Easing,或者说可以自定义一个三阶贝塞尔曲线。

FastOutSlowInEasing、LinearOutSlowInEasing、FastOutLinearInEasing 都是用的三阶贝塞尔曲线 CubicBezierEasing 实现的。

三阶贝塞尔曲线是由四个点来表示一个曲线,但 CubicBezierEasing 只提供了四个参数,也就是只有两个点的坐标,这是为什么呢?实际上 三阶贝塞尔曲线有两个点是固定的,分别是 (0, 0) 和 (1, 1),让我们填写的参数就是剩下的两个点的坐标

那到底怎么用三阶贝塞尔曲线自定义设计一个 Easing?剩下的两个坐标点要怎么计算?其实很简单,我们可以访问一个网站:https://cubic-bezier.com,这个网站是专门用来展示由三阶贝塞尔曲线所决定的动画的曲线的:

在这里插入图片描述
横坐标是时间完成度,纵坐标是动画完成度,曲线的斜率就是每一时刻动画的速度,越陡峭的位置动画速度越快,所以如果我想让动画先加速后减速,就是开始和结束的曲线先平一点,中间陡峭。

但大多数情况下我们都不需要自定义 Easing,Compose 提供了的几种 Easing 已经够用

SnapSpec

SnapSpec 和 snapTo 是一样的效果,直接变成目标值没有任何动画效果。我们将 snapTo 改成 SnapSpec:

var big by mutableStateOf(false)
setContent {
	val size = remember(big) { if (big) 96.dp else 48.dp }
	val anim = remember { Animatable(size, Dp.VectorConverter) }
	LaunchedEffect(big) {
		// anim.snapTo(if (big) 192.dp else 0.dp)
		// anim.animateTo(size, SnapSpec(delay = 1000))
		anim.animateTo(size, snap(delayMillis = 1000)) // 延时1s后再执行到指定目标值
	}
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

和使用 snapTo 不同的是,使用 SnapSpec 可以添加一个延时执行的时间

KeyframesSpec

Keyframe 就是关键帧的意思,关键帧可以让我们在动画过程中去选取几个关键的时间点,并给出这些对应时间点动画的完成度。

KeyframesSpec 就是可以帮我们计算出动画完整的速度曲线,简单理解 KeyframesSpec 就是分段式的 TweenSpec

该怎么理解什么叫分段式的 TweenSpec?还是用示例代码说明:

var big by mutableStateOf(false)
setContent {
	val size = remember(big) { if (big) 96.dp else 48.dp }
	val anim = remember { Animatable(size, Dp.VectorConverter) }
	LaunchedEffect(big) {
		// anim.animateTo(size, KeyframesSpec(KeyframesSpec.KeyframesSpecConfig<Dp>().apply {
		// 	填写关键帧信息...
		// }))		
		
		// 简便写法,lambda 写关键帧信息
		// 默认动画 duration=300ms,我们指定了动画结束目标值是 96dp
		// 而这里关键帧是在 150ms 时到 144dp
		// 所以动画效果是执行到一半时到 144dp,然后又再缩回来到 96dp 结束
		anim.animateTo(size, keyframes {
			// 在150ms的时候要运动到144dp的位置
			144.dp at 150 
		})
	}
	Box(
	  Modifier
		.size(anim.value)
		.background(Color.Green)
		.clickable {
			big = !big
		}
	)
}

在这里插入图片描述

因为默认动画 duration 是 300ms,当我们点击 Box 时,big 状态改变,我们指定了此时动画结束目标值为 96dp,因为我们定义了一个关键帧在动画执行到 150ms 的时候尺寸大小到达指定 144dp,所以最终效果是动画执行到一半时从 48dp 到 96dp 之间,突然变大到 144dp,然后又再缩回来到 96dp 结束。

在这里插入图片描述

在没有关键帧的时候,动画只有起点和终点,按上面的例子就是 0ms 和 300ms,初始值是 48dp,而加入了关键帧之后我们的动画就是三个点了,这就把动画切成了两部分,0ms 到 150ms,150ms 到 300ms,这就变成了两段动画了,只不过两段动画是连续的。

所以在上面为什么说 KeyframesSpec 可以理解为分段式的 TweenSpec。

关键帧也可以设置动画执行的时长和延时执行:

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        anim.animateTo(size, keyframes {
            144.dp at 150
            // 设置整体动画时长
            durationMillis = 450
            // 设置延时
            delayMillis = 500
        })
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

关键帧还能设置对应帧之间的运动曲线,但需要注意的是,设置的关键帧运动曲线是指的以这一帧为起点到后面一帧或动画结束时的运动曲线;在没有写关键帧运动曲线的时候,默认是使用的线形运动曲线,这一点和 TweenSpec 默认使用先加速后减速的运动曲线不同:

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        anim.animateTo(size, keyframes {
            // 如果要从开始就使用速度曲线,需要在关键帧设置初始值和 0ms 时候的处理
            48.dp at 0 with FastOutLinearInEasing
            
            // 关键帧设置动画速度曲线,是从这里开始到后面的速度曲线
            144.dp at 150 with FastOutSlowInEasing

            // 关键帧默认不写是使用的线性运动曲线
            20.dp at 300 with LinearEasing
        })
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

虽然 KeyframesSpec 功能很强大能分段设置动画效果,不过相应的动画的复用性就有所降低,只能使用在特定的自定义处理上。比如两个 KeyframesSpec 的处理是相反的动画效果,那就只能是写两个 KeyframesSpec,分别提供给 animateTo 调用。

TweenSpec、SnapSpec、KeyframesSpec 的相同点

在这里插入图片描述

我们重新看回 AnimationSpec 的继承树,TweenSpec、SnapSpec 和 KeyframesSpec 都是实现自 DurationBasedAnimationSpec 接口,DurationBasedAnimationSpec 就是动画时间确定的 AnimationSpec

什么是动画时间确定?简单理解就是可以手动指定动画在什么时候结束。这么反问或许你也能猜到,是有动画时间不确定的动画,例如 SpringSpec、RepeatableSpec、InfiniteRepeatableSpec。

SpringSpec

TweenSpec、SnapSpec、KeyframesSpec 都是动画时长确定的 AnimationSpec,而 Compose 也确实也有动画时长不确定的 AnimationSpec,比如现在要讲的 SpringSpec 弹簧效果,它是基于物理模型的需要实时的计算动画时长,所以它的动画时长啥时候结束是不确定的。Compose 默认的动画就是 SpringSpec。

var big by mutableStateOf(false)
setContent {
	val size = remember(big) { if (big) 96.dp else 48.dp }
	val anim = remember { Animatable(size, Dp.VectorConverter) }
	LaunchedEffect(big) {
		// 弹簧运动曲线是先加速后减速
		// anim.animateTo(size, SpringSpec())) 
		anim.animateTo(size, spring()) 
	}
	Box(
		Modifier
			.size(anim.value)
			.background(Color.Green)
			.clickable {
				big = !big
			}
	)
}

弹簧效果也是可以设置不同的弹簧实现效果,我们看下 SpringSpec 的源码:

AnimationSpec.kt

@Stable
fun <T> spring(
	dampingRatio: Float = Spring.DampingRatioNoBouncy, 
	stiffness: Float = Spring.StifnessMedium,
	visibilityThreshold: T? = null
): SpringSpec<T> =
	SpringSpec(dampingRatio, stiffness, visibilityThreshold)
  • dampingRatio:阻尼比设置弹簧有多弹,默认是不弹,数值越小来回弹动越频繁。理论上设置为 0f 时是不停止,但是 Compose 做了处理会直接到达弹簧最终点,不过能设置大于 1f 的数值,就是弹得很慢
// Compose 提供的 dampingRatio 数值
object Spring {
    const val DampingRatioHighBouncy = 0.2f
    const val DampingRatioMediumBouncy = 0.5f
    const val DampingRatioLowBouncy = 0.75f
    const val DampingRatioNoBouncy = 1f
    const val DefaultDisplacementThreshold = 0.01f                                
}
  • stiffness:刚度设置弹簧拉起来的时候有多想变回去,越硬的弹簧就越想变回去,弹的时候越快越高
// Compose 提供的 stiffness 数值
object Spring {
    const val StiffnessHigh = 10_000f
    const val StiffnessMedium = 1500f
    const val StiffnessLow = 200f
    const val StiffnessVeryLow = 50f                              
}
  • visibilityThreshold:可见阈值,它和弹簧模型无关,但和动画精确度有关。理论上弹簧是可以弹很久,即使在视觉上已经有了效果但还是在弹,这只会白白浪费手机性能,所以可以设置一个阈值,当到达一个数值时就让弹簧可以停下来不弹了;设置它是为了防止阈值过小导致肉眼已经看不出变化了,弹簧还在弹的情况

到这里 SpringSpec 也讲得差不多了,不过还有一种场景可以用 SpringSpec 实现,比如微信炸弹的原地震动效果,还需要设置 initialVelocity,这算是 SpringSpec 的一种特殊用法:

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        // 原地弹动,initialVelocity 设置 2000dp
        anim.animateTo(48.dp, spring(0.1f, Spring.StiffnessMedium), 2000.dp)
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

RepeatableSpec

顾名思义 RepeatableSpec 就是一个可以让动画重复执行的 AnimationSpec,使用 RepeatableSpec 需要传入一个其他的 AnimationSpec 指定它要重复执行:

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        anim.animateTo(size, repeatable(3, tween()))  // 重复3次
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

简单看下 RepeatableSpec 有哪些参数:

AnimationSpec.kt

@Stable
fun <T> repeatable(
	// 重复的次数,不能填0
	iterations: Int, 
	// 指定重复的动画,只能是 TweenSpec、SnapSpec、KeyframesSpec
	animation: DurationBasedAnimationSpec<T>,
	// 重复模式,重新执行或倒放执行 
	repeatMode: RepeatMode = RepeatMode.Restart, 
	// 启动偏移
	initialStartOffset: StartOffset = StartOffset(0) 
): RepeatableSpec<T> =
	RepeatableSpec(iterations, animation, repeatMode, initialStartOffset)

需要注意的是,当设置 repeatMode 为 RepeatMode.Reverse 时,iterations 重复次数不能是偶数:

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        // 执行效果会出问题
        anim.animateTo(size, repeatable(2, tween(), RepeatMode.Reverse))
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

按上面的例子,如果我们设置重复次数为 2 次倒放执行就会出问题,效果就是从 48dp 到 96dp,然后再从 96dp 到 48dp,最后突然变成 96dp。我们的预想是最终会停在 48dp,怎么又会回到 96dp 了?

会出现这个问题是因为,我们设置的动画结束目标值是 96dp,所以在动画 reverse 时会立刻从 48dp 回到目标值 96dp 结束动画。

当然这样并不是说 RepeatableSpec 是设计有问题的或者是 bug,而是 AnimationSpec 作为动画配置,它只是配置但不能改变动画最终的行为,比如偶数的倒放执行不能执行完就停在 48dp,最终效果是执行到目标值 96dp 是合理的,也应该这么做,否则动画控制不就乱套了

还有最后一个参数 initialStartOffset 启动偏移,它有两种设置方式,一种是延时偏移:

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        // 延时偏移,每次都在 500ms 后再延时执行
        anim.animateTo(
            size,
            repeatable(
                iterations = 2,
                animation = tween(),
                repeatMode = RepeatMode.Restart,
                initialStartOffset = StartOffset(offsetMillis = 500, StartOffsetType.Delay)
            )
        )
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

另一种是快进偏移:

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        // 快进偏移,直接到指定的时间点执行动画
        anim.animateTo(
            size,
            repeatable(
                iterations = 2,
                animation = tween(),
                repeatMode = RepeatMode.Restart,
                initialStartOffset = StartOffset(offsetMillis = 500, StartOffsetType.FastForward)
            )
        )
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

InfiniteRepeatableSpec

相比 RepeatableSpec 可以指定重复次数,InfiniteRepeatableSpec 是无限循环的重复,而无限循环的停止就是动画停止的时候,也就是协程 LaunchedEffect 执行完成或 Recompose 的时候

var big by mutableStateOf(false)
setContent {
    val size = remember(big) { if (big) 96.dp else 48.dp }
    val anim = remember { Animatable(size, Dp.VectorConverter) }
    LaunchedEffect(big) {
        // 协程 LaunchedEffect 结束或 Recompose 时就会停止无限循环
        anim.animateTo(
            size,
            infiniteRepeatable(
                animation = tween(),
                repeatMode = RepeatMode.Restart,
                initialStartOffset = StartOffset(500, StartOffsetType.FastForward)
            )
        )
    }
    Box(
        Modifier
            .size(anim.value)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

其他 AnimationSpec

FloatTweenSpec 和 FloatSpringSpec

var big by mutableStateOf(false)
setContent {
	val size = remember(big) { if (big) 100f else 50f }
	val anim = remember { Animatable(size) }
	LaunchedEffect(big) {
		// 虽然提供了 FloatTweenSpec 和 FloatSpringSpec
		// 但实际上我们不需要用它们,直接使用 tween() 和 spring() 就能满足
		anim.animateTo(200f, FloatTweenSpec())
	}
	Box(
		Modifier
			.size(anim.value)
			.background(Color.Green)
			.clickable {
				big = !big
			}
	)
}

FloatTweenSpec 和 FloatSpringSpec 其实就是指定了 Float 类型的 AnimationSpec,它们主要是给 Compose 动画更底层的计算做辅助工作用的两个类。在实际开发中,我们用 tween 和 spring 就行了,不需要用到它们。

VectorizedAnimationSpec

先看下 VectorizedAnimationSpec 的继承树:

在这里插入图片描述

可以看到 VectorizedAnimationSpec 也是一个接口,并且上面的继承树好像和 AnimationSpec 的继承树基本一样。

Compose 为什么会弄多一套不同的 AnimationSpec?

在讲解 AnimationSpec 前我们有提到,Compose 的动画都是会做转换的,并不是直接将 Dp、Color 做计算,而是转成 AnimationVector,AnimationVector 有几个子类 AnimationVector1D、AnimationVector2D、AnimationVector3D、AnimationVector4D 分别对应一维到四维,最终它们都会转换为 Float,Compose 是通过这四个类去做计算的

VectorizedAnimationSpec 和一大堆子类实现都是针对的 AnimationVector,它们和 AnimationSpec 是一一对应的,都是去做它们的底层计算的,我们每一个 AnimationSpec 最终都会被转成对应的 VectorizedAnimationSpec,Vectorized 就是矢量的意思,矢量也就是 AnimationVector

在我们平常使用上其实不需要了解它。

消散(衰减)型动画 animateDecay

在上面的节点我们讲解了 Animatable 的 animateTo、snapTo,还有一个没有讲的就是 animateDecay 消散型动画。消散型动画就是有一个初速度,在动画过程中好像有一个阻力一样慢慢的减速,最终停下来,这就是消散

或许你会有疑惑:这样的效果我用 animateTo() 也能实现:

var offset by mutableStateOf(0.dp)
setContent {
    val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
    LaunchedEffect(offset) {
        anim.animateTo(offset, tween(easing = LinearOutSlowInEasing))
    }
    Box(
        Modifier
            .padding(0.dp, anim.value, 0.dp, 0.dp)
            .size(100.dp)
            .background(Color.Green)
            .clickable {
                offset = 96.dp
            }
    )

在这里插入图片描述

那为什么 Compose 团队还要再提供一个 animateDecay 的函数呢?

实际上 animateDecay 它是有明确和单一的场景,它会用在惯性滑动的场景,比如滑动列表松手之后的惯性滑动慢慢停止,就是 animateDecay 的使用场景

它和 animateTo 的使用场景有两点不同:

  • animateDecay 的初始速度需要精确的,它的初始速度是手指松手时候的速度。滑动列表松手时速度快初始速度就大,松手速度速度慢初始速度就小

  • animateDecay 这种惯性衰减对动画目标值是不做要求的,就是对于停留位置它不做要求。初始速度快就停留远一点,初始速度慢就停留近一点(animateDecay 也确实不需要填写目标值参数)

所以 animateTo 和 animateDecay 的区别就是,animateTo 瞄准的就是目标值,而 animateDecay 是根据初始速度动态计算最终停留的目标值位置

setContent {
	val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
	val decay = remember { exponentialDecay<Dp>() }
	LaunchedEffect(Unit) {
		delay(1000)
		anim.animateDecay(1000.dp, decay)	
	}

	Box(
	  Modifier
	  	.padding(0.dp, anim.value, 0.dp, 0.dp)
	  	.size(100.dp)
	  	.background(Color.Green)
	)
}

接下来具体看下 animateDecay 有哪些相关配置:

Animatable.kt

suspend fun animateDecay(
    initialVelocity: T,
    animationSpec: DecayAnimationSpec<T>,
    block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> {
    val anim = DecayAnimation(
        animationSpec = animationSpec,
        initialValue = value,
        initialVelocityVector = typeConverter.convertToVector(initialVelocity),
        typeConverter = typeConverter
    )
    return runAnimation(anim, initialVelocity, block)
}
  • initialVelocity:初始速度,单位是每秒多少个。比如使用的 dp,就是每秒多少个 dp

  • animationSpec:配置 DecayAnimationSpec。需要注意的是,DecayAnimationSpec 和 AnimationSpec 是不兼容的,没有继承关系;也就是使用 animateTo 用 AnimationSpec,使用 animateDecay 用 DecayAnimationSpec

DecayAnimationSpec.kt

interface DecayAnimationSpec<T> {
    fun <V : AnimationVector> vectorize(
        typeConverter: TwoWayConverter<T, V>
    ): VectorizedDecayAnimationSpec<V>
}

// 构造是私有的
private class DecayAnimationSpecImpl<T>(
    private val floatDecaySpec: FloatDecayAnimationSpec
) : DecayAnimationSpec<T> {
    override fun <V : AnimationVector> vectorize(
        typeConverter: TwoWayConverter<T, V>
    ): VectorizedDecayAnimationSpec<V> = VectorizedFloatDecaySpec(floatDecaySpec)
}

DecayAnimationSpec 只有一个具体实现 DecayAnimationSpecImpl,但它的构造是私有的,也就是说不能直接通过构造函数创建它。Compose 提供了三个函数创建 DecayAnimationSpec:exponentialDecay()、splineBasedDecay(density)、rememberSplineBasedDecay()。

splineBasedDecay/rememberSplineBasedDecay

SplineBasedDecay.kt

fun <T> splineBasedDecay(density: Density): DecayAnimationSpec<T> =
    SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()

splineBasedDecay 是基于 spline 惯性滑动曲线算法实现的惯性滑动处理,Android 列表自带的惯性滑动算法也是使用这个,即 OverScroller 的惯性滑动计算也是用这个算法。Compose 团队将 Android 的惯性滑动算法原封不动的使用到 Compose。

SplineBasedFloatDecayAnimationSpec.android.kt

@Composable
actual fun <T> rememberSplineBasedDecay(): DecayAnimationSpec<T> {
    // This function will internally update the calculation of fling decay when the density changes,
    // but the reference to the returned spec will not change across calls.
    val density = LocalDensity.current
    return remember(density.density) {
        SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()
    }
}

DecayAnimationSpec.kt

fun <T> FloatDecayAnimationSpec.generateDecayAnimationSpec(): DecayAnimationSpec<T> {
    return DecayAnimationSpecImpl(this) 
}

rememberSplineBasedDecay 就是带 remember 的 splineBasedDecay,在日常开发中我们直接使用它即可

rememberSplineBasedDecay 和 splineBasedDecay 还有一个区别是,splineBasedDecay 需要传入 density 参数,density 参数的作用是将惯性滑动中的摩擦力和手机屏幕像素密度关联起来,具体说就是像素密度越大的设备摩擦力越大,从而惯性滑动减速越猛,即像素密度越大动画越快停止下来

// 提供gif

需要注意的是,splineBasedDecay<>(density) 填写的泛型不要使用 Dp 单位,因为 splineBasedDecay 是面向像素单位的,它根据像素密度 density 对惯性滑动进行修正,不同像素密度的设备会修正减速曲线,这就会出现不同像素密度的设备动画效果会有所不同,它本身已经是将 Dp 修正后的函数,如果还去填写本身就不用修正的 Dp 为单位就会出问题

所以使用 splineBasedDecay 和 rememberSplineBasedDecay 时是不能用 Dp 单位的,而是要用像素为单位

exponentialDecay

DecayAnimationSpec.kt

fun <T> exponentialDecay(
    /*@FloatRange(
        from = 0.0,
        fromInclusive = false
    )*/
    frictionMultiplier: Float = 1f,
    /*@FloatRange(
        from = 0.0,
        fromInclusive = false
    )*/
    absVelocityThreshold: Float = 0.1f
): DecayAnimationSpec<T> =
    FloatExponentialDecaySpec(frictionMultiplier, absVelocityThreshold).generateDecayAnimationSpec()

exponential 直译为指数型,exponentialDecay 就是指数型衰减。它提供了两个参数:

  • frictionMultiplier:摩擦力系数,数值越大摩擦力越大,停得越快

  • absVelocityThreshold:速度阈值绝对值

// gif

splineBasedDecay 和 exponentialDecay 小结

  • splineBasedDecay/rememberSplineBasedDecay:对动画有修正,只能用于像素单位,用像素就直接用它

  • exponentialDecay:没有修正用于像素外的其他单位例如 Dp 单位,用 Dp 就直接用它

监听动画每一帧

常规的三种动画实现 animateTo、snapTo、animateDecay 都已经讲解完了,其中 animateTo、animateDecay 函数都提供了一个 block 参数,它是什么作用呢?

Animatable.kt

suspend fun animateTo(
	targetValue: T,
	animationSpec: AnimationSpec<T> = defaultSpringSpec,
	initialVelocity: T = velocity,
	block: (Animatable<T, V>.() -> Unit)? = null 
) {
	...
}

suspend fun animateDecay(
    initialVelocity: T,
    animationSpec: DecayAnimationSpec<T>,
    block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> {
    ...
}

block 参数是一个函数类型的参数,它的作用是监听动画执行的每一帧,也就是每一次动画刷新都会触发执行 block 的 lambda

比如可以实现跟随移动:

setContent {
	val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
	var padding by remember { mutableStateOf(anim.value) }
	val decay = remember { exponentialDecay<Dp>() }
	LaunchedEffect(Unit) {
		delay(1000)
		anim.animateDecay(1000.dp, decay) {
			// 监听绿色 Box 的每一帧,让红色 Box 跟随绿色 Box 移动
			padding = value
		}
	}
	Row {
		Box(
		  Modifier
		  	.padding(0.dp, anim.value, 0.dp, 0.dp)
		  	.size(100.dp)
		  	.background(Color.Green)
		)
		// 跟随绿色的 Box 一块动
		Box(
		  Modifier
		  	.padding(0.dp, padding, 0.dp, 0.dp)
		  	.size(100.dp)
		  	.background(Color.Red)
		)
	}
}

在这里插入图片描述

动画的边界、结束和取消

我们在开发中经常会遇到,当一个动画在执行过程中,要开启另一个新动画的时候,要将上一个动画停止的处理;在 Compose 中因为是属于强制掐断动画执行,动画是执行在协程中的,也就是说,动画的强制停止会触发协程停止,具体来讲就是协程抛出 CancellationException

setContent {
	val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
	val decay = remember { exponentialDecay<Dp>() }
	LaunchedEffect(Unit) {
		delay(1000)
		// 为了查看效果添加try-catch
		try {
			// 动画被打断抛出异常
			anim.animateDecay(2000.dp, decay)
		} catch (c: CancellationException) {
			println("动画被打断了")
		}
	}
	// 动画执行500ms后突然往上移动,证明上面的动画被打断
	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)
	)
}

在这里插入图片描述

这是 第一种动画被打断的场景:新动画要执行,旧动画会被打断强制停止并抛出异常

第二种场景是主动停止动画,只需要 animatable.stop() 一句代码就可以完成,但使用这个函数有三个注意事项:

  • 该函数是一个挂起函数,所以要在协程执行

  • 该函数不能和要被停止的动画放在同一个协程里面(animateTo/animateDecay 调用后再调用 stop,会先执行 animateTo、animateDecay,执行结束后才会执行它,相当于没调用),它要在另外的一个单独的协程执行它

  • 调用 stop 也是会抛出 CancellationException

setContent {
	val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
	val decay = remember { exponentialDecay<Dp>() }
	LaunchedEffect(Unit) {
		delay(1000)
		anim.animateDecay(2000.dp, decay)
		// 在协程下是先执行完 animateDecay,然后再调用 stop,相当于它没调用
		// anim.stop() 
	}
	LaunchedEffect(Unit) {
		delay(1300)
		// 这里只是为了演示将它写在 LaunchedEffect 的协程
		// 实际使用场景它会是在点击或其他地方被调用,应该使用 lifecycleScope 协程下调用它
		anim.stop() 
	}
	Box(
	  Modifier
	  	.padding(0.dp, anim.value, 0.dp, 0.dp)
	  	.size(100.dp)
	  	.background(Color.Green)
	)
}

在这里插入图片描述

还有第三种场景:动画边界的触达,比如动画在惯性滑动过程中到达屏幕边界动画被迫暂停了,这种就是动画边界

setContent {
	BoxWithConstraints {
		val anim = remember { Animatable(0.dp, Dp.VectorConverter) }
		val decay = remember { exponentialDecay<Dp>() }
		LaunchedEffect(Unit) {
			delay(1000)
			anim.animateDecay(2000.dp, decay)
		}
		// BoxWithConstraints 提供 maxWidth
		// 要减去 Box 的宽度 100dp,实现 Box 移动到屏幕右边界时停止,而不是滑出屏幕
		// updateBounds 调整动画边界,可以调整 upperBound 和 lowerBound 上下边界
		anim.updateBounds(upperBound = maxWidth - 100.dp) 
		Box(
		  Modifier
		  	.padding(anim.value, 0.dp, 0.dp, 0.dp)
		  	.size(100.dp)
		  	.background(Color.Green)
		)
	}
}

在这里插入图片描述

在 Compose 将动画正常停止归类为两种

  • 动画执行到结束正常结束

  • 动画执行到边界停止

其他的都算是非正常停止动画。这种动画执行到边界停止的方式是不会抛出 CancellationException,这是它和其他让动画停止的方式最大的不同

到这里 Compose 动画边界的知识点还没说完,因为 Compose 是支持多维动画的,也就是 AnimationVector1D-4D,在多维动画时 Compose 又会怎么处理呢?是一个维度的停止了,另一个维度继续执行动画?

Compose 对多维动画的边界处理方式是,如果其中一个维度遇到动画边界停止了,那么就停止所有维度的动画。但我们可能会有需求在其中一边遇到动画边界停止了,另一边继续执行动画又应该怎么做呢?实际上 animateTo、animateDecay 它们都有返回值 AnimationResult,通过它我们可以知道动画停止的状态和原因:

Animatable.kt

suspend fun animateTo(
	targetValue: T,
	animationSpec: AnimationSpec<T> = defaultSpringSpec,
	initialVelocity: T = velocity,
	block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> {
	...
}

suspend fun animateDecay(
	initialVelocity: T,
	animationSpec: DecayAnimationSpec<T>,
	block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> {
	...
}

class AnimationResult<T, V: AnimationVector>(
	val endState: AnimationState<T, V>,
	val endReason: AnimationEndReason
)

所以有需求场景需要在多维度动画遇到动画边界其中一边停止时,我们可以使用 AnimationResult 的 endReason 和 endState 分别获取动画停止的状态和动画停止时的速度等信息,重新开启一个动画继续执行:

LaunchedEffect(Unit) {
	val animResult = anim.animateDecay(???, decay) // ??? 是具体的动画数值
	// 如果动画停止是遇到动画边界
	if (animResult.endReason == AnimationEndReason.BoundReached) {
		anim.animateDecay(???) // 启动一个新的动画继续没有遇到动画边界的另一维度的动画
	}
}

因为大多数情况我们都是处理的二维动画,为了动画数值不互相影响,最好的方式是将横纵坐标的动画分成两个处理,例如在传统 View 系统的 OverScroller 就是这么处理的:

public class OverScroller {
	// 将横纵坐标动画拆分成两个,不互相影响
	private final SplineOverScroller mScrollerX;
	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) {
		...
		mScrollerX.fling(startX, velocityX, minX, maxX, overX);
		mScrollerY.fling(startY, velocityY, minY, maxY, overY);
	}
}

我们也用这种方式试一下效果:

setContent {
	BoxWithConstraints {
		val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
		val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
		val decay = remember { exponentialDecay<Dp>() }
		LaunchedEffect(Unit) {
			delay(1000)
			animX.animateDecay(2000.dp, decay)
		}
		LaunchedEffect(Unit) {
			delay(1000)
			animY.animateDecay(2000.dp, decay)
		}
		// 定义两个动画边界,其中一个停止了另一个也能继续执行
		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)
		)
	}
}

在这里插入图片描述

我们再对需求做一下进阶:在碰边的时候反弹回来,这就需要用到刚讲到的 endReason 和 endState:

setContent {
	BoxWithConstraints {
		val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
		val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
		val decay = remember { exponentialDecay<Dp>() }
		LaunchedEffect(Unit) {
			delay(1000)
			val result = animX.animateDecay(2000.dp, decay)
			// 如果到达边界了停止了,按停止时候的速度反弹继续执行
			if (result.endReason == AnimationEndReason.BoundReached) {
				animX.animateDecay(-result.endState.velocity, decay)
			}
		}
		LaunchedEffect(Unit) {
			delay(1000)
			animY.animateDecay(2000.dp, decay)
		}
		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)
		)
	}
}

在这里插入图片描述

如果要不断反弹直到停止,就改成循环就行了:

setContent {
	BoxWithConstraints {
		val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
		val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
		val decay = remember { exponentialDecay<Dp>() }
		LaunchedEffect(Unit) {
			delay(1000)
			var result = animX.animateDecay(4000.dp, decay) // 调大一点
			while (result.endReason == AnimationEndReason.BoundReached) {
				result = animX.animateDecay(-result.endState.velocity, decay)
			}
		}
		LaunchedEffect(Unit) {
			delay(1000)
			animY.animateDecay(2000.dp, decay)
		}
		// 要设置下边界为0dp
		animX.updateBounds(0.dp, 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)
		)
	}
}

在这里插入图片描述

实际上上面每次反弹时的起始速度并不足够准确,这就会导致几轮反弹下来和实际的效果会有那么一点偏差,当然在视觉上一般看不出来,那是否有精准的方式可以获取呢?答案是可以的,不过这需要我们手动计算:

setContent {
    BoxWithConstraints {
        val animX = remember { Animatable(0.dp, Dp.VectorConverter) }
        val animY = remember { Animatable(0.dp, Dp.VectorConverter) }
        val decay = remember { exponentialDecay<Dp>() }
        LaunchedEffect(Unit) {
            delay(1000)
            var result = animX.animateDecay(4000.dp, decay)
            while (result.endReason == AnimationEndReason.BoundReached) {
                result = animX.animateDecay(-result.endState.velocity, decay)
            }
        }
        LaunchedEffect(Unit) {
            delay(1000)
            animY.animateDecay(2000.dp, decay)
        }
        // animX.updateBounds(0.dp, upperBound = maxWidth - 100.dp) // 不提供边界
        animY.updateBounds(upperBound = maxHeight - 100.dp)
        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) {
                // 在到达边界撞墙之前按原有速度继续运行
                usedValue
            } else {
                // 在第一次撞墙之后再第二次撞墙之前
                (maxWidth - 100.dp) * 2 - usedValue
            }
        }
        Box(
            Modifier
                .padding(paddingX, animY.value, 0.dp, 0.dp)
                .size(100.dp)
                .background(Color.Green)
        )
    }
}

在这里插入图片描述

对比不是手动计算的方式的效果刚好能看得出来偏差,手动计算最终停下的位置确实会多一点点偏差。

Transition

多属性的状态切换

原生也有名为 Transition 的过渡动画,它是负责 Activity 或 Fragment 之间的转场动画,因为 Activity 或 Fragment 是比 View 更外层的,理论上是不能使用 View 的动画,但是借助 Android 提供的 Transition 就可以做到界面的转场。

而这里提到的 Transition 是 Compose 自己的转场动画,和 Activity 或 Fragment 的转场动画没有关系。

转场动画其实就是一种状态切换,我们有讲过状态切换动画可以用 animateXxxAsState 来实现:

setContent {
    var big by remember { mutableStateOf(false) }
    val size by animateDpAsState(if (big) 96.dp else 48.dp)
    Box(
        Modifier
            .size(size)
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

如果我们想用 Transition 来实现这个状态切换该怎么做呢?

setContent {
	var big by remember { mutableStateOf(false) }
	// 创建和更新Transition
	val bigTransition = updateTransition(big, label = "big") 
	// 根据Transition计算动画的目标值
	val size by bigTransition.animateDp(label = "size") { if (it) 96.dp else 48.dp }
	Box(
	  Modifier
	  	.size(size)
	  	.background(Color.Green)
	  	.clickable {
	  		big = !big	
	  	}
	)
}

Transition.kt

@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
}

updateTransition 有两个作用

  • 创建 Transition

  • 更新 Transition:更新是渐变的过程,即使是 Boolean 从 true 到 false 也是渐变的过程,它会开启一个协程慢慢的渐变到达一个状态的目标值

updateTransition 内部带有 remember,而且它只是用来管理状态的变化,所以要动画能运行只有 updateTransition 是不够的,还需要一系列的动画 api,比如 animateDp 计算动画的目标值。

现在或许你会有疑问:我用 animateDpAsState 就能实现这个功能,为什么还要提供 Transition?

animateXxxAsState 是面向变量属性本身,而 Transition 瞄准的是变量背后的状态,这样 Transition 就能建立多属性的状态切换的模型

比如我想在变大变小的同时修改圆角的大小:

setContent {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, label = "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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

虽然效果也可以用 animateDpAsState 来实现,不过它是面向属性本身的,所以有多少个 animateDpAsState 或者说有多少个属性,就会开启多少个协程来执行;而 Transition 统一在这个对象内部对多个属性用一个协程对过程管理,性能也更好一些

Transition 还支持在 IDE 的 Animation Preview 实时预览的功能,动画调试更加方便,这在 animateDpAsState 是不支持的(Transition 动画交互需要升级 Android Studio Chipmunk 才支持)。

在这里插入图片描述

在这里插入图片描述
animateDp 还有一个参数 transitionSpec:

Transition.kt

@Composable
inline fun <S> Transition<S>.animateDp(
	// 返回值是 FiniteAnimationSpec,只能是 TweenSpec、KeyframesSpec 等
    noinline transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<Dp> = {
    	// 返回一个随用随生成的 AnimationSpec,控制 Transition 动画的运动曲线
        spring(visibilityThreshold = Dp.VisibilityThreshold)
    },
    label: String = "DpAnimation",
    targetValueByState: @Composable (state: S) -> Dp
): State<Dp> =
    animateValue(Dp.VectorConverter, transitionSpec, label, targetValueByState)

interface Segment<S> {
	val initialState: S
	val targetState: S

    infix fun S.isTransitioningTo(targetState: S): Boolean {
        return this == initialState && targetState == this@Segment.targetState
    }
}

transitionSpec 参数是一个 Composable,Transition.Segment 调用并且返回值是 FiniteAnimationSpec,我们在讲 AnimationSpec 时有提到过 FiniteAnimationSpec,它是 AnimationSpec 的子接口:

在这里插入图片描述

除了 FloatAnimationSpec 是辅助的我们一般用不到之外,还有一个无限循环的 InfiniteRepeatableSpec,返回值只允许是 FiniteAnimationSpec 的子类,比如 KeyframesSpec、SnapSpec、TweenSpec 等。

为什么不能是无限循环的 AnimationSpec?当然并不是因为 Transition 这种状态切换的场景无限循环没有意义,Compose 实际上提供了专门用于处理无限循环的 rememberInfiniteTransition

// 处理无限循环的 Transition
val infiniteTransition = rememberInfiniteTransition()

InfiniteTransition.kt

@Composable
fun rememberInfiniteTransition(): InfiniteTranstion() {
	val infiniteTransition = remember { InfiniteTransition() }
	infiniteTransition.run()
	return infiniteTransition
}

// InfiniteTransition 并不继承 Transition,而是一个单独的类
class InfiniteTransition internal constructor() {
	...
}

我们拐回刚才说到的 transitionSpec 会返回一个 AnimationSpec,并且还有一个 lambda 默认值,需要我们 返回一个随用随生成的 AnimationSpec,由它控制某个 Transition 属性的动画过程的运动曲线,需要注意的是这 需要由该属性的初始状态和目标状态决定,而不是外部传入的参数判断

setContent {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, label = "big")
    // animateDp 的 lambda 指定了 Transition 使用的运动曲线
    // size 的动画属性指定了初始状态和目标状态使用不同的运动曲线
    // initialState 和 targetState 的类型是由 bigTransition 的类型决定的
    // initialState 和 targetState 它是由 Transition.Segment 提供的
    val size by bigTransition.animateDp({ if (!initialState && targetState) spring() else tween() }, 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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

上面是使用了 Transition.Segment 提供的 initialState 和 targetState 判断属性使用哪种运动曲线,其实还有更简便的写法:

val size by bigTransition.animateDp({ if (false isTransitioningTo true) spring() else tween() }, label = "size") { if (it) 96.dp else 48.dp }

说了 Transition 和 animateXxxAsState 的区别,再来说说 Transition 有什么缺点。

Transition 和 animateXxxAsState 一样默认是无法设置初始值的,当然也有相应的解决方案,要设置 Transition 初始值需要手动提供 MutableTransitionState 设置,从源码也可以了解到:

Transition.kt

@Composable
fun <T> updateTransition(
    targetState: T,
    label: String? = null
): Transition<T> {
	// targetState 作为 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
}

@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) // 创建了 MutableTransitionState 设置初始值
}

所以我们如果要设置初始值,手动提供 MutableTransitionState:

setContent {
    var big by remember { mutableStateOf(false) }
    val bigState = MutableTransitionState(big) 
    bigState.targetState = true // 提供初始值
    val bigTransition = updateTransition(bigState, label = "big")
    val size by bigTransition.animateDp({ if (false isTransitioningTo true) spring() else tween() }, 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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

AnimatedVisibility

AnimatedVisibility 就是将显示和隐藏的过程用渐变的方式呈现,但需要注意的是,在 AnimatedVisibility 的 lambda 内只能有一个 Composable,如果要多个则另外加一个 AnimatedVisibility 处理

setContent {
	Column {
		var shown by remember { mutableStateOf(true) }
		// 动画方式渐变内容显示隐藏
		AnimatedVisibility(shown) {
			TransitionSquare()
		}
		Button(onClick = { shown = !shown }) {
			Text("切换")
		}
	}
}

@Preview
@Composable
fun TransitionSquare() {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, label = "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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

我们查看效果会发现,动画的显示隐藏是会根据方向也就是 Column 垂直纵向处理的显示隐藏,怎么会这么智能呢?我们一起看下 AnimatedVisibility 的源码:

AnimatedVisibility.kt

// 通用 AnimatedVisibility 函数
@Composable
fun AnimatedVisibility(
	visible: Boolean,
	modifier: Modifier = Modifier,
	enter: EnterTransition = fadeIn() + expendIn(),
	exit: ExitTransition = shrinkOut() + fadeOut(),
	label: String = "AnimatedVisibility",
	content: @Composable AnimatedVisibilityScope.() -> Unit
) {
	val transition = updateTransition(visible, label)
	AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

// 对应 Composable 的 AnimatedVisibility 扩展函数
@Composable
fun ColumnScope.AnimatedVisibility(
	visible: Boolean,
	modifier: Modifier = Modifier,
	enter: EnterTransition = fadeIn() + expendVertically(),
	exit: ExitTransition = fadeOut() + shrinkVertically(),
	label: String = "AnimatedVisibility",
	content: @Composable AnimatedVisibilityScope.() -> Unit
) {
	val transition = updateTransition(visible, label)
	AnimatedEnterExitImpl(transition, { it }, modifier, enter, exit, content)
}

AnimatedVisibility 是 Column 的一个扩展函数,当我们只填写控制隐藏的参数时,显示隐藏的转场动画也有默认提供。当然,并不是说 AnimatedVisibility 只能在 Column 使用,而是在一些 Composable 函数提供了一个对应的 AnimatedVisibility 扩展函数,并且也提供了通用版本的 AnimatedVisibility 函数。

如果我们要修改动画入场出场效果可以自定义 enter 和 exit 参数的动画,例如 fadeIn()、slideIn() 等等,而这些动画的处理都是由 TransitionData 的参数控制:

EnterExitTransition.kt

@Immutable
internal data class TransitionData(
	val fade: Fade? = null, // 控制淡入淡出效果
	val slide: Slide? = null, // 控制滑入滑出效果
	val changeSize: ChangeSize? = null, // 控制裁切效果
	val scale: Scale? = null // 控制缩放效果
)
  • fadeIn():淡入淡出效果
setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        AnimatedVisibility(shown, enter = fadeIn(tween(2000), initialAlpha = 0.3f)) {
            TransitionSquare()
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

@Preview
@Composable
fun TransitionSquare() {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, label = "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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

  • slideIn():滑入效果
setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        // it.width 和 it.height 是组件的宽高
        // slideInHorizontally() 和 slideInVertically() 专门处理横向纵向
        AnimatedVisibility(shown, enter = slideIn(tween(2000)) {
            IntOffset(
                -it.width,
                -it.height
            )
        }) {
            TransitionSquare()
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

@Preview
@Composable
fun TransitionSquare() {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, label = "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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

  • expandIn():裁切进入到完全显示出来效果
setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        // expandFrom:从哪个方向开始裁切展开
        // initialSize:初始裁切位置,默认从0开始
        // clip:显示时是否有裁切效果,默认true
        // expandHorizontally() 和 expandVertically() 专门处理横向纵向
        AnimatedVisibility(
            shown,
            enter = expandIn(
                tween(5000),
                expandFrom = Alignment.TopStart,
                initialSize = { IntSize(it.width / 2, it.height / 2) },
                clip = false
            )
        ) {
            TransitionSquare()
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

@Preview
@Composable
fun TransitionSquare() {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, label = "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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

  • scaleIn():缩放显示效果
setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        // transformOrigin:缩放中心点位置
        AnimatedVisibility(
            shown,
            enter = scaleIn(transformOrigin = TransformOrigin(1f, 1f))
        ) {
            TransitionSquare()
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

@Preview
@Composable
fun TransitionSquare() {
    var big by remember { mutableStateOf(false) }
    val bigTransition = updateTransition(big, label = "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
            .size(size)
            .clip(RoundedCornerShape(corner))
            .background(Color.Green)
            .clickable {
                big = !big
            }
    )
}

在这里插入图片描述

上面介绍了 fadeIn、slideIn、expandIn 和 scaleIn 四种动画方式,如果要几种动画结合使用,我们可以用 + 将两种或多种动画方式拼接起来,+ 是重载了操作符:

EnterExitTransition.kt

@ExperimentalAnimationApi
@Immutable
sealed class EnterTransition {
	internal abstract val data: TransitionData

    @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
            )
        )
    }	
}

AnimatedVisibility 说完了 EnterTransition,ExitTransition 其实也是差不多的,就是 fadeOut、slideOut、expandOut、scaleOut,具体就不再演示。

Crossfade

Crossfade 是用来处理内容的淡入淡出渐变切换的,比如从 A 渐变切换到 B。AnimatedVisibility 是让一个组件渐变出现,Crossfade 是让两个组件渐变交替出现

setContent {
     Column {
         var shown by remember { mutableStateOf(true) }
         Crossfade(shown, animationSpec = tween(2000)) {
             if (it) {
                 Box(
                     Modifier
                         .size(48.dp)
                         .background(Color.Green)
                 )
             } else {
                 Box(
                     Modifier
                         .size(24.dp)
                         .background(Color.Red)
                 )
             }
         }
         Button(onClick = { shown = !shown }) {
             Text("切换")
         }
     }
 }

在这里插入图片描述

AnimatedContent

AnimatedVisibility 是只做单个组件的出现和消失的动画,但是拥有丰富的配置细节,Crossfade 是针对状态改变的时候状态前后有不同的组件它们分别出现和消失的场景,组件数量上更复杂但是只能处理不可配置的淡入淡出效果,而不是有更多的细节配置。

那是否有组件即能有 AnimatedVisibility 的配置,又能有 Crossfade 处理不同组件切换的场景呢?那就是 AnimatedContent,AnimatedContent 就是 AnimatedVisibility 和 Crossfade 的结合

setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        AnimatedContent(shown) {
            if (it) {
                Box(
                    Modifier
                        .size(48.dp)
                        .background(Color.Green)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Red)
                )
            }
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

在 Crossfade 案例的基础上,将 Crossfade 修改为 AnimatedContent 就可以完成与 Crossfade 相近的效果,效果上会有一些小差异,但这都不是重要的,刚才讲到 AnimatedContent 也是可以配置的,具体看下它的源码:

AnimatedContent.kt

@ExperimentalAnimationApi
@Composable
fun <S> AnimatedContent(
	targetState: S,
	modifier: Modifier = Modifier,
	// transitionSpec 可以具体配置入场出场动画效果
	// 相当于将 AnimatedContent 内的所有组件的入场和出场都一起配置
	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
) {
	...
}

我们主要讲一下 transitionSpec 这个参数,动画的入场出场动画都是由它配置的,AnimatedContent 内的所有组件的入场出场动画都在这里统一配置生效。

需要传入 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 主要讲两个参数:

  • targetContentZIndex:配置动画切换时的遮盖关系,例如处在入场动画时入场遮盖出场动画,或者出场遮盖入场动画。不过一般不需要配置,具体还是看需求

  • sizeTransform:配置尺寸变化效果

我们先演示下 ContentTransform 自定义处理入场和出场动画效果:

// with 其实就是创建了 ContentTransition
@ExperimentalAnimationApi
infix fun EnterTransition.with(exit: ExitTransition) = ContentTransform(this, exit)

setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        AnimatedContent(shown, transitionSpec = { fadeIn() with fadeOut() }) {
            if (it) {
                Box(
                    Modifier
                        .size(48.dp)
                        .background(Color.Green)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Red)
                )
            }
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

在这里插入图片描述

接下来是 targetContentZIndex 的配置演示:

setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        // 延时3s后再出场,演示 targetContentZIndex 出场遮盖入场效果
        AnimatedContent(shown, transitionSpec = {
            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)
                        .background(Color.Green)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Red)
                )
            }
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

在这里插入图片描述

最后是 sizeTransform 的演示:

@ExperimentalAnimationApi
infix fun ContentTransform.using(sizeTransform: SizeTransform?) = this.apply {
    this.sizeTransform = sizeTransform
}

@ExperimentalAnimationApi
fun SizeTransform(
	clip: Boolean = true,
	sizeAnimationSpec: (initialSize: IntSize, targetSize: IntSize) -> FiniteAnimationSpec<IntSize> = 
		{ _, _ -> spring(visibilityThreshold = IntSize.VisibilityThreshold) }
): SizeTransform = SizeTransformImpl(clip, sizeAnimationSpec)

setContent {
    Column {
        var shown by remember { mutableStateOf(true) }
        // 使用 SizeTransform 配置是否裁切和尺寸变化的运动曲线
        AnimatedContent(shown, transitionSpec = {
            if (targetState) {
                (fadeIn(tween(3000)) with fadeOut(tween(3000, 3000))).apply {
                    targetContentZIndex = -1f
                }
            } else {
                (fadeIn(tween(3000)) with fadeOut(tween(3000, 3000)) using
                        SizeTransform(clip = false))
            }
        }) {
            if (it) {
                Box(
                    Modifier
                        .size(48.dp)
                        .background(Color.Green)
                )
            } else {
                Box(
                    Modifier
                        .size(24.dp)
                        .background(Color.Red)
                )
            }
        }
        Button(onClick = { shown = !shown }) {
            Text("切换")
        }
    }
}

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值