我正在参与「兔了个兔」构思投稿大赛,概况请看:「兔了个兔」构思投稿大赛

再过几天就要过大年了,最近周围也是一到晚上便是处处都在放焰火,看得我非常眼馋也想搞点焰火来放放,可惜周围实在是买不到,一打听全是托人在外省买的,算了太麻烦了,那真的焰火放不了,我就爽性决议写个焰火出来吧,应应景,也烘托点年味儿出来~刚好最近学了点Compose的动画,所以这个焰火就拿Compose来写,先上个终究作用图

因为买不到烟花,所以我想用Compose来放烟花

不好意思…放错作用图了…这个才是

因为买不到烟花,所以我想用Compose来放烟花

gif有点卡,真实作用还要流畅点,这些咱们先不说,先来看看这个动画咱们需要做些什么

  1. 一闪一闪(对..在闪)的小星星
  2. 逐步上升的焰火火苗
  3. 焰火炸开的作用
  4. 炸开后亮光作用

开端开发

闪耀的星星

首要咱们放焰火必定是在晚上放焰火的,所以整体画布首要背景色便是黑色,模拟一个夜空的场景

Canvas(
    modifier = Modifier
        .size(screenWidth().dp, screenHeight().dp)
        .background(color = Color.Black)
) {
}

确认好了画布今后,咱们先来想想怎样画星星,夜空中的星星其实便是在画布上画几个小圆点,然后小圆点的色彩是白色的,最后星星看起来都是有大有小的,由于间隔咱们的间隔不相同,所以咱们的小圆点也要看起来巨细不相同,也便是圆点的半径不相同,知道这些今后咱们开端规划代码,先确认好需要的变量,比如画布的中心点xy坐标,星星的xy坐标,以及星星的色彩

val drawColor = colorResource(id = R.color.white)
val centerX = screenWidth() / 2
val centerY = screenHeight() / 2
val starXList = listOf(
    screenWidth() / 12, screenWidth() / 6, screenWidth() / 4,
    screenWidth() / 3, screenWidth() * 5 / 12, screenWidth() / 2, screenWidth() * 7 / 12,
    screenWidth() * 2 / 3, screenWidth() * 3 / 4, screenWidth() * 5 / 6, screenWidth() * 11 / 12
)
val starYList = listOf(
    centerY / 12, centerY / 6, centerY / 4,
    centerY / 3, centerY * 5 / 12, centerY / 2, centerY * 7 / 12,
    centerY * 2 / 3, centerY * 3 / 4, centerY * 5 / 6, centerY * 11 / 12
)

starXList放星星的横坐标,横坐标便是把画布宽十二等分,starYList放星星的纵坐标,纵坐标便是把画布高的二分之再三十二等分,这样作法的意图便是终究画圆点的时分,两个List能够随机选取下标值,抵达星星随机散布在夜空的作用

drawCircle(drawColor, 5f, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f, Offset(starXList[10], starYList[8]))

然后一闪一闪的作用怎样做呢,一闪一闪也便是圆点的半径循环在变大变小,所以咱们需要用到Compose的循环动画rememberInfiniteTransition,这个函数能够通过它的animateXXX函数来创立循环动画,它里边有三个这样的函数

因为买不到烟花,所以我想用Compose来放烟花

咱们这儿就运用animateFloat来创立能够改动的半径

val startRadius by transition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing))
)

这个函数返回的是一个Float类型的值,前两个参数很好了解,初始值跟终究值,第三个参数是一个 InfiniteRepeatableSpec的对象,它决议这个循环动画的一些参数,duration决议动画持续时刻,delayMillis延迟履行的时刻,easing决议动画履行的速度

  • LinearEasing 匀速履行
  • FastOutLinearInEasing 逐步加快
  • FastOutSlowInEasing 先加快后减速
  • LinearOutSlowInEasing 逐步减速

这儿的星星的动画就挑选匀速履行就好,咱们把starRadius设置到星星的制作流程里边去

drawCircle(drawColor, 5f + startRadius, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f + startRadius, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f + startRadius, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[10], starYList[8]))

作用便是这样的

因为买不到烟花,所以我想用Compose来放烟花

焰火火苗

