羡慕大劳星空顶?不如跟我一起使用 Jetpack compose 绘制一个星空背景(带流星动画)

前言

背景

作为一个自诩的电影爱好者,经常会在半夜看电影,看完后就会顺道去豆瓣标记一下看过,再看看别人对这个电影的理解。

某日深夜,看完电影后,顺手打开了豆瓣的 书影音记录 这个功能,起初并没有注意到这个页面的背景有什么东西,我以为只是一个普通的深色背景而已,直至一道流星突然划过屏幕!

好漂亮!我这才发现原来这个页面的背景是一个星空!时不时的还会有流星飞过!

这么漂亮的背景,不仿写一下真的对不起它了!

这个页面静态时是这样的:

s1

我把内容拉到最后,然后录制了一个动图,可以看到流星飞过的样子:

s2

实现效果

这次依然使用 JetpackPack Compose 作为 UI 框架来实现。

最终实现效果如图:

p1

代码地址

完整代码地址:starrySky

实现

分析背景组成

繁星

在开始实现之前,我们首先要分析一下豆瓣的这个背景都有些什么元素,它们的运行逻辑是什么。

我们先看一下这张仅有背景的截图:

s3

显而易见,该页面以纯黑色作为底色,然后点缀了一些白色或者说灰色的圆形小点,即繁星。

我原本以为这些繁星应该是随机生成的,但是经过我的观察和测试,实际上这些繁星都是固定不变的,我猜测这其实就是一整个静态图片。

但是我想实现不是这种的,如果只是一张静态图片那还有什么意思呢?

所以我准备更改为随机生成星星,且可以自定义星星的尺寸、颜色等参数。

流星

流星相对来说稍微复杂那么一点点,我做了一张流星局部放大且减速的动图:

s4

从上面这个减速动图中可以看出,流星的生成有如下几个要点:

  1. 流星刚出现时有一个透明度逐渐减小的渐变效果
  2. 流星从出现到结束,一直都在沿着一条直线平移
  3. 流星刚出现时较短,并且逐渐变长,但是在达到一定长度后就不再变化

compose 自定义绘制基础知识

分析完这个页面由什么构成的后,我们先别急着直接开始写,我先扩展几个关于 compose 自定义绘制的基础知识,后面会用到。

DrawScope

首先,在compose中如果想要自己绘制的话,需要在 DrawScope 中才能使用我们在 view 中熟悉的 drawXXX 绘制相应的图形。

那么,怎么才能使用 DrawScope 呢?

我们可以直接使用 Canvans ,它的 onDraw 参数接收的就是一个作用域为 DrawScope 的匿名函数,我们可以在这个函数中进行我们的绘制操作,例如,这里我使用 drawRect 画了一个白色的矩形:

p2

不过,仔细想想,我们这里的需求,直接使用 Canvans 合适吗?

我们需要做的只是一个背景啊,直接使用 Canvans 虽然也能实现我们的需求,但是总觉得怪怪的。

不用担心,compose 还有一个地方也提供了 DrawScope ,那就是在 Modifier 中,在 Modifier 中自定义绘制的话特别适合于给已有的布局加东西。

而 Modifier 中有三个绘制相关的 API 可以使用,分别是 drawWithContentdrawBehinddrawWithCache

其中,drawWithContent 是和上面的 Canvans 差不多,并且可以通过更改 drawContent() 的位置,来实现控制绘制内容和这个控件原有内容的位置关系。

drawBehind 顾名思义就是把我们的内容放到原有内容之下,嗯?这不就是我们要的吗?绘制背景嘛。其实使用 drawWithContent 可以实现和这个 API 完全一致的效果,但是这里咱们直接使用这个就行。

drawWithCache 看名字就知道,是带有缓存的绘制,我们可以缓存住一些不需要改变的对象,避免重复创建对象的开销。

关于这三个 API 的使用可以参考 自定义绘制

给自定义绘制内容添加动画

知道了往哪儿绘制图形后,下一步是了解一下如何给自定义绘制内容添加动画效果。

其实,给绘制内容添加动画效果和给普通的 compose 控件加动画基本一致。

例如,我给上面这个矩形添加一个旋转动画可以这样写:

@Preview
@Composable
fun PreviewTest() {
    Column(
        Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var state by remember {
            mutableStateOf(true)
        }
        
        val rotateValue by animateFloatAsState(targetValue = if (state) 90f else 0f)

        Canvas(
            modifier = Modifier.size(100.dp).clickable { state = !state }
            , onDraw = {
            withTransform(
                {
                    rotate(rotateValue)
                }
            ) {
                drawRect(Color.White)
            }
        })
    }
}

可以看到,与正常用法几乎没有区别,这里演示的是使用 draw 中的变换功能,旋转当前绘制的矩形,旋转的角度则由 animateFloatAsState 来提供,这样就实现了一个简单的旋转动画。

开始实现

基础结构

由于我们最终会在 Modifier 中进行绘制,如果直接写的话会显得很臃肿,而且也无法多次使用,所以我们需要实现一个 Modifier 的扩展函数,使用时只需要直接调用这个扩展函数即可:

fun Modifier.drawStarrySkyBg() : Modifier = composed { 
    drawBehind { 
        // ......
    }
}

使用时直接调用 Modifier.drawStarrySkyBg() 即可。

另外,在上面我们介绍过,可以使用 drawWithCache 缓存对象,为了性能更好,这里应该使用 drawWithCache 而不是直接使用 drawBehind

fun Modifier.drawStarrySkyBg() : Modifier = composed { 
    drawWithCache {
        // ……
        // 可以在这里初始化对象,这里的内容不会被 recompose
        
        onDrawBehind { 
            // ……
            // 这里和 drawBehind 一样,可以在这里进行绘制
        }
    }
}

绘制纯色背景

首先,我们直接绘制一个占满画布的矩形将背景覆盖掉,达到更改背景颜色的目的:

fun Modifier.drawStarrySkyBg(
    background: Color = Color.Black,
) : Modifier = composed {
    drawWithCache {
        // ……

        onDrawBehind {
            // ……
            // 绘制背景
            drawRect(color = background)
        }
    }
}

绘制星星

星星的绘制比较简单,直接使用 drawCircle 绘制圆形即可。

但是,这里我们需要实现的是,星星的位置、大小、颜色应该是随机的。

所以我们首先需要定义一个数据类 StarInfo 用于存放星星信息,然后在 CacheDrawScope 中初始化好星星信息,在 DrawScope 中直接根据这个信息绘制即可:

data class StarInfo(
    val offset: Offset,
    val color: Color,
    val radius: Float
)

当然,随机的颜色和尺寸应该是预设一组,而非真的完全随机,所以给这个函数添加参数

fun Modifier.drawStarrySkyBg(
    // ……
    starNum: Int = 20, // 需要生成多少个星星
    starColorList: List<Color> = listOf(Color(0x99CCCCCC), Color(0x99AAAAAA), Color(0x99777777)),
    starSizeList: List<Float> = listOf(0.8f, 0.9f, 1.2f),
    // ……
)

需要注意的是,这里的 starSizeList 并不是真正的圆形尺寸,而是缩放系数,因为圆形尺寸是按照当前可绘制区域的尺寸计算出来的,如果直接写死尺寸,会不太美观。

然后,定义并初始化星星信息:

drawWithCache {
    val random = Random(seed)
    val startInfoList = mutableListOf<StarInfo>()

    // 添加星星数据
    for (i in 0 until starNum) {
        val sizeScale = starSizeList.random(random)

        startInfoList.add(
            StarInfo(
                Offset( // 随机生成坐标
                    random.nextDouble(size.width.toDouble()).toFloat(), 
                    random.nextDouble(size.height.toDouble()).toFloat()
                ),
                starColorList.random(random),  // 随机选择一个预设颜色
                size.width / 200 * sizeScale  // 尺寸为可绘制区域大小的 1/200 并乘以随机选择到的缩放系数
            )
        )
    }
    
    // ……
}

上面代码中的 size 是当前可绘制区域的尺寸信息。

最后,开始绘制:

onDrawBehind {
    // ……
    
    // 绘制星星
    for (star in startInfoList) {
        drawCircle(color = star.color, center = star.offset, radius = star.radius)
    }
    
    // ……
}

绘制流星

绘制流星部分我们将分为三步走:

  1. 绘制出流星
  2. 让流星动起来
  3. 给流星加上一点细节

