前言

背景

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

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

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

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

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

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

实现效果

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

最终实现效果如图:

代码地址

完整代码地址:starrySky

实现

分析背景组成

繁星

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

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

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

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

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

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

流星

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

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

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

compose 自定义绘制基础知识

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

DrawScope

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

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

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

不过,仔细想想,我们这里的需求,直接使用 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 {// ……// 可以在这里初始化对象,这里的内容不会被 recomposeonDrawBehind { // ……// 这里和 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 * cosAnglecurrentEndY = startY + meteorVelocity * 2 * meteorTimeAnim * sinAngle
}
else { // 已达到目标长度,直接用起点坐标加上目标长度即可得到终点坐标currentLength = meteorLengthcurrentEndX = currentStartX + meteorLength * cosAnglecurrentEndY = 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 自定义绘制

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

  1. 安卓动画壁纸实战:制作一个星空动态壁纸(带随机流星动画)

    前言 在我之前的文章 羡慕大劳星空顶?不如跟我一起使用 Jetpack compose 绘制一个星空背景(带流星动画) 中,我们使用 Compose 实现了星空背景效果. 并且调用非常方便,只需要一行 ...

  2. 上海天文台实习的一个项目-根据卫星数据绘制南极星空分布图

    大三的时候做的一个东西,当时在上海天文台实习了15天,在老师要求下写了下面一大段代码,现在想起来,都不记得这是干的什么了. 人最可悲的,就是很多东西本来学会了,结果没过多久就忘了. 我只记得,当时是美 ...

  3. 「Python海龟画图」利用海龟画笔绘制满天星空

    设置海龟画布 功能要求 设置海龟画布大小为800×600,并设置画布的背景图(背景图片和Python源文件存放在同一个目录下). 实例代码 import turtleturtle.setup(800, ...

  4. 【C++】教你如何在中秋节给家人们画一个星空

    前言 将至中秋,想必大家都想给自己的家人们一个惊喜吧!今天就手把手地教大家如何用C++和Easyx画一个星空. (效果图:) 一.准备Easyx 首先我们要前往Easyx官网下载安装程序, 下载完成后 ...

  5. Java黑皮书课后题第5章:*5.23(演示抵消错误)当处理一个很大的数字或很小的数字时候,会产生一个抵消错误。……编写程序对上面的数列从左到右和从右向左计算的结果进行比较,n=50000

    5.23(演示抵消错误)1 + 1/2 + 1/3 + -- + 1/n,编写程序对上面的数列从左到右和从右向左计算的结果进行比较,n=50000 题目 题目概述 破题 代码 运行示例 题目 题目概述 ...

  6. 输入5个学生的名字(英文),使用冒泡排序按从大到小排序。 提示:涉及到字符串数组,一个字符串是一个一维字符数组;一个 字符串数组就是一个二维字符数组。...

    输入5个学生的名字(英文),使用冒泡排序按从大到小排序. 提示:涉及到字符串数组,一个字符串是一个一维字符数组:一个 字符串数组就是一个二维字符数组. #include <stdio.h> ...

  7. 用HTML5绘制的一个星空特效图

    <!doctype html> <html lang="en"> <head><meta charset="UTF-8" ...

  8. HTML5期末考核大作业:美食主题网站设计——沪上美食(9页)带Flash动画视频导航下拉表单 HTML+CSS+JavaScript

    HTML5期末大作业:美食主题网站设计--沪上美食(9页)带Flash动画视频导航下拉表单 HTML+CSS+JavaScript 期末作业HTML代码 学生网页课程设计期末作业下载 web网页设计制 ...

  9. HTML5期末大作业:绿色环境保护网站设计(10页) 带flash动画带背景音HTML+CSS+JavaScript

    HTML5期末大作业:绿色环境保护网站设计(10页) 带flash动画带背景音HTML+CSS+JavaScript 学生DW网页设计作业成品 web课程设计网页规划与设计 大学生毕设网页设计源码HT ...

最新文章

  1. Android小項目之---吃飯選哪?--》選擇對話框(附源碼)
  2. 图像多分类——卷积神经网络
  3. 戴尔网站的服务器,PowerEdge 11G R610机架式服务器
  4. 四种浏览器对 clientHeight、offsetHeight、scrollHeight、clientWidth、offsetWidth 和 scrollWidth 的解释差异...
  5. msm8953 fm设置频段流程
  6. sleuth zipkin mysql_springCloud的使用08-----服务链路追踪(sleuth+zipkin)
  7. Discuz!ML 3.x任意代码执行漏洞
  8. java 梯形校正_高清投影神器 联想YOGA平板2 Pro评测
  9. 【C补充】qsort函数 —— 数组元素排序
  10. python3 使用writerows写入csv时有多余空行的处理办法
  11. 更改itunes备份路径【windows备份iphone数据】
  12. 如何建立一个快速显示桌面的快捷方式?
  13. 人工神经网络概念及组成,人工神经网络基本概念
  14. dell系统重装后无法进入系统_戴尔装win7后无法进入系统怎么办?戴尔装win7后进不了系统解决方法...
  15. Domoticz-Dummy(虚拟传感器)
  16. 网易微博宣布将用户迁移至轻博客Lofter
  17. 微信公众账号高级接口使用小结
  18. Ecshop实现注册页面手机号唯一的验证
  19. 前端JavaScript自学复盘梳理D2
  20. mat 释放_Opencv - 释放内存将cv :: Mat引用计数器更改为零

热门文章

  1. Linux机械键盘按键无法识别,机械键盘个别键位失灵该怎么修复?
  2. HTML:一种标记语言而不是编程语言(6.0)
  3. 关于Google浏览器和Youdao桌面词典的UI设计
  4. 计算机找不到系统影响怎么办,电脑开机显示找不到系统怎么办?
  5. win7 家庭组连接 使用用户账号和密码连接到其他计算机,Win7 家庭组 共享 要密码用户名 及无权访问 解决方案...
  6. 服务器时区修改引发的后果
  7. 魅族16Android版本,魅族16极光蓝和魅族16有什么区别?极光蓝对比普通版
  8. 正则表达式(详细了解 )
  9. 红米手机android版本升级,红米6A 也能升级 Android P,小米公布各机型升级计划
  10. 【嵌入式】树莓派3b+人像识别摄像头安装和使用