我报名参加金石计划1期挑战——分割10万奖池,这是我的第3篇文章,点击检查活动详情

前言

布景

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

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

好美丽!我这才发现本来这个页面的布景是一个星空!时不时的还会有流星飞过!

这么美丽的布景,不仿写一下真的对不起它了!

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

s1.png

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

s2.gif

完成作用

这次仍然运用 JetpackPack Compose 作为 UI 结构来完成。

终究完成作用如图:

p1.gif

代码地址

完好代码地址:starrySky

完成

剖析布景组成

繁星

在开端完成之前,咱们首要要剖析一下豆瓣的这个布景都有些什么元素,它们的运转逻辑是什么。

咱们先看一下这张仅有布景的截图:

s3.png

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

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

可是我想完成不是这种的,假如仅仅一张静态图片那还有什么意思呢?

所以我预备更改为随机生成星星,且可以自界说星星的尺度、色彩等参数。

流星

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

s4.gif

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

  1. 流星刚呈现时有一个透明度逐步减小的渐变作用
  2. 流星从呈现到完毕,一向都在沿着一条直线平移
  3. 流星刚呈现时较短,而且逐步变长,可是在到达必定长度后就不再改变

compose 自界说制作基础知识

剖析完这个页面由什么构成的后,咱们先别急着直接开端写,我先扩展几个关于 compose 自界说制作的基础知识,后边会用到。

DrawScope

首要,在compose中假如想要自己制作的话,需求在 DrawScope 中才干运用咱们在 view 中了解的 drawXXX 制作相应的图形。

那么,怎样才干运用 DrawScope 呢?

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

p2.png

不过,细心想想,咱们这儿的需求,直接运用 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 自界说制作