首先,我们需要绘制出流星的图案。

其实,这个流星无非就是一条直线,所以,我们只需要使用 drawLine 绘制直线即可。

drawLine 需要三个必须的参数:

  1. color: Color, 直线的颜色
  2. start: Offset, 直线的起点坐标
  3. end: Offset, 直线的终点坐标

为了提高扩展性,我们将颜色提出作为 drawStarrySkyBg 的参数,同时,流星并不是横平竖直的,而是有一定倾斜角度的,所以我们还要提供一个角度参数,另外,流星的线段宽度我们也提出来作为一个参数:

fun Modifier.drawStarrySkyBg(
    // ……
    meteorColor: Color = Color.White,
    meteorRadian: Double = 0.7853981633974483,  // 这里的角度是弧度,相当于45度
    meteorStrokeWidth: Float = 1f,
    // ……
) 

然后,绘制出一帧的流星:

drawLine(
    color = meteorColor,
    start = Offset(currentStartX, currentStartY),
    end = Offset(currentEndX, currentEndY),
    strokeWidth = meteorStrokeWidth
)

流星应该是从出现到结束一直都是在运动的,不可能是静态的,所以上面这个只是绘制出了流星某一个时刻的状态,所以我称之为绘制出了一帧。上面的起点坐标和终点坐标也应该是实时计算出来。

至于怎么计算的,我们先按下不表,先来说说怎么模拟流星的运动轨迹。

即,让流星动起来。

如果想要让绘制的内容动起来,理所当然的会想到应该使用动画相关的API,仔细分析一下我们这里的流星动画,它应该是无限运行的,因为流星需要一直都有,不能说是飞一次就销毁了是吧?

所以这里我们应该使用无限动画API rememberInfiniteTransition()

但是,应该将什么参数作为动画的值呢?

流星的坐标? 时间?

为了方便理解,这里我们选择使用时间作为动画值,而坐标由时间来实时计算出来。

因为如果直接将坐标作为动画值的话,不方便编写算法,同时也不好做出一些扩展。

编写动画参数如下:

val deltaMeteorAnim = rememberInfiniteTransition()
val meteorTimeAnim by deltaMeteorAnim.animateFloat(
    initialValue = 0f,
    targetValue = 300f,  // 这个值其实可以根据时间、速度、指定长度、以及当前绘制区域可用大小计算出来,但是我懒得算了,就直接写死一个比较大的值了
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = meteorTime, delayMillis = meteorScaleTime, easing = LinearEasing)
    )
)

这里我们使用 meteorTimeAnim 作为模拟的时间值,需要注意的是这个值并不是和现实时间对应的,只是一个模拟变化值。

这个值将会无限的重复运行,每次运行都会间隔 meteorScaleTime 毫秒,并且单次运行持续时间为 meteorTime 毫秒。运行的内容是将 meteorTimeAnim 线性的从 0 过渡到 300。

上面提到的这几个参数都抽出来作为函数的参数:

fun Modifier.drawStarrySkyBg(
    // ……
    meteorTime: Int = 1500,
    meteorScaleTime: Int = 3000,
    // ……
) 

既然选择了时间作为变化的值,那么对于流星的运动,我们可以直接按照 时间x速度 来计算出它的运动路程,因此,再抽出一个参数作为速度:

fun Modifier.drawStarrySkyBg(
    // ……
    meteorVelocity: Float = 10f,
    // ……
) 

需要注意的是,这里速度也只是一个模拟值,并不是真正的速度。

有了时间和速度我们就可以计算出流星实时运行的坐标值了,对了,上面我们已经说了流星不是横平竖直的飞行的,而是有一个角度的,所以实际坐标值计算应该是:

val cosAngle = cos(meteorRadian).toFloat()
val sinAngle = sin(meteorRadian).toFloat()

// 计算当前起点坐标
currentStartX = startX + meteorVelocity * meteorTimeAnim * cosAngle
currentStartY = startY + meteorVelocity * meteorTimeAnim * sinAngle

其中,startXstartY 是我们随机生成的一个初始坐标,因为流星每次出现的初始位置应该是随机的而不是固定在一个地方,所以我们给他加了一个初始坐标。

当然,这个只是计算流星的起点坐标,对于终点坐标,我们则需要做一些处理。

