ComposeDesktop初体验之绘制
从0到1搞一个ComposeDesktop版本的玩天气之绘制
上一篇文章从0到1搞一个ComposeDesktop版本的玩天气之踩坑中大概说了下刚开始使用ComposeDesktop会遇到的一些问题,帮大家踩了踩坑,那么这一篇则会带大家一起来看下项目中绘制的一些东西,再来看下项目的最终实现效果吧!
视频动画的使用
通过上面的GIF图可以看到项目中使用到了一些动画,效果还是非常不错的,其实实现起来非常简单!可见性动画
首先来看下可见性动画的使用,之前我写过一个专栏,里面专门说了下Compose中的动画的使用及原理,有兴趣的大家可以去看下:Compose动画开发艺术探索。
可见性动画在页面左边用到了,点击添加按钮出现搜索页面的时候就使用的是可见性动画,简单看下代码:ComposablefunLeftInformation(){varshowSearchbyrememberSaveable{mutableStateOf(false)}Box(Modifier。fillMaxHeight()。width(300。dp)。padding(end10。dp)){WeatherDetails(onAddClick{showSearchtrue})AnimatedVisibility(visibleshowSearch,enterslideInHorizontally(),exitslideOutHorizontally()){SearchCity()}}}
可以看到这块在进入的时候使用了slideInHorizontally动画,顾名思义,就是水平滑动展开,退出的时候使用了slideOutHorizontally,就是水平滑动退出。
实现效果这里就不展示了,就是文章左边的动画效果。无限重复动画
无限重复动画在左边展示天气信息的天气图标上用到了,这块的重复动画使用了两种,如果是晴天的话就修改Modifier。rotate,因为晴天是太阳,旋转的话好看一些,如果不是晴天的话旋转不好看,所以改为Modifier。offset,这样平移的话好看一些。来看下实现代码吧:ComposableprivatefunRotateWeatherIcon(icon:String){valinfiniteTransitionrememberInfiniteTransition()valmodifierif(icon100){valrotatebyinfiniteTransition。animateFloat(initialValue0f,targetValue360f,animationSpecinfiniteRepeatable(animationtween(3500,easingLinearOutSlowInEasing),repeatModeRepeatMode。Reverse))Modifier。rotate(rotate)}else{valoffsetXbyinfiniteTransition。animateValue(initialValue(30)。dp,初始值targetValue30。dp,目标值typeConverterTwoWayConverter({AnimationVector1D(it。value)},{it。value。dp}),类型转换animationSpecinfiniteRepeatable(动画规格!!!animationtween(3500,easingLinearOutSlowInEasing),repeatModeRepeatMode。Reverse))Modifier。offset(xoffsetX)}Image(painterpainterResource(getWeatherIcon(icon)),,modifiermodifier。size(170。dp)。padding(10。dp))}
无限重复动画的使用方式也不难,在之前的章节中说过,感兴趣的可以去上面所说的专栏中查看,大家放心,JetpackCompose中动画的使用方式和ComposeDesktop一致。空气质量
空气质量就是右边天气详情中的第一个模块,样子如下图所示:
这块是一个自定义View,为什么要加引号呢?因为这是Compose啊,不是安卓的View系统。
下面来看下这个自定义View如何实现的吧!ComposableprivatefunAirQualityProgress(aqiValue:Int){Canvas{drawLine(brushBrush。linearGradient(0。0ftoColor(red139,green195,blue74),0。1ftoColor(red255,green239,blue59),0。2ftoColor(red255,green152,blue0),0。3ftoColor(red244,green67,blue54),0。4ftoColor(red156,green39,blue176),1。0ftoColor(red143,green0,blue0),),startOffset。Zero,endOffset(size。width,0f),strokeWidth20f,capStrokeCap。Round,)drawPoints(pointsarrayListOf(Offset(size。width500aqiValue,0f)),pointModePointMode。Points,colorColor。White,strokeWidth20f,capStrokeCap。Round,)}}
因为我没有开发过桌面的应用,所以不太清楚在桌面程序中实现这样的一个控件需要写多少代码,我只开发过安卓,只能拿安卓原生View做对比,在安卓View中如果想实现这样的一个控件的话绝对不止这么一点代码
来简单解释下这个控件吧:在Compose中绘制需要使用可组合项Canvas,然后来绘制下面的那条线,线上的颜色是渐变的,在Compose中只需要使用Brush就可以实现渐变,也可以控制在不同的进度显示不同颜色,空气质量一般分为六个等级:优、良、轻度污染、中度污染、重度污染和严重污染,所以上面对应有六种颜色。最后算出当前的AQI值应该绘制的地方进行绘制即可。7日天气预报
24小时天气预报中没有什么需要说的,一个LazyRow就实现了,就直接跳过了。
接下来来看下7日天气预报,这里其实大部分也不难,但注意看右边的温度条,这是模仿苹果天气中的温度条实现的,下面来看下苹果的样子吧:
再来看下我模仿实现的效果:
不能说一模一样,只能说大差不离。
在模仿苹果这个小彩条的时候刚开始就犯了难,这是啥意思啊这条里面都代表着什么啊,也看不太懂,后来网上找了半天才知道。小彩条的长度代表温差,彩条越长温差越大。根据最近10天的温度,分别设置最高值和最低值。例如上面的苹果截图,近十天的最高温度为4度,则这组彩条最右端代表4度。近十天最低温为12度,那么这组彩条最左端就代表12度。左右两端的极值不是固定不变的。小白点代表了此时的温度。
搞明白这个小彩条的含义就好说了,来自定义下这个控件吧!ComposableprivatefunTemperatureChart(min:Int,max:Int,currentMin:Int,currentMax:Int,currentTemperature:Int100){valcurrentMinColor:ColorgetTemperatureColor(currentMin)valcurrentMaxColor:ColorgetTemperatureColor(currentMax)计算周温差valnummaxminCanvas{绘制底条drawLine(colorColor。Gray,startOffset。Zero,endOffset(size。width,0f),strokeWidth10f,capStrokeCap。Round,)绘制这一天的气温drawLine(brushBrush。linearGradient(0。0ftocurrentMinColor,1。0ftocurrentMaxColor,),startOffset(size。widthnum(currentMinmin),0f),endOffset(size。widthnum(currentMaxmin),0f),strokeWidth10f,capStrokeCap。Round,)如果是当天,则绘制当前温度小白点if(currentTemperature100){drawPoints(pointsarrayListOf(Offset(size。widthnum(currentTemperaturemin),0f)),pointModePointMode。Points,colorColor。White,strokeWidth10f,capStrokeCap。Round,)}}}
首先看下这个可组合项接收的几个参数:min:未来几天最低温度max:未来几天最高温度currentMin:当前绘制天的最低温度currentMax:当前绘制天的最高温度currentTemperature:当前天的当前温度
再简单说下函数内容,先计算下这几天的温差,然后绘制温度底条,再然后绘制温度条,这个温度条是渐变的,需要根据不同温度换不同颜色,最后判断是不是当天,如果是当天的就绘制当前温度的小白点。
上面调用一个函数getTemperatureColor,这是为了计算不同温度的颜色的方法,来看下这个方法吧:获取不同气温的颜色值,需要动态判断privatefungetTemperatureColor(temperature:Int):Color{returnif(temperature20){Color(red26,green92,blue249)}elseif(temperature30){Color(red253,green138,blue11)}else{Color(red248,green60,blue30)}}
这块没有写全这些颜色,其实写了挺多,篇幅原因就不写了,大家能理解就好。太阳月亮
顾名思义,太阳月亮就是指的日出日落和月出月落,还是再来看下实现好的样式吧:
根据日出日落和月出月落的时间来展示当前太阳和月亮的状态。由上面图大概可以看出,需要使用到贝塞尔曲线,由于只是一段曲线,所以使用二阶贝塞尔曲线就可以了。
什么是贝塞尔曲线呢?来看下百度百科的描述吧:
贝塞尔曲线(Bziercurve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段与节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。
下面来简单看下二阶贝塞尔曲线的简单动画吧:
二阶贝塞尔曲线的公式如下:
B(t)(1t)2P02t(1t)P1t2P2,t〔0,1〕
下面来看下在Compose中如何绘制二阶贝塞尔曲线吧:Canvas{valpathPath()path。moveTo(0f,size。height)二阶贝塞尔曲线path。quadraticBezierTo(size。width2,size。height,size。width,size。height)drawPath(pathpath,colorColor(red255,green193,blue7,alpha255),styleStroke(width3f))}
可以看到在Compose使用Path的quadraticBezierTo函数来绘制二阶贝塞尔曲线,这块需要解释下,二阶贝塞尔曲线一共需要三个点,但quadraticBezierTo函数中只接收了两个点,那剩下一个点呢?其实Path先moveTo到的点就是第一个点,quadraticBezierTo函数接收的第一个点是控制点,第二个参数是终点。绘制完后贝塞尔曲线后还要绘制曲线两边的圆点:drawPoints(pointsarrayListOf(Offset(0f,size。height),Offset(size。width,size。height)),pointModePointMode。Points,colorColor(red255,green193,blue7,alpha255),strokeWidth20f,capStrokeCap。Round,)
绘制完贝塞尔曲线和圆点之后就该绘制太阳和月亮图标了,这块需要使用贝塞尔曲线的公式来计算点的坐标了。绘制点之前需要计算当前时间占太阳或月亮在天上的百分比:fungetAccounted(rise:String,set:String,isSun:Booleantrue):Double{valcalendarCalendar。getInstance()valcurrentMillscalendar。timeInMilliscalendar。set(Calendar。HOUROFDAY,getHour(rise))calendar。set(Calendar。MINUTE,getMinute(rise))valriseMillscalendar。timeInMillisif(!isSun){calendar。set(Calendar。DAYOFMONTH,calendar。get(Calendar。DAYOFMONTH)1)}calendar。set(Calendar。HOUROFDAY,getHour(set))calendar。set(Calendar。MINUTE,getMinute(set))valsetMillscalendar。timeInMillisvalresult(currentMillsriseMills)(setMillsriseMills)。toDouble()returnif(currentMillsriseMills)0。0elseif(result1)1。0elseresult}
这块的代码不多,使用Calendar来获取当前毫秒值存下来,然后设置日出日落的小时分钟并记录下来毫秒值,最后进行计算即可。
现在百分比也有了,只剩下计算贝塞尔曲线上的坐标点了,先来看下计算坐标点的公式吧:P0(起始点),P1(控制点),P2(终点)P0(x1,y1),P2(x2,y2),P1(cx,cy)valxMath。pow(1t,2)x12t(1t)cxMath。pow(t,2)x2valyMath。pow(1t,2)y12t(1t)cyMath。pow(t,2)y2
公式是固定的,只需要往里套点即可:valx(1。0sunResult)。pow(2。0)0f2sunResult(1sunResult)(size。width2)sunResult。pow(2。0)size。widthvaly(1。0sunResult)。pow(2。0)size。height2sunResult(1sunResult)(size。height)sunResult。pow(2。0)size。height
计算出来贝塞尔曲线中的点后就该绘制月亮或太阳的图标了:drawImage(imagesunImage,topLeftOffset(xsunImage。width2,xsunImage。height2))
这块的图片需要ImageBitmap格式,直接使用上一篇文章中的useResource即可生成。drawImage中的topLeft参数表示左上角的坐标,默认的话时(0,0),但图片有宽高,所以需要减去宽高的一半,这样太阳和月亮的图标才能显示在正中间。跳转浏览器
在安卓中咱们可以使用WebView来展示网页,但是在桌面版的应用中就没有了,需要使用系统自带的浏览器,那使用ComposeDesktop应该如何打开系统自带的浏览器呢?可以使用Desktop中的browse方法,下面是我写的一个扩展函数:通过字符串打开系统默认浏览器funString?。openBrowse(){if(this?。startsWith(http)false!this。startsWith(https)){throwIllegalArgumentException(thisillegalargumentexception)}try{valuriURI。create(this?:https:www。baidu。com)获取当前系统桌面valdpDesktop。getDesktop()判断系统桌面是否支持要执行的功能if(dp。isSupported(Desktop。Action。BROWSE)){获取系统默认浏览器打开链接dp。browse(uri)}}catch(e:Exception){println(e。message)}}
首先判断当前字符串前缀是否为http和https,如果不是的话就证明这个字符串不是网络链接,就直接抛出异常,剩下代码中的注释写的已经比较全了,就不多说了。
函数有了再来看下如何调用吧:Row{Image(painterpainterResource(imageiclauncher。svg),,modifierModifier。size(15。dp))Spacer(modifierModifier。width(5。dp))Text(text数据来自和风天气,fontSize12。sp,modifierModifier。clickable{fxLink。openBrowse()})}
很简单,直接调用即可。运行效果就不在这里进行展示了,大家可以下载代码运行看看。对话框
在安卓中对话框的使用场景实在是太多了,就不一一列举了,随便打开一个应用里面都有一堆对话框,那么在ComposeDesktop中该如何弹出对话框呢?先来看下Dialog的函数定义吧:ComposablefunDialog(onCloseRequest:()Unit,state:DialogStaterememberDialogState(),visible:Booleantrue,title:StringUntitled,icon:Painter?null,undecorated:Booleanfalse,transparent:Booleanfalse,resizable:Booleantrue,enabled:Booleantrue,focusable:Booleantrue,onPreviewKeyEvent:((KeyEvent)Boolean){false},onKeyEvent:((KeyEvent)Boolean){false},content:ComposableDialogWindowScope。()Unit)
看到这些参数眼熟么?和上一篇文章中提到的Window基本一致,不同的就是这块的state为DialogState,接下来看下DialogState吧:interfaceDialogState{varposition:WindowPositionvarsize:DpSize}
可以看到通过定义DialogState可以定义对话框的位置和大小,大小可以直接通过DpSize设置,位置的话通过WindowPosition来设置,但WindowPosition可以通过绝对位置和相对位置来设置位置:绝对位置,绝对坐标funWindowPosition(x:Dp,y:Dp)WindowPosition。Absolute(x,y)相对位置funWindowPosition(alignment:Alignment)WindowPosition。Aligned(alignment)
可以看到对话框也可以设置标题和图标,剩下的参数都见过,就不过多介绍了。
来看看在ComposeDesktop中如何使用对话框吧:valalertDialogrememberSaveable{mutableStateOf(false)}Dialog(onCloseRequest{alertDialog。valuefalse},visiblealertDialog。value,staterememberDialogState(sizeDpSize(300。dp,200。dp)),titleWeather,iconbuildPainter(imageiclauncher。svg)){Column(horizontalAlignmentAlignment。CenterHorizontally,modifierModifier。padding(top20。dp)){Text(texttitle,fontSize16。sp,maxLines1,fontWeightFontWeight。Bold,colorMaterialTheme。colors。onSecondary,modifierModifier。padding(horizontal20。dp))}}
代码中设置了下对话框的大小,对话框使用方式和JetpackCompose基本一致,看下运行效果吧:
可以看到对话框使用很简单,有需要的可以在Dialog中添加一些别的可组合项进行使用。桌面的PopopWindow
在安卓中咱们经常使用的PopopWindow如何在ComposeDesktop中使用呢?
Compose中可以直接使用Popup来构建类似于安卓中PopupWindow的弹框,但我试着直接使用了下Popup,不太好控制弹出的地方,所以我就想着有没有能更简单控制弹出位置的方法,仔细找了下,果然有!可以使用CursorDropdownMenu,它可以将Popup在鼠标点击的地方弹出。ComposablefunCursorDropdownMenu(expanded:Boolean,onDismissRequest:()Unit,focusable:Booleantrue,modifier:ModifierModifier,content:ComposableColumnScope。()Unit){。。。。。。Popup(focusablefocusable,onDismissRequestonDismissRequest,popupPositionProviderrememberCursorPositionProvider(),onKeyEvent{handlePopupOnKeyEvent(it,onDismissRequest,focusManager!!,inputModeManager!!)},)。。。。。。}
上面就是CursorDropdownMenu进行了一些删减的源码,可以看到里面也调用了Popup。
接下来看下使用方式吧:varshowPopupWindowbyremember{mutableStateOf(false)}CursorDropdownMenu(showPopupWindow,onDismissRequest{showPopupWindowfalse},modifiermodifier。width(300。dp)。padding(horizontal15。dp)。padding(bottom10。dp)){Row(modifierModifier。fillMaxWidth(),horizontalArrangementArrangement。SpaceBetween,verticalAlignmentAlignment。CenterVertically,){Text(textdata。titleDetails,fontSize15。sp,fontWeightFontWeight。Bold,colorMaterialTheme。colors。onSecondary)IconButton(onClick{showPopupWindowfalse}){Icon(Icons。Sharp。Close,Close)}}}
其实使用方法和对话框是类似的,都是通过定义一个是否展开的变量,然后通过这个变量来确定当前弹框是否显示。
下面来看下运行效果:
可以看到还是挺好看的,哈哈哈!系统菜单
在Mac中右上角会显示应用的菜单,如下图所示:
别的应用有,我们当然也想要!那咱们的ComposeDesktop应该如何展示呢?
放心,Jetbrains都为我们想到了!来看看如何使用吧!Window(onCloseRequest::exitApplication,title天青色等烟雨){MenuBar{Menu(文件,mnemonicF){Item(复制(假的),onClick{actionLastaction:Copy},shortcutKeyShortcut(Key。C,ctrltrue))Item(粘贴(假的),onClick{actionLastaction:Paste},shortcutKeyShortcut(Key。V,ctrltrue))}Menu(帮助,mnemonicH){Item(天气帮助,onClick{actionLastaction:Help})}}App()}
直接使用MenuBar就可以展示类似于上方图片中的菜单了,需要注意的是MenuBar需要FrameWindowScope,上一篇文章中所说Window的content就是FrameWindowScope,所以可以进行使用,要直接拿出来就不行了,如果想拿出来的话需要添加一个扩展函数:privatefunFrameWindowScope。DemoMenu(){MenuBar{Menu(文件,mnemonicF){Item(复制(假的),onClick{actionLastaction:Copy},shortcutKeyShortcut(Key。C,ctrltrue))Item(粘贴(假的),onClick{actionLastaction:Paste},shortcutKeyShortcut(Key。V,ctrltrue))}Menu(帮助,mnemonicH){Item(天气帮助,onClick{actionLastaction:Help})}}}
简单说下吧,先来看下Menu吧:ComposablefunMenu(text:String,mnemonic:Char?null,enabled:Booleantrue,content:ComposableMenuScope。()Unit)
函数参数并不多,只有mnemonic不太好理解,它对应于键盘上某个键的字符,当这个键和Alt被按下时菜单将打开。然后需要重点看下content,它的参数类型为MenuScope,那就来看下MenuScope中都能添加什么可组合项吧!classMenuScopeinternalconstructor(privatevalimpl:MenuScopeImpl){ComposablefunMenu()ComposablefunSeparator()impl。Separator()ComposablefunItem()ComposablefunCheckboxItem()ComposablefunRadioButtonItem()}
可以看到,还能再添加Menu,剩下可添加的还有Item、Separator、CheckboxItem和RadioButtonItem,故名思义,分别是条目、分隔符、复选框和单选框。
废话不多说,运行看下效果吧!
大家在使用的时候可以根据需求选择需要使用的可组合项来组合系统菜单。托盘及通知
托盘是什么呢?在Mac中右上角展示的就是托盘,如下图所示;Windows中在右下角。
托盘
同样的,Jetbrains也为我们想到了,使用方法也不难,直接来看下吧:Tray(staterememberTrayState(),iconpainterResource(imagelauncher。png),menu{Item(天气预报,onClick{})Separator()Item(退出,onClick{})})
在ComposeDesktop中使用Tray来为应用添加系统托盘,这里的Menu其实和上面系统菜单中的Menu是一回事,所以上面所描述的Item、Separator、CheckboxItem和RadioButtonItem都可以进行使用。
下面来运行看下实际效果吧:
这块还有一个小知识点,咱们有时候使用的一些工具其实都没有真正页面,只是在系统托盘中存在,Tray也可以在没有窗口的情况下创建托盘应用程序:funmain()application{Tray(iconpainterResource(imagelauncher。png),menu{Item(退出,onClick::exitApplication)})}
这样就可以创建出一个没有窗口的程序了。通知
咱们还可以使用系统托盘,也就是Tray向用户发送通知。一共有3种类型的通知:notify简单的通知warn警告通知Error错误通知
下面来看下使用方法:valtrayStaterememberTrayState()valinfoNotificationrememberNotification(天气预报,明天的天气很好,建议出门遛弯,Notification。Type。Info)Tray(statetrayState,iconpainterResource(imagelauncher。png),menu{Item(天气预报,onClick{trayState。sendNotification(infoNotification)})Separator()Item(退出,onClick{isOpen。valuefalse})})
使用起来很简单,先使用rememberNotification来构建出一个Notification,然后直接使用trayState中的sendNotification进行发送通知即可。
我录制了一个完整的显示系统菜单、托盘以及通知的GIF,大家来看下效果吧。
小结
本文大概描述了下我在编写这个天气应用时遇到的一些问题及难点,还有自定义绘制的一些避坑点到此就告一段落了。此项目所有代码都放到了Github中。
Github地址:https:github。comzhujiang521PlayWeathertreedesktop
如果文中写的有误,欢迎在评论区提出,咱们一起探讨。
文章如果能帮助到大家,哪怕是一点,我也非常高兴,先这样。