现在开端制作焰火部分,首要是上升的火苗,火苗也是个小圆点,它的起始坐标跟结尾坐标很好确认,横坐标都是centerX即画布的一半,纵坐标开端方位是在画布高度方位,结束是在centerY即画布一半高度方位,而一次放焰火的进程中,焰火炸开的次数有很屡次,伴跟着火苗上升次数也很屡次,所以这个也是个循环动画,整个进程代码完结如下

val fireDuration = 3000
val shootHeight by transition.animateFloat(
    screenHeight(),
    screenHeight() / 2,
    animationSpec = InfiniteRepeatableSpec(tween(fireDuration, 
    easing = FastOutSlowInEasing))
)
Canvas(
    modifier = Modifier
        .size(screenWidth().dp, screenHeight().dp)
        .background(color = Color.Black)
) {
    drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}

由于火苗上升会跟着重力逐步减速,所以这儿挑选的是先快后慢的动画作用,作用如下

因为买不到烟花,所以我想用Compose来放烟花

焰火炸开

这一部分难度开端增加了,由于焰火炸开这个作用是要等到火苗上升到最高点的方位然后在炸开的,这两个动画有个先后关系,用惯了Androi特点动画的我刚开端还不认为然,认为必定会有个动画回调或许监听器之类的东西,可是看了下循环动画的源码发现并没有找到想要的监听器

因为买不到烟花,所以我想用Compose来放烟花
那只能换个思路了,刚刚提到炸开的动画是在火苗上升到最高点的时分才开端的,那这个最高点便是个开关,当火苗抵达最高点的时分,让火苗的动画“暂停”,然后开端炸开的动画,现在问题的关键是,怎样让火苗的动画“暂停”,咱们知道火苗的动画是一个循环动画,循环动画是从初始值到终究值循环改动的进程,那么咱们是不是只要让这两个值都为同一个,让它们没有改动的空间,是不是就等于让这个动画“暂停”了呢,咱们开端规划这个进程

var turnOn by remember { mutableStateOf(false) }
val distance = remember { Animatable(screenHeight().dp, Dp.VectorConverter) }
LaunchedEffect(turnOn) {
    distance.snapTo(if (turnOn) screenHeight().dp else 0.dp)
}

turnOn是个开关,true的时分表明火苗动画开端,false的时分表明火苗动画现已抵达最高点,distance是一个Animatable的对象,Animatable是啥呢,从字面上就能了解它也是个动画,但与咱们刚刚接触的循环动画不相同,它只要从初始值到终究值的单向改动,而后边的LaunchedEffect是啥呢,咱们点到里边去看下它的源码

fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

这儿咱们看到key1是任何值,被remember保存了起来,block是个挂起的函数类型对象,也便是block是运转在协程里边的,咱们再去LaunchedEffectImpl里边看看

因为买不到烟花,所以我想用Compose来放烟花

咱们看到了这个协程是在被remember的值产生改动今后才去履行的,那现在清楚了,每次改动turnOn的值,distance就会来回从screenHeight()和0之间切换,而切换的条件便是火苗上升高度抵达了画布的一半,咱们改一下刚刚火苗的动画,让shootHeight跟着distance改动而改动,别的咱们给画布添加个点击工作,每次点击让turnOn的值产生改动,意图让动画多进行几次

val shootHeight by transition.animateFloat(
    distance.value.value,
    distance.value.value / 2,
    animationSpec = InfiniteRepeatableSpec(tween(fireDuration, 
    easing = FastOutSlowInEasing))
)
Canvas(
    modifier = Modifier
        .size(screenWidth().dp, screenHeight().dp)
        .background(color = Color.Black)
        .clickable { turnOn = !turnOn }
) {
    if (shootHeight.toInt() != screenHeight().toInt() / 2) {
        if (shootHeight.toInt() != 0) {
            drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
        }
    } else { 
        turnOn = false
    }
}

咱们看下作用是不是咱们想要的

因为买不到烟花,所以我想用Compose来放烟花

So far so good~动画现已别离开来了,现在就要开端炸开作用的开发,咱们先脑补下炸开是什么姿态的,是由火苗开端,向四周延伸出去若干条焰火,或许换句话说便是以火苗为圆心,向四周画线条,这样说咱们思路有了,这是一个由圆心开端向外drawLine的进程,drawLine这个api咱们很熟悉了,最主要的便是确认开端跟结束两处的坐标,可是不管开端仍是结束,这两个坐标都是散布在一个圆周上的,所以咱们榜首步先要确认在哪几个视点上面画线

