1. 项目背景


最近参加了Compose挑战赛的终极挑战,使用Compose完成了一个天气app。之前几轮挑战也都有参与,每次都学到不少新东西。如今迎来最终挑战,希望能将这段时间的积累活学活用,做出更加成熟的作品。
Android 开发挑战赛 | 终极挑战: 天气应用

项目中的挑战

因为没有美工协助,所以我考虑通过代码实现app中的所有UI元素例如各种icon等,这样的UI在任何分辨率下都不会失真,而且可以灵活地实现各种动画效果。

为了降低实现成本,我将app中的UI元素定义成偏卡通的风格,可以更容易地通过代码实现:

上面这些动画没有使用giflottie等三方资源,所有图形都基于Compose代码绘制。

2. MyApp - CuteWeather


2.1 App效果

App界面比较简洁,采用单页面呈现(比赛要求基于SingleScreen实现),各种卡通的天气动画是app的最大特色:

项目地址:https://github.com/vitaviva/compose-weather

2.2 App界面

App纵向划分为几个功能区域,每个区域都涉及到一些不同的Compose API的使用

涉及技术点较多,本文主要介绍如何使用Compose绘制自定义图形,并基于这些图形实现动画,其他内容有机会再单独介绍。

3. Compose自定义绘制


像常规的Android开发一样,除了各种默认的Composable控件以外,Compose也提供了Canvas用来绘制自定义UI。

Canvas相关API在各个平台都大同小异,但在Compose上的使用有以下特点:

  • 用声明式的方式创建和使用Canvas
  • 通过DrawScope提供必要的state及各种APIs
  • APIs更简单易用

3.1 声明式地创建和使用Canvas

Compose中,Canvas作为Composable可以声明式地添加到其他Composable中,并通过Modifier进行配置

Canvas(modifier = Modifier.fillMaxSize()){ // this: DrawScope //内部进行自定义绘制
}

传统方式需要获取Canvas句柄命令式的进行绘制,而Canvas{...}通过状态驱动的方式执行block内的绘制逻辑、刷新UI。

3.2 强大的DrawScope

Canvas{...}内部通过DrawScope提供必要的state用来获取当前绘制所需环境变量,例如我们最常用的size。DrawScope还提了各种常用的绘制API,例如drawLine

Canvas(modifier = Modifier.fillMaxSize()){//通过size获取当前canvas的width和heightval canvasWidth = size.widthval canvasHeight = size.height//绘制直线drawLine(start = Offset(x=canvasWidth, y = 0f),end = Offset(x = 0f, y = canvasHeight),color = Color.Blue,strokeWidth = 5F //设置直线宽度)
}

上面代码绘制效果如下:

3.3 简单易用的API

传统的Canvas API需要进行Paint等配置,而DrawScope提供的API则更简单、使用更友好。

例如绘制一个圆,传统的API是这样:

public void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) {//...
}

DrawScope提供的API:

fun drawCircle(color: Color,radius: Float = size.minDimension / 2.0f,center: Offset = this.center,alpha: Float = 1.0f,style: DrawStyle = Fill,colorFilter: ColorFilter? = null,blendMode: BlendMode = DefaultBlendMode
) {...}

看起来参数变多了,其实已经通过size等设置了合适的默认值,同时省去了对Paint的创建和配置,使用起来更方便。

使用原生Canvas

