前言
前几天遇到一个动画需求,由于当时的时间比较急,因此当时这个效果就直接使用之前的原生动画工具类来实现了,现在有时间了,我们一起来使用Compose实现这个动画,试一下!
观察这个动画,其实是由两个动画完成的,中间还有一个间隙,我们接下来就一步一步来实现它。
绘制第一个动画
第一个动画是金币由屏幕中心展开,并散成一个不规则的圆。
我们先画它一个金币试一下!
@Composable
fun CreateAnimation(){
Image(painterResource(id = R.drawable.common_coin_normal), contentDescription = "coin", modifier = Modifier.size(32.dp))
}
当点击时,将金币展示出来
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
var show by remember { mutableStateOf(false) }
Box(modifier.fillMaxSize()) {
Button(modifier = Modifier.align(Alignment.Center),onClick = {
show = !show
}) {
Text("点击测试动画")
}
if (show){
Box(modifier = Modifier.align(Alignment.Center)) {
CreateAnimation()
}
}
}
}
再画它20个金币
@Composable
fun CreateAnimation(){
for (i in 0 until 20){
Image(painterResource(id = R.drawable.common_coin_normal), contentDescription = "coin", modifier = Modifier.size(32.dp))
}
}
这个时候20个金币摊叠在一起了,我们看不到位置,接下来尝试让它动起来!
动画的时候,我们设定动画的时长先设置1000毫秒,然后每个金币展开的距离为随机值,代码如下:
@Composable
fun CreateAnimation() {
// 使用 Animatable 控制初始扩展动画
val expansionProgress = remember { Animatable(0f) }
// 定义 20 个金币,存储它们的随机半径和角度
val circleCount = 20
val randomDistances = remember { List(circleCount) { Random.nextFloat() * 100 + 200 } }
val angles = remember { List(circleCount) { it * (360f / circleCount) * (Math.PI / 180f).toFloat() } }
LaunchedEffect(Unit) {
expansionProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 300, easing = LinearEasing)
)
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
for (i in 0 until circleCount) {
// 动态计算每个金币的展开距离
val distance = randomDistances[i] * expansionProgress.value
val offsetX = cos(angles[i]) * distance
val offsetY = sin(angles[i]) * distance
Image(
painter = painterResource(id = R.drawable.common_coin_normal),
contentDescription = "coin",
modifier = Modifier
.size(32.dp)
.offset(pxToDp(offsetX), pxToDp(offsetY))
)
}
}
}
@Composable
fun pxToDp(px: Float): Dp {
// 获取 LocalDensity 实例
val density = LocalDensity.current
// 使用 density 转换 px 为 dp
return with(density) { px.toDp() }
}
由于圆的外面已经有一个Box组件了,因此外面可以直接使用CreateAnimation
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
var show by remember { mutableStateOf(false) }
Box(modifier.fillMaxSize()) {
Button(modifier = Modifier.align(Alignment.Center),onClick = {
show = !show
}) {
Text("点击测试动画")
}
if (show){
CreateAnimation()
}
}
}
接下来看看效果:
速度有点快,调试到300毫秒试一下
小结
至此,第一个动画就绘制完成了。
- 我们使用了
Animatable来声明了类似属性动画的对象,初始值为0 - 接下来计算了
20个圆需要偏移的直线距离和角度 - 接下来开启动画,
Animatable目标值设为1,整个动画时长为300毫秒,且插值器为LinearEasing - 然后遍历了
20个圆,根据动画的变化值,通过正弦三角函数和余弦三角函数得到移动过程中的坐标,并将坐标由px转为dp,交给offset使用.
接下来绘制第二个动画.
绘制第二个动画
仔细观察原动画可以发现,在第一个动画结束后,第二个动画的启动时间并不一致,然后每个动画偏移到指定位置的过程中还有一个缩小的动画。那我们就先偏移到指定位置,然后缩小,最后设置动画的延迟间隙。
偏移到指定位置
其实现思路同第一个动画类似,先计算目标位置的相对坐标,然后声明20个金币的偏移动画Animatable,在第一个动画expansionProgress完成后,遍历20个金币动画并依次启动,然后在绘制过程中计算偏移位置,代码如下:
@Composable
fun CreateAnimation() {
// 使用 Animatable 控制初始扩展动画
val expansionProgress = remember { Animatable(0f) }
// 定义 20 个金币,存储它们的随机半径和角度
val circleCount = 20
val randomDistances = remember { List(circleCount) { Random.nextFloat() * 100 + 200 } }
val angles = remember { List(circleCount) { it * (360f / circleCount) * (Math.PI / 180f).toFloat() } }
// 获取屏幕宽高
val screenWidthPx = LocalContext.current.resources.displayMetrics.widthPixels
val screenHeightPx = LocalContext.current.resources.displayMetrics.heightPixels
// 每个金币偏移的最终位置,以屏幕中心为圆点,计算左上角(20dp,50dp)的相对坐标
val targetPosition = Offset(DpToPx(20f)-screenWidthPx/2, DpToPx(50f)-screenHeightPx/2)
// 每个金币的移动动画的时长
val durations = remember { List(circleCount) { Random.nextInt(1000, 1400) } }
// 每个金币的动画
val moveAnimations = remember { List(circleCount) { Animatable(0f) } }
LaunchedEffect(Unit) {
expansionProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 300, easing = LinearEasing)
)
// 开始每个金币的偏移
moveAnimations.forEachIndexed { index, animatable ->
launch {
animatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = durations[index],
easing = LinearEasing
)
)
}
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
for (i in 0 until circleCount) {
// 动态计算每个金币的展开距离
val distance = randomDistances[i] * expansionProgress.value
var offsetX = cos(angles[i]) * distance
var offsetY = sin(angles[i]) * distance
// 计算每个金币向目标点偏移的插值位置
val moveProgress = moveAnimations[i].value
offsetX = lerp(offsetX, targetPosition.x, moveProgress)
offsetY = lerp(offsetY, targetPosition.y, moveProgress)
Image(
painter = painterResource(id = R.drawable.common_coin_normal),
contentDescription = "coin",
modifier = Modifier
.size(32.dp)
.offset(pxToDp(offsetX), pxToDp(offsetY))
)
}
}
}
// 线性插值函数
private fun lerp(start: Float, end: Float, progress: Float): Float {
return start + (end - start) * progress
}
@Composable
fun pxToDp(px: Float): Dp {
// 获取 LocalDensity 实例
val density = LocalDensity.current
// 使用 density 转换 px 为 dp
return with(density) { px.toDp() }
}
@Composable
fun DpToPx(dpValue:Float):Float {
val density = LocalDensity.current
// 将 dp 转换为像素
val pixelValue = with(density) { dpValue.dp.toPx() }
return pixelValue
}
我们看看这个效果:
缩小视图
现在偏移过程是没有问题了,但是还有一个缩小的动画。这个缩小动画和偏移动画使用同一个Animatable,代码如下:
@Composable
fun CreateAnimation() {
// 使用 Animatable 控制初始扩展动画
val expansionProgress = remember { Animatable(0f) }
// 定义 20 个金币,存储它们的随机半径和角度
val circleCount = 20
val randomDistances = remember { List(circleCount) { Random.nextFloat() * 100 + 200 } }
val angles = remember { List(circleCount) { it * (360f / circleCount) * (Math.PI / 180f).toFloat() } }
// 获取屏幕宽高
val screenWidthPx = LocalContext.current.resources.displayMetrics.widthPixels
val screenHeightPx = LocalContext.current.resources.displayMetrics.heightPixels
// 每个金币偏移的最终位置,以屏幕中心为圆点,计算左上角(20dp,50dp)的相对坐标
val targetPosition = Offset(DpToPx(20f)-screenWidthPx/2, DpToPx(50f)-screenHeightPx/2)
// 每个金币的移动动画的时长
val durations = remember { List(circleCount) { Random.nextInt(1000, 1400) } }
// 每个金币的动画
val moveAnimations = remember { List(circleCount) { Animatable(0f) } }
LaunchedEffect(Unit) {
expansionProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 300, easing = LinearEasing)
)
// 开始每个金币的偏移
moveAnimations.forEachIndexed { index, animatable ->
launch {
animatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = durations[index],
easing = LinearEasing
)
)
}
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
for (i in 0 until circleCount) {
// 动态计算每个金币的展开距离
val distance = randomDistances[i] * expansionProgress.value
var offsetX = cos(angles[i]) * distance
var offsetY = sin(angles[i]) * distance
// 计算每个金币向目标点偏移的插值位置
val moveProgress = moveAnimations[i].value
offsetX = lerp(offsetX, targetPosition.x, moveProgress)
offsetY = lerp(offsetY, targetPosition.y, moveProgress)
Image(
painter = painterResource(id = R.drawable.common_coin_normal),
contentDescription = "coin",
modifier = Modifier
.size(32.dp)
.offset(pxToDp(offsetX), pxToDp(offsetY))
.scale(1-moveProgress)
)
}
}
}
效果已经七七八八了,但是我们再增加一些随机,让动画更自然一些。
LaunchedEffect(Unit) {
expansionProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 300, easing = LinearEasing)
)
// 开始每个金币的偏移
moveAnimations.forEachIndexed { index, animatable ->
launch {
// 增加延迟的随机
delay(Random.nextLong(50,100))
animatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = durations[index],
easing = LinearEasing
)
)
}
}
}
最后来看看完整的效果:
动画完成回调
这样整个动画就算是完成了。如果需要在动画执行结束以后增加回调,那么我们还可以添加一个接口回调:
@Composable
fun CreateAnimation(withEndAction:(() -> Unit)? = null) {
// 使用 Animatable 控制初始扩展动画
val expansionProgress = remember { Animatable(0f) }
// 定义 20 个金币,存储它们的随机半径和角度
val circleCount = 20
val randomDistances = remember { List(circleCount) { Random.nextFloat() * 100 + 200 } }
val angles = remember { List(circleCount) { it * (360f / circleCount) * (Math.PI / 180f).toFloat() } }
// 获取屏幕宽高
val screenWidthPx = LocalContext.current.resources.displayMetrics.widthPixels
val screenHeightPx = LocalContext.current.resources.displayMetrics.heightPixels
// 每个金币偏移的最终位置,以屏幕中心为圆点,计算左上角(20dp,50dp)的相对坐标
val targetPosition = Offset(DpToPx(20f)-screenWidthPx/2, DpToPx(50f)-screenHeightPx/2)
// 每个金币的移动动画的时长
val durations = remember { List(circleCount) { Random.nextInt(1000, 1400) } }
// 每个金币的动画
val moveAnimations = remember { List(circleCount) { Animatable(0f) } }
LaunchedEffect(Unit) {
expansionProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 300, easing = LinearEasing)
)
// 开始每个金币的偏移
moveAnimations.forEachIndexed { index, animatable ->
launch {
// 增加延迟的随机
delay(Random.nextLong(50,100))
animatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = durations[index],
easing = LinearEasing
)
)
if (index == moveAnimations.size-1){
withEndAction?.invoke()
}
}
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
for (i in 0 until circleCount) {
// 动态计算每个金币的展开距离
val distance = randomDistances[i] * expansionProgress.value
var offsetX = cos(angles[i]) * distance
var offsetY = sin(angles[i]) * distance
// 计算每个金币向目标点偏移的插值位置
val moveProgress = moveAnimations[i].value
offsetX = lerp(offsetX, targetPosition.x, moveProgress)
offsetY = lerp(offsetY, targetPosition.y, moveProgress)
Image(
painter = painterResource(id = R.drawable.common_coin_normal),
contentDescription = "coin",
modifier = Modifier
.size(32.dp)
.offset(pxToDp(offsetX), pxToDp(offsetY))
.scale(1-moveProgress)
)
}
}
}
如此就算完成了。
小结
第二个动画在第一个动画执行完毕后,遍历20个金币的动画,执行一个随机50~100毫秒的延迟后,开启金币偏移/缩小动画,然后绘制的时候,计算每个金币的展开距离后,再次计算偏移动画需要偏移的距离,最后通过Modifier#scale执行每个金币的缩放。
以上就是整个金币收集动画的全部实现了,看上去挺复杂,其实代码上手还是很容易的。就酱!