还记得吗?上面我们分析的时候说过,流星的长度并不是一开始就是目标长度的,而是从 0 开始逐渐伸长到目标长度的。

所以我们需要在流星长度未达到目标长度时,让流星的终点坐标"跑"的比起点坐标快:

// 如果长度未达到目标长度,则开始增长长度,具体表现为计算终点坐标时,速度是起点的两倍
if (currentLength < meteorLength) {
    currentEndX = startX + meteorVelocity * 2 * meteorTimeAnim * cosAngle
    currentEndY = startY + meteorVelocity * 2 * meteorTimeAnim * sinAngle
}
else { // 已达到目标长度,直接用起点坐标加上目标长度即可得到终点坐标
    currentLength = meteorLength
    currentEndX = currentStartX + meteorLength * cosAngle
    currentEndY = currentStartY + meteorLength * sinAngle
}

在这里,我们直接把终点坐标运行的速度设置为起点坐标的两倍,其实这里可以编写一个更复杂的加速度算法,使得流星运行起来更自然,更舒适,但是这里我们就不写这么复杂了,感兴趣的可以自己修改。

其中,当前流星长度的计算公式为:

// 只有未达到目标长度才实时计算当前长度
if (currentLength != meteorLength) {
    currentLength = sqrt(
        (currentEndX - currentStartX).pow(2) + (currentEndY - currentStartY).pow(2)
    )
}

这就是数学中的计算两点之间的距离公式,这里就不展开讲了,感兴趣的可以自己去看看。

由于受到浮点数计算精度影响还有为了性能更优,我们只会在目标长度和当前实际长度不一致时才计算当前长度。

并且我们会在当前长度大于或等于目标长度时就直接把目标长度复制给当前长度,确保它俩能保持一致。

对了,流星的目标长度同样是抽出来作为函数的一个参数:

fun Modifier.drawStarrySkyBg(
    // ……
    meteorLength: Float = 500f,
    // ……
)

经过上面的计算,我们就能够得到一个飞翔的流星了。

接下来,就是给这个流星的动画加上一点细节。

首先是流星刚出来时的透明度过度动画:

val meteorAlphaAnima by deltaMeteorAnim.animateFloat(
    initialValue = 0f,
    targetValue = 1000f, // 透明度的动画时长应该是整体动画的 1/10 。这里直接使用1000作为目标值
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = meteorTime, delayMillis = meteorScaleTime, easing = LinearEasing)
    )
)

// ……

// 绘制流星
drawLine(
    // ……
    alpha = (meteorAlphaAnima / 100).coerceAtMost(1f)
)

在这里,我们透明度的动画值依旧使用的是和时间一样的无限动画,只不过我们把目标值设置为了 1000, 然后在实际使用时将其除以 100 , 并且保证透明度不大于 1 (该参数不能大于1)。

这样处理的目的是使得透明度动画能够保持和时间的同步,并且确保透明度会在时间走了 1/10 时完全不透明,即只有最开始的 1/10 时间有透明度过渡效果。

其他的一些小细节,诸如流星已经飞出屏幕边界后就不再计算和绘制、流星初始坐标随机生成的边界控制、流星可以使用无限拖尾等这里就不再赘述,感兴趣的可以直接看代码。

代码非常简单,只有不到200行。

地址:starrySky

预览效果

这个函数封装好后使用十分简单,只需要在想要添加星空背景的组件的 modifier 参数加上 .drawStarrySkyBg() 即可,例如:

Column(
    Modifier
        .fillMaxSize()
        .drawStarrySkyBg(), // 给这个 Column 加上星空背景
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    var text by remember { mutableStateOf("Hello equationl  \n at starry sky\n${System.currentTimeMillis()}") }

    Text(
        text = text,
        color = Color.White,
        fontSize = 32.sp,
        modifier = Modifier.clickable {
            text = "Hello equationl  \n at starry sky\n${System.currentTimeMillis()}"
        }
    )
}

参考资料

  1. Exploring Jetpack Compose Canvas: the power of drawing
  2. Jetpack Compose 绘制 Canvas,DrawScope, 以及Modifier.drawWithContent,BlendMode讲解
  3. Custom Canvas Animations in Jetpack Compose
  4. Compose 自定义绘制