val anglist = listOf(30, 75, 120, 165, 210, 255, 300, 345)

知道了视点今后,就要去核算xy坐标了,这个就要用到正弦余弦公式

private fun calculateX(centerX: Float, fl: Int, endCor: Boolean): Float {
    val angle = Math.toRadians(fl.toDouble())
    return centerX - cos(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}
private fun calculateY(centerY: Float, fl: Int, endCor: Boolean): Float {
    val angle = Math.toRadians(fl.toDouble())
    return centerY - sin(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}

其间endColor是true便是画结尾的坐标,false便是起点的坐标,咱们先画一条线,剩下的线的代码都相同

val startfireOneX = calculateX(centerX, anglist[0], false)
val startfireOneY = calculateY(centerY, anglist[0], false)
val endfireOneX = calculateX(centerX, anglist[0], true)
val endfireOneY = calculateY(centerY, anglist[0], true)
var fireColor = colorResource(id = R.color.color_03DAC5)
var fireOn by remember { mutableStateOf(false) }
val fireOneXValue = remember { Animatable(startfireOneX, Float.VectorConverter) }
val fireOneYValue = remember { Animatable(startfireOneY, Float.VectorConverter) }
val fireStroke = remember { Animatable(0f, Float.VectorConverter) }
LaunchedEffect(fireOn){
    fireStroke.snapTo(if(fireOn) 20f else 0f)
    fireOneXValue.snapTo(if(fireOn) endfireOneX else startfireOneX)
    fireOneYValue.snapTo(if(fireOn) endfireOneY else startfireOneY)
}

fireOneXValue是榜首条线横坐标的改动动画,fireOneYValue是纵坐标的改动动画,它们的改动都有fireOn去操控,fireOn翻开的机遇便是火苗上升到最高点的时分,同时咱们也增加了fireStroke,表明线条粗细的改动动画,也跟着fireOn的改动而改动,咱们现在去创立横坐标,纵坐标以及线条粗细的循环动画

val fireOneX by transition.animateFloat(
    startfireOneX, fireOneXValue.value,
    infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)
val fireOneY by transition.animateFloat(
    startfireOneY, fireOneYValue.value,
    infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)
val strokeW by transition.animateFloat(
    initialValue = fireStroke.value/20,
    targetValue = fireStroke.value,
    animationSpec = infiniteRepeatable(tween(fireDuration, 
    easing = FastOutSlowInEasing))
)

咱们现在能够去制作榜首根线了,在Canvas里边增加榜首个drawLine

Canvas(
    modifier = Modifier
        .size(screenWidth().dp, screenHeight().dp)
        .background(color = Color.Black)
        .clickable { turnOn = !turnOn }
) {
    if (shootHeight.toInt() != screenHeight().toInt() / 2) {
        if (shootHeight.toInt() != 0) {
            drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
        }
    } else { 
        turnOn = false
        fireOn = true
    }
    drawLine(
        fireColor, Offset(startfireOneX, startfireOneY),
        Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
    )
}

到了这一步,咱们应该考虑的是,怎样让动画衔接起来,也便是炸开动画完结今后,持续履行火苗动画,那么咱们就要找出炸开动画结束的那个点,这儿总共有三个值,咱们挑选strokeW,当线条粗细抵达最大值的时分,将fireOn封闭,将turnOn翻开,咱们在drawLine后边加上这段逻辑

Canvas(
    modifier = Modifier
        .size(screenWidth().dp, screenHeight().dp)
        .background(color = Color.Black)
        .clickable { turnOn = !turnOn }
) {
    if (shootHeight.toInt() != screenHeight().toInt() / 2) {
        if (shootHeight.toInt() != 0) {
            drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
        }
    } else { 
        turnOn = false
        fireOn = true
    }
    drawLine(
        fireColor, Offset(startfireOneX, startfireOneY),
        Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
    )
    if(strokeW == 19){
        fireOn = false
        turnOn = true
    }
}

这个时分,两个动画就连起来了,咱们运转下看看作用

因为买不到烟花,所以我想用Compose来放烟花

一条线完结了,那么其余几根线道理也是相同的,代码有点多篇幅关系就不贴出来了,直接看作用图吧

因为买不到烟花,所以我想用Compose来放烟花

根本的姿态现已出来了,现在给这个焰火优化一下,咱们知道放焰火时分,每次炸开的姿态都是不相同的,红橙黄绿啥色彩都有,咱们这边也让每次炸开时分,色彩都不相同,那首要咱们要弄一个色彩的调集

val colorList = listOf(
    colorResource(id = R.color.color_03DAC5), colorResource(id = R.color.color_BB86FC),
    colorResource(id = R.color.color_E6A639), colorResource(id = R.color.color_01B9FF),
    colorResource(id = R.color.color_FF966B), colorResource(id = R.color.color_FFEBE7),
    colorResource(id = R.color.color_FF4252), colorResource(id = R.color.color_EC4126)
)

并且让fireColor在每次炸开之前,替换一次色彩,随机换也行,依照下标顺序替换也行,这边我挑选顺序换了,方位便是炸开动画开端的当地

var colorIndex by remember { mutableStateOf(0) }
Canvas(
    modifier = Modifier
        .size(screenWidth().dp, screenHeight().dp)
        .background(color = Color.Black)
        .clickable { turnOn = !turnOn }
) {
    if (shootHeight.toInt() != screenHeight().toInt() / 2) {
        if (shootHeight.toInt() != 0) {
            drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
        }
    } else {
        if (strokeW.toInt() == 0) {
            colorIndex += 1
            if (colorIndex > 7) colorIndex = 0
            fireColor = colorList[colorIndex]
        }
        turnOn = false
        fireOn = true
    }
    drawLine(
        fireColor, Offset(startfireOneX, startfireOneY),
        Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
    )
    if(strokeW == 19){
        fireOn = false
        turnOn = true
    }
}

咱们再想想看,焰火结束今后是不是还会有一些亮光,有的焰火的亮光还会有声响,声响咱们弄不出来,可是亮光仍是能够的,还记得咱们星星怎样画的吗,不便是几个圆圈在那里不断制作,然后一闪一闪的作用便是不断改动圆圈的半径,那咱们焰火的亮光作用也能够这么做,首要咱们先确认好需要制作圆圈的坐标

val endXAnimList = listOf(
    calculatePointX(centerX, anglist[0]),
    calculatePointX(centerX, anglist[1]),
    calculatePointX(centerX, anglist[2]),
    calculatePointX(centerX, anglist[3]),
    calculatePointX(centerX, anglist[4]),
    calculatePointX(centerX, anglist[5]),
    calculatePointX(centerX, anglist[6]),
    calculatePointX(centerX, anglist[7])
)
val endYAnimList = listOf(
    calculatePointY(centerY, anglist[0]),
    calculatePointY(centerY, anglist[1]),
    calculatePointY(centerY, anglist[2]),
    calculatePointY(centerY, anglist[3]),
    calculatePointY(centerY, anglist[4]),
    calculatePointY(centerY, anglist[5]),
    calculatePointY(centerY, anglist[6]),
    calculatePointY(centerY, anglist[7])
)

然后焰火放完今后会有个逐步暗淡的进程,在这儿咱们就让圆圈的半径也有个逐步变小的进程,那咱们就创立个变小的动画

val pointDuration = 3000
val firePointRadius = remember{ Animatable(0f, Float.VectorConverter) }
val pointRadius by transition.animateFloat(
    initialValue = firePointRadius.value,
    targetValue = firePointRadius.value / 6,
    animationSpec = infiniteRepeatable(tween(pointDuration, 
    easing = FastOutLinearInEasing))
)

有了这个亮光的动画今后,接下去就要让它跟炸开的动画衔接起来了,这边也跟其他动画相同,增加一个开关去操控,当开关翻开之后,firePointRadius设置成最大,敞开这个亮光动画,当开关封闭今后,就让firePointRadius设置为0,也便是封闭亮光动画,代码如下

var pointOn by remember { mutableStateOf(false) }
LaunchedEffect(pointOn) {
    firePointRadius.snapTo(if (pointOn) 12f else 0f)
}

参数都设置好了今后,咱们能够去制作亮光的圆圈了,这边咱们让亮光的开关在炸开动画结束之后翻开,本来要敞开的火苗动画咱们暂时先不翻开,而亮光动画的色彩咱们让它跟炸开的动画色彩一致,让整个进程看上去像是焰火自己炸开然后变成小颗粒的姿态

Canvas(
    modifier = Modifier
        .size(screenWidth().dp, screenHeight().dp)
        .background(color = Color.Black)
        .clickable { turnOn = !turnOn }
) {
    ....此处省掉前面两个焰火动画的制作进程.....
    if(strokeW == 19){
        fireOn = false 
        pointOn = true
    }
    if(pointOn){
        repeat(endXAnimList.size) {
            drawCircle(
                colorList[colorIndex], pointRadius,
                Offset(endXAnimList[it], endYAnimList[it])
            )
        }
    }
}

到了这儿感觉好像漏了点什么,没错,之前咱们暂时把火苗开关翻开的机遇取消了,那这个开关得翻开呀,否则咱们的焰火没办法连在一起放,现在便是要找到这个临界值,咱们发现这个制作圆圈的进程,只要圆圈的半径在跟着时刻的递进逐步变小的,它的最小值是当pointOn开关翻开之后,targetValue的值也便是2,所以咱们能够判别当pointRadius变成2的时分,将亮光动画封闭,火苗动画翻开,咱们将这个判别加到制作圆圈的后边

if(pointOn){
    repeat(endXAnimList.size) {
        drawCircle(
            colorList[colorIndex], pointRadius,
            Offset(endXAnimList[it], endYAnimList[it])
        )
    }
    if (pointRadius.toInt() == 2) {
        pointOn = false
        turnOn = true
    }
 }

现在动画现已都衔接起来了,咱们看下作用吧

因为买不到烟花,所以我想用Compose来放烟花

额~~感觉怪怪的,说好的亮光呢,但就动画而言圆圈的确是完结了半径逐步变小的制作进程,那么问题出在哪里呢?咱们回到代码中再查看一遍,发现了这一处代码

Offset(endXAnimList[it], endYAnimList[it])

这个圆点制作的方位是均匀散布在一个圆周上的,也便是只制作了八个圆点,可是真实作用里边的圆点有很多个,那咱们是不是只要将endXAnimList,endYAnimList这两个数组里边的坐标打乱随机组成一个圆点不就好了,这样一来最多会制作出64个圆点,再合作动画不就能抵达亮光的作用了吗,所以咱们先写一个随机函数

private fun randomCor(): Int {
    return (Math.random() * 8).toInt()
}

然后将本来制作圆点的坐标的下标改成随机数

if(pointOn){
    repeat(endXAnimList.size) {
        drawCircle(
            colorList[colorIndex], pointRadius,
            Offset(endXAnimList[randomCor()], endYAnimList[randomCor()])
        )
    }
    if (pointRadius.toInt() == 2) {
        pointOn = false
        turnOn = true
    }
 }

现在咱们再来看看作用怎样

因为买不到烟花,所以我想用Compose来放烟花

总结

完好的动画作用现已出来了,整个开发进程仍是相对来讲比较吃力的,我想这应该是刚开端接触Compose动画这一部分吧,后边再多开发几个动画应该会称心如意一些,但仍是有点收获的,比如

  • 循环动画如果中途需要暂停,然后过段时刻再翻开的话,不能直接对它的initValue跟targetValue设置值,这样是无效的,必须搭配着Animatable动画一起运用才行
  • LaunchedEffect虽说是在Compose里边是提供给协程运转的函数,不看源码的话认为它里边只能做一件工作,其他工作会被堵塞,其实LaunchedEffect现已封装好了,它的block便是一个协程,所以不管在LaunchedEffect做几件工作,它们都只是运转在一个协程里边

也有一些遗憾与缺乏

  • 动画衔接的当地都是判别一个值有没有抵达一个详细值,然后用开关去操控,感觉应该有更好的方法,比如能够合作着delayMillis,让动画延迟一会再开端
  • 焰火自身其实能够用曲线来代替直线,比如贝塞尔,这个是在开发进程中才想到的,我先去试试看,等龙年再画个更好的焰火~~