从 0 到 1 搞一个 Compose Desktop 版本的玩天气之绘制
上一篇文章 “从 0 到 1 搞一个 Compose Desktop 版本的玩天气之踩坑” 中大概说了下刚开始使用 Compose Desktop
会遇到的一些问题,帮大家踩了踩坑,那么这一篇则会带大家一起来看下项目中绘制的一些东西,再来看下项目的最终实现效果吧!
视频
动画的使用
通过上面的 GIF 图可以看到项目中使用到了一些动画,效果还是非常不错的,其实实现起来非常简单!
可见性动画
首先来看下可见性动画的使用,之前我写过一个专栏,里面专门说了下 Compose
中的动画的使用及原理,有兴趣的大家可以去看下:Compose 动画开发艺术探索 。
可见性动画在页面左边用到了,点击添加按钮出现搜索页面的时候就使用的是可见性动画,简单看下代码:
@Composable
fun LeftInformation() {
var showSearch by rememberSaveable { mutableStateOf(false) }
Box(
Modifier.fillMaxHeight().width(300.dp).padding(end = 10.dp)
) {
WeatherDetails(onAddClick = {
showSearch = true
})
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(),
exit = slideOutHorizontally()
) {
SearchCity()
}
}
}
可以看到这块在进入的时候使用了 slideInHorizontally
动画,顾名思义,就是水平滑动展开,退出的时候使用了 slideOutHorizontally
,就是水平滑动退出。
实现效果这里就不展示了,就是文章左边的动画效果。
无限重复动画
无限重复动画在左边展示天气信息的天气图标上用到了,这块的重复动画使用了两种,如果是晴天的话就修改 Modifier.rotate
,因为晴天是太阳,旋转的话好看一些,如果不是晴天的话旋转不好看,所以改为 Modifier.offset
,这样平移的话好看一些。来看下实现代码吧:
@Composable
private fun RotateWeatherIcon(icon: String) {
val infiniteTransition = rememberInfiniteTransition()
val modifier = if (icon == "100") {
val rotate by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(3500, easing = LinearOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
Modifier.rotate(rotate)
} else {
val offsetX by infiniteTransition.animateValue(
initialValue = (-30).dp, // 初始值
targetValue = 30.dp, // 目标值
typeConverter = TwoWayConverter(
{ AnimationVector1D(it.value) },
{ it.value.dp }), // 类型转换
animationSpec = infiniteRepeatable( // 动画规格!!!
animation = tween(3500, easing = LinearOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
Modifier.offset(x = offsetX)
}
Image(
painter = painterResource(getWeatherIcon(icon)),
"",
modifier = modifier.size(170.dp).padding(10.dp)
)
}
无限重复动画的使用方式也不难,在之前的章节中说过,感兴趣的可以去上面所说的专栏中查看,大家放心,Jetpack Compose
中动画的使用方式和 Compose Desktop
一致。
空气质量
空气质量就是右边天气详情中的第一个模块,样子如下图所示:
这块是一个 “自定义 View”,为什么要加引号呢?因为这是 Compose
啊,不是安卓的 View
系统😂。
下面来看下这个 “自定义 View” 如何实现的吧!
@Composable
private fun AirQualityProgress(aqiValue: Int) {
Canvas {
drawLine(
brush = Brush.linearGradient(
0.0f to Color(red = 139, green = 195, blue = 74),
0.1f to Color(red = 255, green = 239, blue = 59),
0.2f to Color(red = 255, green = 152, blue = 0),
0.3f to Color(red = 244, green = 67, blue = 54),
0.4f to Color(red = 156, green = 39, blue = 176),
1.0f to Color(red = 143, green = 0, blue = 0),
),
start = Offset.Zero,
end = Offset(size.width, 0f),
strokeWidth = 20f,
cap = StrokeCap.Round,
)
drawPoints(
points = arrayListOf(
Offset(size.width / 500 * aqiValue, 0f)
),
pointMode = PointMode.Points,
color = Color.White,
strokeWidth = 20f,
cap = StrokeCap.Round,
)
}
}
因为我没有开发过桌面的应用,所以不太清楚在桌面程序中实现这样的一个控件需要写多少代码,我只开发过安卓,只能拿安卓原生 View
做对比,在安卓 View
中如果想实现这样的一个控件的话绝对不止这么一点代码。。。
来简单解释下这个控件吧:在 Compose
中绘制需要使用可组合项 Canvas
,然后来绘制下面的那条线,线上的颜色是渐变的,在 Compose
中只需要使用 Brush
就可以实现渐变,也可以控制在不同的进度显示不同颜色,空气质量一般分为六个等级:优、良、轻度污染、中度污染、重度污染和严重污染,所以上面对应有六种颜色。最后算出当前的 AQI 值应该绘制的地方进行绘制即可。
7 日天气预报
24 小时天气预报中没有什么需要说的,一个 LazyRow
就实现了,就直接跳过了。
接下来来看下 7 日天气预报,这里其实大部分也不难,但注意看右边的温度条,这是模仿苹果天气中的温度条实现的,下面来看下苹果的样子吧:
再来看下我模仿实现的效果:
不能说一模一样,只能说大差不离。
在模仿苹果这个小彩条的时候刚开始就犯了难,这是啥意思啊。。。这条里面都代表着什么啊,也看不太懂,后来网上找了半天才知道。
- 小彩条的长度代表温差,彩条越长温差越大。
- 根据最近 10 天的温度,分别设置最高值和最低值。例如上面的苹果截图,近十天的最高温度为4度,则这组彩条最右端代表 4 度。 近十天最低温为 -12 度,那么这组彩条最左端就代表 -12 度。左右两端的极值不是固定不变的。
- 小白点代表了此时的温度。
搞明白这个小彩条的含义就好说了,来自定义下这个控件吧!
@Composable
private fun TemperatureChart(min: Int, max: Int, currentMin: Int,
currentMax: Int, currentTemperature: Int = -100) {
val currentMinColor: Color = getTemperatureColor(currentMin)
val currentMaxColor: Color = getTemperatureColor(currentMax)
// 计算周温差
val num = max - min
Canvas {
// 绘制底条
drawLine(
color = Color.Gray,
start = Offset.Zero,
end = Offset(size.width, 0f),
strokeWidth = 10f,
cap = StrokeCap.Round,
)
// 绘制这一天的气温
drawLine(
brush = Brush.linearGradient(
0.0f to currentMinColor,
1.0f to currentMaxColor,
),
start = Offset(size.width / num * (currentMin - min), 0f),
end = Offset(size.width / num * (currentMax - min), 0f),
strokeWidth = 10f,
cap = StrokeCap.Round,
)
// 如果是当天,则绘制当前温度小白点
if (currentTemperature > -100) {
drawPoints(
points = arrayListOf(
Offset(size.width / num * (currentTemperature - min), 0f)
),
pointMode = PointMode.Points,
color = Color.White,
strokeWidth = 10f,
cap = StrokeCap.Round,
)
}
}
}
首先看下这个可组合项接收的几个参数:
- min:未来几天最低温度
- max:未来几天最高温度
- currentMin:当前绘制天的最低温度
- currentMax:当前绘制天的最高温度
- currentTemperature:当前天的当前温度
再简单说下函数内容,先计算下这几天的温差,然后绘制温度底条,再然后绘制温度条,这个温度条是渐变的,需要根据不同温度换不同颜色,最后判断是不是当天,如果是当天的就绘制当前温度的小白点。
上面调用一个函数 getTemperatureColor
,这是为了计算不同温度的颜色的方法,来看下这个方法吧:
/**
* 获取不同气温的颜色值,需要动态判断
*/
private fun getTemperatureColor(temperature: Int): Color {
return if (temperature < -20) {
Color(red = 26, green = 92, blue = 249)
} else if (temperature < 30) {
Color(red = 253, green = 138, blue = 11)
} else {
Color(red = 248, green = 60, blue = 30)
}
}
这块没有写全这些颜色,其实写了挺多,篇幅原因就不写了,大家能理解就好。
太阳月亮
顾名思义,太阳月亮就是指的日出日落和月出月落,还是再来看下实现好的样式吧:
根据日出日落和月出月落的时间来展示当前太阳和月亮的状态。由上面图大概可以看出,需要使用到贝塞尔曲线,由于只是一段曲线,所以使用二阶贝塞尔曲线就可以了。
什么是贝塞尔曲线呢?来看下百度百科的描述吧:
贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段与节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。
下面来简单看下二阶贝塞尔曲线的简单动画吧:
二阶贝塞尔曲线的公式如下:
B(t)=(1−t)2P0+2t(1−t)P1+t2P2,t∈[0,1]
下面来看下在 Compose
中如何绘制二阶贝塞尔曲线吧:
Canvas {
val path = Path()
path.moveTo(0f, size.height)
// 二阶贝塞尔曲线
path.quadraticBezierTo(
size.width / 2, -size.height,
size.width, size.height
)
drawPath(
path = path, color = Color(red = 255, green = 193, blue = 7, alpha = 255),
style = Stroke(width = 3f)
)
}
可以看到在 Compose
使用 Path
的 quadraticBezierTo
函数来绘制二阶贝塞尔曲线,这块需要解释下,二阶贝塞尔曲线一共需要三个点,但 quadraticBezierTo
函数中只接收了两个点,那剩下一个点呢?其实 Path
先 moveTo
到的点就是第一个点,quadraticBezierTo
函数接收的第一个点是控制点,第二个参数是终点。绘制完后贝塞尔曲线后还要绘制曲线两边的圆点:
drawPoints(
points = arrayListOf(
Offset(0f, size.height),
Offset(size.width, size.height)
),
pointMode = PointMode.Points,
color = Color(red = 255, green = 193, blue = 7, alpha = 255),
strokeWidth = 20f,
cap = StrokeCap.Round,
)
绘制完贝塞尔曲线和圆点之后就该绘制太阳和月亮图标了,这块需要使用贝塞尔曲线的公式来计算点的坐标了。绘制点之前需要计算当前时间占太阳或月亮在天上的百分比:
fun getAccounted(rise: String, set: String, isSun: Boolean = true): Double {
val calendar = Calendar.getInstance()
val currentMills = calendar.timeInMillis
calendar.set(Calendar.HOUR_OF_DAY, getHour(rise))
calendar.set(Calendar.MINUTE, getMinute(rise))
val riseMills = calendar.timeInMillis
if (!isSun) {
calendar.set(Calendar.DAY_OF_MONTH, calendar.get(Calendar.DAY_OF_MONTH) + 1)
}
calendar.set(Calendar.HOUR_OF_DAY, getHour(set))
calendar.set(Calendar.MINUTE, getMinute(set))
val setMills = calendar.timeInMillis
val result = (currentMills - riseMills) / (setMills - riseMills).toDouble()
return if (currentMills < riseMills) 0.0 else if (result > 1) 1.0 else result
}
这块的代码不多,使用 Calendar
来获取当前毫秒值存下来,然后设置日出日落的小时分钟并记录下来毫秒值,最后进行计算即可。
现在百分比也有了,只剩下计算贝塞尔曲线上的坐标点了,先来看下计算坐标点的公式吧:
P0(起始点) , P1(控制点), P2 (终点)
P0(x1,y1),P2(x2,y2), P1(cx,cy)
val x = Math.pow(1-t, 2) * x1 + 2 * t * (1-t) * cx + Math.pow(t, 2) * x2
val y = Math.pow(1-t, 2) * y1 + 2 * t * (1-t) * cy + Math.pow(t, 2) * y2
公式是固定的,只需要往里套点即可:
val x = (1.0 - sunResult).pow(2.0) * 0f + 2 * sunResult * (1 - sunResult) * (size.width / 2) + sunResult.pow(2.0) * size.width
val y = (1.0 - sunResult).pow(2.0) * size.height + 2 * sunResult * (1 - sunResult) * (-size.height) + sunResult.pow(2.0) * size.height
计算出来贝塞尔曲线中的点后就该绘制月亮或太阳的图标了:
drawImage(
image = sunImage,
topLeft = Offset(
x - sunImage.width / 2,
x - sunImage.height / 2
)
)
这块的图片需要 ImageBitmap
格式,直接使用上一篇文章中的 useResource
即可生成。drawImage
中的 topLeft
参数表示左上角的坐标,默认的话时(0,0),但图片有宽高,所以需要减去宽高的一半,这样太阳和月亮的图标才能显示在正中间。
跳转浏览器
在安卓中咱们可以使用 WebView
来展示网页,但是在桌面版的应用中就没有了,需要使用系统自带的浏览器,那使用 Compose Desktop
应该如何打开系统自带的浏览器呢?可以使用 Desktop
中的 browse
方法,下面是我写的一个扩展函数:
/**
* 通过字符串打开系统默认浏览器
*/
fun String?.openBrowse() {
if (this?.startsWith("http") == false && !this.startsWith("https")) {
throw IllegalArgumentException("this illegal argument exception")
}
try {
val uri = URI.create(this ?: "https://www.baidu.com")
// 获取当前系统桌面
val dp = Desktop.getDesktop()
// 判断系统桌面是否支持要执行的功能
if (dp.isSupported(Desktop.Action.BROWSE)) {
// 获取系统默认浏览器打开链接
dp.browse(uri)
}
} catch (e: Exception) {
println(e.message)
}
}
首先判断当前字符串前缀是否为 “http” 和 “https”,如果不是的话就证明这个字符串不是网络链接,就直接抛出异常,剩下代码中的注释写的已经比较全了,就不多说了。
函数有了再来看下如何调用吧:
Row {
Image(painter = painterResource("image/ic_launcher.svg"), "", modifier = Modifier.size(15.dp))
Spacer(modifier = Modifier.width(5.dp))
Text(text = "数据来自和风天气", fontSize = 12.sp, modifier = Modifier.clickable {
fxLink.openBrowse()
})
}
很简单,直接调用即可。运行效果就不在这里进行展示了,大家可以下载代码运行看看。
对话框
在安卓中对话框的使用场景实在是太多了,就不一一列举了,随便打开一个应用里面都有一堆对话框,那么在 Compose Desktop
中该如何弹出对话框呢?先来看下 Dialog
的函数定义吧:
@Composable
fun Dialog(
onCloseRequest: () -> Unit,
state: DialogState = rememberDialogState(),
visible: Boolean = true,
title: String = "Untitled",
icon: Painter? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
onKeyEvent: ((KeyEvent) -> Boolean) = { false },
content: @Composable DialogWindowScope.() -> Unit
)
看到这些参数眼熟么?和上一篇文章中提到的 Window
基本一致,不同的就是这块的 state
为 DialogState
,接下来看下 DialogState
吧:
interface DialogState {
var position: WindowPosition
var size: DpSize
}
可以看到通过定义 DialogState
可以定义对话框的位置和大小,大小可以直接通过 DpSize
设置,位置的话通过 WindowPosition
来设置,但 WindowPosition
可以通过绝对位置和相对位置来设置位置:
// 绝对位置,绝对坐标
fun WindowPosition(x: Dp, y: Dp) = WindowPosition.Absolute(x, y)
// 相对位置
fun WindowPosition(alignment: Alignment) = WindowPosition.Aligned(alignment)
可以看到对话框也可以设置标题和图标,剩下的参数都见过,就不过多介绍了。
来看看在 Compose Desktop
中如何使用对话框吧:
val alertDialog = rememberSaveable { mutableStateOf(false) }
Dialog(
onCloseRequest = { alertDialog.value = false }, visible = alertDialog.value,
state = rememberDialogState(size = DpSize(300.dp, 200.dp)),
title = "Weather", icon = buildPainter("image/ic_launcher.svg")
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 20.dp)
) {
Text(
text = title,
fontSize = 16.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSecondary,
modifier = Modifier.padding(horizontal = 20.dp)
)
}
}
代码中设置了下对话框的大小,对话框使用方式和 Jetpack Compose
基本一致,看下运行效果吧:
可以看到对话框使用很简单,有需要的可以在 Dialog
中添加一些别的可组合项进行使用。
桌面的 PopopWindow
在安卓中咱们经常使用的 PopopWindow
如何在 Compose Desktop
中使用呢?
Compose
中可以直接使用 Popup
来构建类似于安卓中 PopupWindow
的弹框,但我试着直接使用了下 Popup
,不太好控制弹出的地方,所以我就想着有没有能更简单控制弹出位置的方法,仔细找了下,果然有!可以使用 CursorDropdownMenu
,它可以将 Popup
在鼠标点击的地方弹出。
@Composable
fun CursorDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
focusable: Boolean = true,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
......
Popup(
focusable = focusable,
onDismissRequest = onDismissRequest,
popupPositionProvider = rememberCursorPositionProvider(),
onKeyEvent = {
handlePopupOnKeyEvent(it, onDismissRequest, focusManager!!, inputModeManager!!)
},
)
......
}
上面就是 CursorDropdownMenu
进行了一些删减的源码,可以看到里面也调用了 Popup
。
接下来看下使用方式吧:
var showPopupWindow by remember { mutableStateOf(false) }
CursorDropdownMenu(
showPopupWindow,
onDismissRequest = { showPopupWindow = false },
modifier = modifier.width(300.dp).padding(horizontal = 15.dp).padding(bottom = 10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = data.titleDetails,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSecondary
)
IconButton(onClick = { showPopupWindow = false }) {
Icon(Icons.Sharp.Close, "Close")
}
}
}
其实使用方法和对话框是类似的,都是通过定义一个是否展开的变量,然后通过这个变量来确定当前弹框是否显示。
下面来看下运行效果:
可以看到还是挺好看的,哈哈哈!
系统菜单
在 Mac 中右上角会显示应用的菜单,如下图所示:
别的应用有,我们当然也想要!那咱们的 Compose Desktop
应该如何展示呢?
放心,Jetbrains
都为我们想到了!来看看如何使用吧!
Window(onCloseRequest = ::exitApplication, title = "天青色等烟雨") {
MenuBar {
Menu("文件", mnemonic = 'F') {
Item("复制(假的)", onClick = { action = "Last action: Copy" }, shortcut = KeyShortcut(Key.C, ctrl = true))
Item("粘贴(假的)", onClick = { action = "Last action: Paste" }, shortcut = KeyShortcut(Key.V, ctrl = true))
}
Menu("帮助", mnemonic = 'H') {
Item("天气帮助", onClick = { action = "Last action: Help" })
}
}
App()
}
直接使用 MenuBar
就可以展示类似于上方图片中的菜单了,需要注意的是 MenuBar
需要 FrameWindowScope
,上一篇文章中所说 Window
的 content
就是 FrameWindowScope
,所以可以进行使用,要直接拿出来就不行了,如果想拿出来的话需要添加一个扩展函数:
private fun FrameWindowScope.DemoMenu() {
MenuBar {
Menu("文件", mnemonic = 'F') {
Item("复制(假的)", onClick = { action = "Last action: Copy" }, shortcut = KeyShortcut(Key.C, ctrl = true))
Item("粘贴(假的)", onClick = { action = "Last action: Paste" }, shortcut = KeyShortcut(Key.V, ctrl = true))
}
Menu("帮助", mnemonic = 'H') {
Item("天气帮助", onClick = { action = "Last action: Help" })
}
}
}
简单说下吧,先来看下 Menu
吧:
@Composable
fun Menu(
text: String,
mnemonic: Char? = null,
enabled: Boolean = true,
content: @Composable MenuScope.() -> Unit
)
函数参数并不多,只有 mnemonic
不太好理解,它对应于键盘上某个键的字符,当这个键和 Alt 被按下时菜单将打开。然后需要重点看下 content
,它的参数类型为 MenuScope
,那就来看下 MenuScope
中都能添加什么可组合项吧!
class MenuScope internal constructor(private val impl: MenuScopeImpl) {
@Composable
fun Menu()
@Composable
fun Separator() = impl.Separator()
@Composable
fun Item()
@Composable
fun CheckboxItem()
@Composable
fun RadioButtonItem()
}
可以看到,还能再添加 Menu
,剩下可添加的还有 Item
、Separator
、CheckboxItem
和 RadioButtonItem
,故名思义,分别是条目、分隔符、复选框和单选框。
废话不多说,运行看下效果吧!
大家在使用的时候可以根据需求选择需要使用的可组合项来组合系统菜单。
托盘及通知
托盘是什么呢?在 Mac
中右上角展示的就是托盘,如下图所示;Windows
中在右下角。
托盘
同样的,Jetbrains
也为我们想到了,使用方法也不难,直接来看下吧:
Tray(
state = rememberTrayState(),
icon = painterResource("image/launcher.png"),
menu = {
Item(
"天气预报",
onClick = {}
)
Separator()
Item(
"退出",
onClick = {}
)
}
)
在 Compose Desktop
中使用 Tray
来为应用添加系统托盘,这里的 Menu
其实和上面系统菜单中的 Menu
是一回事,所以上面所描述的 Item
、Separator
、CheckboxItem
和 RadioButtonItem
都可以进行使用。
下面来运行看下实际效果吧:
这块还有一个小知识点,咱们有时候使用的一些工具其实都没有真正页面,只是在系统托盘中存在,Tray
也可以在没有窗口的情况下创建托盘应用程序:
fun main() = application {
Tray(
icon = painterResource("image/launcher.png"),
menu = {
Item(
"退出",
onClick = ::exitApplication
)
}
)
}
这样就可以创建出一个没有窗口的程序了。
通知
咱们还可以使用系统托盘,也就是 Tray
向用户发送通知。一共有 3 种类型的通知:
- notify - 简单的通知
- warn - 警告通知
- Error - 错误通知
下面来看下使用方法:
val trayState = rememberTrayState()
val infoNotification = rememberNotification("天气预报", "明天的天气很好,建议出门遛弯", Notification.Type.Info)
Tray(
state = trayState,
icon = painterResource("image/launcher.png"),
menu = {
Item(
"天气预报",
onClick = {
trayState.sendNotification(infoNotification)
}
)
Separator()
Item(
"退出",
onClick = {
isOpen.value = false
}
)
}
)
使用起来很简单,先使用 rememberNotification
来构建出一个 Notification
,然后直接使用 trayState
中的 sendNotification
进行发送通知即可。
我录制了一个完整的显示系统菜单、托盘以及通知的 GIF ,大家来看下效果吧。
小结
本文大概描述了下我在编写这个天气应用时遇到的一些问题及难点,还有自定义绘制的一些避坑点到此就告一段落了。此项目所有代码都放到了 Github
中。
Github
地址:https://github.com/zhujiang521/PlayWeather/tree/desktop
如果文中写的有误,欢迎在评论区提出,咱们一起探讨。
文章如果能帮助到大家,哪怕是一点,我也非常高兴,先这样。