目前DrawScope提供的API还不及原生Canvas丰富(比如不支持drawText等),当不满足使用需求时,也可以直接使用原生Canvas对象进行绘制

   drawIntoCanvas { canvas ->//nativeCanvas是原生canvas对象,android平台即android.graphics.Canvasval nativeCanvas  = canvas.nativeCanvas}

上面介绍了Compose Canvas的基本知识,下面结合app中的具体示例看一下实际使用效果

首先,看一下雨水的绘制过程。

4. 雨天效果


雨天天气的关键是如何绘制不断下落的雨水

4.1 雨滴的绘制

我们先绘制构成雨水的基本单元:雨滴

经拆解后,雨水效果可由三组雨滴构成,每一组雨滴分成上下两段,这样在运动时就可以形成接连不断的雨水效果。

我们使用drawLine绘制每一条线段,设置适当的stokeWidth,并通过cap设置端点的圆形效果:

@Composable
fun rainDrop() {Canvas(modifier) {val x: Float = size.width / 2 //x坐标: 1/2的位置drawLine(Color.Black,Offset(x, line1y1), //line1 的起点Offset(x, line1y2), //line1 的终点strokeWidth = width, //设置宽度cap = StrokeCap.Round//头部圆形)// line2同上drawLine(Color.Black,Offset(x, line2y1),Offset(x, line2y2),strokeWidth = width,cap = StrokeCap.Round)}
}

4.2 雨滴下落动画

完成雨滴的图形绘制之后,接下来为两线段添加循环往复的位移动画,形成流动效果。

以两线段中间空隙为动画的锚点,根据animationState设置其y轴位置,让其从canvas的顶端移动到低端(0 ~ size.hight),然后restart这个动画。

以锚点为基准绘制上下两线段,就可以行成接连不断的雨滴效果了

代码如下:

@Composable
fun rainDrop() {//循环播放的动画 ( 0f ~ 1f)val animateTween by rememberInfiniteTransition().animateFloat(initialValue = 0f,targetValue = 1f,animationSpec = infiniteRepeatable(tween(durationMillis, easing = LinearEasing),RepeatMode.Restart //start动画))Canvas(modifier) {// scope : 绘制区域val width = size.widthval x: Float = size.width / 2// width/2是strokCap的宽度,scopeHeight处预留strokCap宽度,让雨滴移出时保持正圆,提高视觉效果val scopeHeight = size.height - width / 2 // space : 两线段的间隙val space = size.height / 2.2f + width / 2 //间隙sizeval spacePos = scopeHeight * animateTween //锚点位置随animationState变化val sy1 = spacePos - space / 2val sy2 = spacePos + space / 2// line lengthval lineHeight = scopeHeight - space// line1val line1y1 = max(0f, sy1 - lineHeight)val line1y2 = max(line1y1, sy1)// line2val line2y1 = min(sy2, scopeHeight)val line2y2 = min(line2y1 + lineHeight, scopeHeight)// drawdrawLine(Color.Black,Offset(x, line1y1),Offset(x, line1y2),strokeWidth = width,colorFilter = ColorFilter.tint(Color.Black),cap = StrokeCap.Round)drawLine(Color.Black,Offset(x, line2y1),Offset(x, line2y2),strokeWidth = width,colorFilter = ColorFilter.tint(Color.Black),cap = StrokeCap.Round)}
}

4.3 Compose自定义布局

上面完成了单个雨滴的动画,接下来我们使用三个雨滴组成雨水的效果。

首先可以使用Row+Space的方式进行组装,但是这种方式缺少灵活性,仅通过Modifier很难准确布局三雨滴的相对位置,因此考虑转而使用Compose的自定义布局,以提高灵活性和准确性:

Layout(modifier = modifier.rotate(30f), //雨滴旋转角度content = { // 定义子ComposableRaindrop(modifier.fillMaxSize())Raindrop(modifier.fillMaxSize())Raindrop(modifier.fillMaxSize())}
) { measurables, constraints ->// List of measured childrenval placeables = measurables.mapIndexed { index, measurable ->// Measure each childrenval height = when (index) { //让三个雨滴的height不同,增加错落感0 -> constraints.maxHeight * 0.8f1 -> constraints.maxHeight * 0.9f2 -> constraints.maxHeight * 0.6felse -> 0f}measurable.measure(constraints.copy(minWidth = 0,minHeight = 0,maxWidth = constraints.maxWidth / 10, // raindrop widthmaxHeight = height.toInt(),))}// Set the size of the layout as big as it canlayout(constraints.maxWidth, constraints.maxHeight) {var xPosition = constraints.maxWidth / ((placeables.size + 1) * 2)// Place children in the parent layoutplaceables.forEachIndexed { index, placeable ->// Position item on the screenplaceable.place(x = xPosition, y = 0)// Record the y co-ord placed up toxPosition += (constraints.maxWidth / ((placeables.size + 1) * 0.8f)).roundToInt()}}
}

Compose中,可以通过Layout{...}对Composable进行自定义布局,content{...}中定义参与布局的子Composable。

跟传统Android视图一样,自定义布局需要先后经历measurelayout两步。

  • measruemeasurables返回所有待测量的子Composable,constraints类似于MeasureSpec,封装父容器对子元素的布局约束。measurable.measure()中对子元素进行测量
  • layoutplaceables返回测量后的子元素,依次调用placeable.place()对雨滴进行布局,通过xPosition预留雨滴在x轴的间隔

经过layout之后,通过 modifier.rotate(30f) 对Composable进行旋转,完成最终效果:

5. 雪天效果


雪天效果的关键在于雪花的飘落。

5.1 雪花的绘制

雪花的绘制非常简单,用一个圆圈代表一个雪花

Canvas(modifier) {val radius = size / 2drawCircle( //白色填充color = Color.White,radius = radius,style = FILL)drawCircle(// 黑色边框color = Color.Black,radius = radius,style = Stroke(width = radius * 0.5f))
}

5.2 雪花飘落动画

雪花飘落的过程相对于雨滴坠落要复杂一些,由三个动画组成:

  1. 下落:改变y轴位置:0f ~ 2.5f
  2. 左右飘移:改变x轴的offse:-1f ~ 1f
  3. 逐渐消失:改变alpha:1f ~ 0f

借助InfiniteTransition同步控制多个动画,代码如下:

@Composable
private fun Snowdrop(modifier: Modifier = Modifier,durationMillis: Int = 1000 // 雪花飘落动画的druation
) {//循环播放的Transitionval transition = rememberInfiniteTransition()//1. 下降动画:restart动画val animateY by transition.animateFloat(initialValue = 0f,targetValue = 2.5f,animationSpec = infiniteRepeatable(tween(durationMillis, easing = LinearEasing),RepeatMode.Restart))//2. 左右飘移:reverse动画val animateX by transition.animateFloat(initialValue = -1f,targetValue = 1f,animationSpec = infiniteRepeatable(tween(durationMillis / 3, easing = LinearEasing),RepeatMode.Reverse))//3. alpha值:restart动画,以0f结束val animateAlpha by transition.animateFloat(initialValue = 1f,targetValue = 0f,animationSpec = infiniteRepeatable(tween(durationMillis, easing = FastOutSlowInEasing),))Canvas(modifier) {val radius = size.width / 2// 圆心位置随AnimationState改变,实现雪花飘落的效果val _center = center.copy(x = center.x + center.x * animateX,y = center.y + center.y * animateY)drawCircle(color = Color.White.copy(alpha = animateAlpha),//alpha值的变化实现雪花消失效果center = _center,radius = radius,)drawCircle(color = Color.Black.copy(alpha = animateAlpha),center = _center,radius = radius,style = Stroke(width = radius * 0.5f))}
}

animateYtargetValue设为2.5f,让雪花的运动轨迹更长,看起来更加真实

5.3 雪花的布局

像雨滴一样,对雪花也使用Layout自定义布局

@Composable
fun Snow(modifier: Modifier = Modifier,animate: Boolean = false,
) {Layout(modifier = modifier,content = {//摆放三个雪花,分别设置不同duration,增加随机性Snowdrop( modifier.fillMaxSize(), 2200)Snowdrop( modifier.fillMaxSize(), 1600)Snowdrop( modifier.fillMaxSize(), 1800)}) { measurables, constraints ->val placeables = measurables.mapIndexed { index, measurable ->val height = when (index) {// 雪花的height不同,也是为了增加随机性0 -> constraints.maxHeight * 0.6f1 -> constraints.maxHeight * 1.0f2 -> constraints.maxHeight * 0.7felse -> 0f}measurable.measure(constraints.copy(minWidth = 0,minHeight = 0,maxWidth = constraints.maxWidth / 5, // snowdrop widthmaxHeight = height.roundToInt(),))}layout(constraints.maxWidth, constraints.maxHeight) {var xPosition = constraints.maxWidth / ((placeables.size + 1))placeables.forEachIndexed { index, placeable ->placeable.place(x = xPosition, y = -(constraints.maxHeight * 0.2).roundToInt())xPosition += (constraints.maxWidth / ((placeables.size + 1) * 0.9f)).roundToInt()}}}
}

最终效果如下:

6. 晴天效果


通过一个旋转的太阳代表晴天效果

6.1 太阳的绘制

太阳的图形由中间的圆形和围绕圆环的等分线段组成。

@Composable
fun Sun(modifier: Modifier = Modifier) {Canvas(modifier) {val radius = size.width / 6val stroke = size.width / 20// draw circledrawCircle(color = Color.Black,radius = radius + stroke / 2,style = Stroke(width = stroke),)drawCircle(color = Color.White,radius = radius,style = Fill,)// draw lineval lineLength = radius * 0.2fval lineOffset = radius * 1.8f(0..7).forEach { i ->val radians = Math.toRadians(i * 45.0)val offsetX = lineOffset * cos(radians).toFloat()val offsetY = lineOffset * sin(radians).toFloat()val x1 = size.width / 2 + offsetXval x2 = x1 + lineLength * cos(radians).toFloat()val y1 = size.height / 2 + offsetYval y2 = y1 + lineLength * sin(radians).toFloat()drawLine(color = Color.Black,start = Offset(x1, y1),end = Offset(x2, y2),strokeWidth = stroke,cap = StrokeCap.Round)}}
}

均分360度,每间隔45度画一条线段,cos计算x轴坐标,sin计算y轴坐标。

6.2 太阳的转动

太阳转动的动画很简单,通过Modifier.rotate不断转动Canvas即可。

@Composable
fun Sun(modifier: Modifier = Modifier) {//循环动画val animateTween by rememberInfiniteTransition().animateFloat(initialValue = 0f,targetValue = 360f,animationSpec = infiniteRepeatable(tween(5000), RepeatMode.Restart))Canvas(modifier.rotate(animateTween)) {// 旋转动画val radius = size.width / 6val stroke = size.width / 20val centerOffset = Offset(size.width / 30, size.width / 30) //圆心偏移量// draw circledrawCircle(color = Color.Black,radius = radius + stroke / 2,style = Stroke(width = stroke),center = center + centerOffset //圆心偏移)//...略}
}

此外,DrawScope也提供了rotate的API,也可以实现旋转效果。

最后我们给太阳的圆心增加一个偏移量,让转动更加活泼:

7. 动画的组合、切换


分别实现了RainSnowSun等图形之后就可以使用这些图形组合成各种天气效果了。

7.1 将图形组合成天气

Compose的声明式语法非常有利于UI的组合:

比如,多云转阵雨,我们摆放SunCloudRain等元素后,通过Modifier调整各自位置即可:

@Composable
fun CloudyRain(modifier: Modifier) {Box(modifier.size(200.dp)){Sun(Modifier.size(120.dp).offset(140.dp, 40.dp))Rain(Modifier.size(80.dp).offset(80.dp, 60.dp))Cloud(Modifier.align(Aligment.Center))}
}

7.2 让动画切换更加自然


当在多个天气动画之间进行切换时,我们希望能实现更自然的过渡。实现思路是将组成天气动画的各元素的Modifier信息变量化,然后通过Animation进行改变

假设所有的天气都可以由CloudSunRain组合而成,无非就是offsetsizealpha值的不同:

ComposeInfo

data class IconInfo(val size: Float = 1f, val offset: Offset = Offset(0f, 0f),val alpha: Float = 1f,
)
//天气组合信息,即Sun、Cloud、Rain的位置信息
data class ComposeInfo(val sun: IconInfo,val cloud: IconInfo,val rains: IconInfo,) {operator fun times(float: Float): ComposeInfo =copy(sun = sun * float,cloud = cloud * float,rains = rains * float)operator fun minus(composeInfo: ComposeInfo): ComposeInfo =copy(sun = sun - composeInfo.sun,cloud = cloud - composeInfo.cloud,rains = rains - composeInfo.rains,)operator fun plus(composeInfo: ComposeInfo): ComposeInfo =copy(sun = sun + composeInfo.sun,cloud = cloud + composeInfo.cloud,rains = rains + composeInfo.rains,)
}

如上,ComposeInfo中持有各种元素的位置信息,运算符重载使其可以在Animation中计算当前最新值。

接下来,定义各不同天气的ComposeInfo

//晴天
val SunnyComposeInfo = ComposeInfo(sun = IconInfo(1f),cloud = IconInfo(0.8f, Offset(-0.1f, 0.1f), 0f),rains = IconInfo(0.4f, Offset(0.225f, 0.3f), 0f),
)//多云
val CloudyComposeInfo = ComposeInfo(sun = IconInfo(0.1f, Offset(0.75f, 0.2f), alpha = 0f),cloud = IconInfo(0.8f, Offset(0.1f, 0.1f)),rains = IconInfo(0.4f, Offset(0.225f, 0.3f), alpha = 0f),
)//雨天
val RainComposeInfo = ComposeInfo(sun = IconInfo(0.1f, Offset(0.75f, 0.2f), alpha = 0f),cloud = IconInfo(0.8f, Offset(0.1f, 0.1f)),rains = IconInfo(0.4f, Offset(0.225f, 0.3f), alpha = 1f),
)

ComposedIcon

接着,定义ComposedIcon,根据ComposeInfo实现不同的天气UI

@Composable
fun ComposedIcon(modifier: Modifier = Modifier, composeInfo: ComposeInfo) {//各元素的ComposeInfoval (sun, cloud, rains) = composeInfoBox(modifier) {//应用ComposeInfo到Modifierval _modifier = remember(Unit) {{ icon: IconInfo ->Modifier.offset( icon.size * icon.offset.x, icon.size * icon.offset.y ).size(icon.size).alpha(icon.alpha)}}Sun(_modifier(sun))Rains(_modifier(rains))AnimatableCloud(_modifier(cloud))}
}

ComposedWeather

最后,定义ComposedWeather记录当前ComposedIcon,并在其发生变更时使用动画进行过度:

@Composable
fun ComposedWeather(modifier: Modifier, composedIcon: ComposedIcon) {val (cur, setCur) = remember { mutableStateOf(composedIcon) }var trigger by remember { mutableStateOf(0f) }DisposableEffect(composedIcon) {trigger = 1fonDispose { }}//创建动画(0f ~ 1f),用于更新ComposeInfoval animateFloat by animateFloatAsState(targetValue = trigger,animationSpec = tween(1000)) {//当动画结束时,更新ComposeWeather到最新statesetCur(composedIcon)trigger = 0f}//根据AnimationState计算当前ComposeInfoval composeInfo = remember(animateFloat) {cur.composedIcon + (weatherIcon.composedIcon - cur.composedIcon) * animateFloat}//使用最新的ComposeInfo显示IconComposedIcon(modifier,composeInfo)
}

8 总结


本文以天气app为例介绍了Compose的自定义绘制、布局以及动画等的基本使用方法。
Compose通过声明式的方式,结合Canvas实现定义图形和动画,这相比与传统命令式的代码来的更加简单。更低的实现成本让代码替代Gif实现简单的动画成为可能,经代码绘制的效果在清晰度以及帧率上都要远超越Gif,欢迎大家下载源码体验~

在我看来Compose核心不仅是UI,后面有机会会继续分享一些关于架构以及底层实现的文章,欢迎大家持续关注~

参考

MyApp#CuteWeather
Compose#Canvas
Compose#CustomLayout

告别GIF,使用Jetpack Compose打造可爱的天气动画相关推荐

  1. android雪花飘落动画,用Jetpack Compose制作出可爱的天气动画

    1. 背景介绍 最近参加了Compose挑战赛的终极挑战,使用Compose完成了一个天气app.之前几轮挑战我也都有参与,每次都学到不少新东西,希望在这最后一轮挑战中,活用这段时间的积累做出更加成熟 ...

  2. 用Jetpack Compose做一个俄罗斯方块游戏机

    本文介绍如何使用Jetpack Compose打造一个经典版的俄罗斯方块游戏. 玩过上面这种游戏机的朋友应该会对本文内容感到亲切,废话不多说,先看东西: 1. 为什么Compose适合做游戏? 通常一 ...

  3. 告别XML,Android新声明式UI框架《Jetpack Compose入门到精通》最全开发指南

    什么是Jetpack Compose? Jetpack Compose是Android的新声明式UI框架.长期以来, Android 开发人员习惯于使用带有状态视图的xml编写UI,这些状态视图通过逐 ...

  4. 随输入动态改变ui_深入详解 Jetpack Compose | 优化 UI 构建

    人们对于 UI 开发的预期已经不同往昔.现如今,为了满足用户的需求,我们构建的应用必须包含完善的用户界面,其中必然包括动画 (animation) 和动效 (motion),这些诉求在 UI 工具包创 ...

  5. 在 Jetpack Compose 中安全地使用数据流

    /   今日科技快讯   / 11月17日下午,暴雪中国官方微博发布公告称,各位暴雪游戏的国服玩家,我们很遗憾地通知大家,随着我们与网之易公司现有授权协议的到期,自2023年1月24日0点起,所有&l ...

  6. 安卓开发: Jetpack compose + kotlin 实现 俄罗斯方块游戏

    文章目录 前言 俄罗斯方块开发文档 1.摘要 2.开发工具选取 2.1.Compose 的自身优点 2.2.数据驱动界面 3.设计需求 3.1.功能需求 3.1.1.基本游戏功能 3.1.2.拓展功能 ...

  7. 深入理解 Jetpack Compose 内核:SlotTable 系统

    引言 Compose 的绘制有三个阶段,组合 > 布局 > 绘制.后两个过程与传统视图的渲染过程相近,唯独组合是 Compose 所特有的.Compose 通过组合生成渲染树,这是 Com ...

  8. 学不动了,尝试用Android Jetpack Compose重写微信经典飞机大战游戏

    前段时间看了TechMerger大佬写的<一气呵成:用Compose完美复刻Flappy Bird!>,甚是有趣,按耐不住那躁动的心,笔者决定跟随大佬的脚步通过写游戏的方式学习Jetpac ...

  9. Android 开发新技术:Jetpack Compose当仁不让

    前言 Jetpack Compose是用于构建原生Android 界面的新款工具包. 平时我们开发Android界面都是靠XML画出来,但是Compose 则是用代码来写界面,和Flutter写法有点 ...

最新文章

  1. Python 字典(Dictionary) get()方法
  2. 电影天堂React Native 客户端
  3. 2015年美军将具备60分钟内打击全球目标能力(图)
  4. C语言中数组所占字节怎么算
  5. java工作台无法显示_【Eclipse】使用指南(18)搜索工作台
  6. 如何理解lvs中DR模型的arp请求-arp_announce和arp_ignore
  7. 【云小课】基础服务第25课 容灾演练:平时多练兵,急时保可用!
  8. 它又又又来了,Fastjson 最新高危漏洞来袭!快升级吧
  9. 放出几个E-book,经典啊,Ruby的
  10. 归并排序时间复杂度分析
  11. 性能测试基础知识-测试指标(转载自阿里云)
  12. (更新)视频设备通过rtsp接入amazon alexa echo show
  13. Taylor公式的证明
  14. 微分与导数之一,切线
  15. SQL中rand和order by rand()用法
  16. Linux下使用aMsn详解(转)
  17. 5月28-29日规模化敏捷联合作战沙盘之乌托邦计划—成都站
  18. 支付宝沙箱开启以及配置
  19. 如何在arduino上使用315 mhz的发射模块和接收模块P1
  20. 软考初级程序员上午单选题(11)

热门文章

  1. iOS项目技术还债之路(IAP掉单优化)
  2. 蓝鲸平台MySQL数据库管理规范建议
  3. equals()方法的使用
  4. COO 叶谦解读:《2017年全域互联网发展报告》
  5. 如何使用ffmpeg进行音频文件合并
  6. 618什么数码好物值得买、2022值得买的数码好物指南
  7. [附源码]Java计算机毕业设计SSM党员学习管理系统
  8. 配置zbar识别二维码(转载)
  9. python 写文件
  10. 谷歌去掉input黄色背景,并设置透明背景