利用Jetpack Compose绘制可爱的天气动画

作者:ax2djmti 时间:2022-04-06 18:50:35 

1. 项目背景

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

项目挑战

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

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

利用Jetpack Compose绘制可爱的天气动画

上面的动画没有使用gif、lottie或者其他静态资源,所有图形都是基于Compose代码绘制的。

2. MyApp:CuteWeather

App界面比较简洁,采用单页面呈现(挑战赛要求),卡通风格的天气动画算是相对于同类app的特色:

利用Jetpack Compose绘制可爱的天气动画

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

App界面构成

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

利用Jetpack Compose绘制可爱的天气动画

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

3. Compose自定义绘制

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

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

  • 用声明式的方式创建和使用Canvas

  • 通过DrawScope提供必要的state及各种APIs

  • API更简单易用

声明式地创建和使用Canvas

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

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

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

强大的DrawScope

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

Canvas(modifier = Modifier.fillMaxSize()){
//通过size获取当前canvas的width和height
   val canvasWidth = size.width
   val canvasHeight = size.height

//绘制直线
   drawLine(
       start = Offset(x=canvasWidth, y = 0f),
       end = Offset(x = 0f, y = canvasHeight),
       color = Color.Blue,
       strokeWidth = 5F //设置直线宽度
   )
}

上面代码绘制效果如下:

利用Jetpack Compose绘制可爱的天气动画

4.简单易用的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.Canvas
           val nativeCanvas  = canvas.nativeCanvas

}

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

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

5. 雨天效果

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

利用Jetpack Compose绘制可爱的天气动画

雨滴的绘制

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

利用Jetpack Compose绘制可爱的天气动画

经拆解后,雨水效果可由三组雨滴构成,每一组雨滴分成上下两端,这样在运动时就可以形成接连不断的雨水效果。我们使用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
       )
   }
}

雨滴下落动画

完成基本图形的绘制后,接下来为两线段实现循环往复的位移动画,形成雨水的流动效果。

利用Jetpack Compose绘制可爱的天气动画

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

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

利用Jetpack Compose绘制可爱的天气动画

代码如下:

@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.width
       val 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 //间隙size
       val spacePos = scopeHeight * animateTween //锚点位置随animationState变化
       val sy1 = spacePos - space / 2
       val sy2 = spacePos + space / 2

// line length
       val lineHeight = scopeHeight - space

// line1
       val line1y1 = max(0f, sy1 - lineHeight)
       val line1y2 = max(line1y1, sy1)

// line2
       val line2y1 = min(sy2, scopeHeight)
       val line2y2 = min(line2y1 + lineHeight, scopeHeight)

// draw
       drawLine(
           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
       )
   }
}

6.Compose自定义布局

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

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

Layout(
   modifier = modifier.rotate(30f), //雨滴旋转角度
   content = { // 定义子Composable
 Raindrop(modifier.fillMaxSize())
 Raindrop(modifier.fillMaxSize())
 Raindrop(modifier.fillMaxSize())
   }
) { measurables, constraints ->
   // List of measured children
   val placeables = measurables.mapIndexed { index, measurable ->
       // Measure each children
       val height = when (index) { //让三个雨滴的height不同,增加错落感
           0 -> constraints.maxHeight * 0.8f
           1 -> constraints.maxHeight * 0.9f
           2 -> constraints.maxHeight * 0.6f
           else -> 0f
       }
       measurable.measure(
           constraints.copy(
               minWidth = 0,
               minHeight = 0,
               maxWidth = constraints.maxWidth / 10, // raindrop width
               maxHeight = height.toInt(),
           )
       )
   }

// Set the size of the layout as big as it can
   layout(constraints.maxWidth, constraints.maxHeight) {
       var xPosition = constraints.maxWidth / ((placeables.size + 1) * 2)

// Place children in the parent layout
       placeables.forEachIndexed { index, placeable ->
           // Position item on the screen
           placeable.place(x = xPosition, y = 0)

// Record the y co-ord placed up to
           xPosition += (constraints.maxWidth / ((placeables.size + 1) * 0.8f)).roundToInt()
       }
   }
}

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

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

measrue:measurables返回所有待测量的子Composable,constraints类似于MeasureSpec,封装父容器对子元素的布局约束。measurable.measure()中对子元素进行测量

layout:placeables返回测量后的子元素,依次调用placeable.place()对雨滴进行布局,通过xPosition预留雨滴在x轴的间隔

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

利用Jetpack Compose绘制可爱的天气动画

7.. 雪天效果

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

利用Jetpack Compose绘制可爱的天气动画

雪花的绘制

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

Canvas(modifier) {

val radius = size / 2

drawCircle( //白色填充
 color = Color.White,
 radius = radius,
 style = FILL
)

drawCircle(// 黑色边框
  color = Color.Black,
    radius = radius,
 style = Stroke(width = radius * 0.5f)
)
}

雪花飘落动画

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

  • 下降:通过改变y轴位置实现 (0f ~ 2.5f)

  • 左右飘移:通过该表x轴的offset实现 (-1f ~ 1f)

  • 逐渐消失:通过改变alpha实现(1f ~ 0f)

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

@Composable
private fun Snowdrop(
modifier: Modifier = Modifier,
durationMillis: Int = 1000 // 雪花飘落动画的druation
) {

//循环播放的Transition
   val 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,让雪花的运动轨迹更长,看起来更加真实

雪花的自定义布局

像雨滴一样,对雪花也使用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.6f
               1 -> constraints.maxHeight * 1.0f
               2 -> constraints.maxHeight * 0.7f
               else -> 0f
           }
           measurable.measure(
               constraints.copy(
                   minWidth = 0,
                   minHeight = 0,
                   maxWidth = constraints.maxWidth / 5, // snowdrop width
                   maxHeight = 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()
           }
       }
   }
}

最终效果如下:

利用Jetpack Compose绘制可爱的天气动画

8. 晴天效果

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

利用Jetpack Compose绘制可爱的天气动画

太阳的绘制

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

@Composable
fun Sun(modifier: Modifier = Modifier) {

Canvas(modifier) {

val radius = size.width / 6
       val stroke = size.width / 20

// draw circle
       drawCircle(
           color = Color.Black,
           radius = radius + stroke / 2,
           style = Stroke(width = stroke),
       )
       drawCircle(
           color = Color.White,
           radius = radius,
           style = Fill,
       )

// draw line

val lineLength = radius * 0.2f
       val 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 + offsetX
           val x2 = x1 + lineLength * cos(radians).toFloat()

val y1 = size.height / 2 + offsetY
           val 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轴坐标。

太阳的旋转

太阳的旋转动画很简单,通过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 / 6
       val stroke = size.width / 20
       val centerOffset = Offset(size.width / 30, size.width / 30) //圆心偏移量

// draw circle
       drawCircle(
           color = Color.Black,
           radius = radius + stroke / 2,
           style = Stroke(width = stroke),
           center = center + centerOffset //圆心偏移
       )

//...略
   }
}

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

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

利用Jetpack Compose绘制可爱的天气动画

9. 动画的组合、切换

上面分别实现了Rain、Snow、Sun等图形,接下来使用这些元素组合成各种天气效果。

将图形组合成天气

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))
}
}

让动画切换更加自然

利用Jetpack Compose绘制可爱的天气动画

当在多个天气动画之间进行切换时,我们希望能实现更自然的过渡。实现思路是将组成天气动画的各元素的Modifier信息变量化,然后通过Animation进行改变state 假设所有的天气都可以由Cloud、Sun、Rain组合而成,无非就是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实现不同的天气组合

@Composable
fun ComposedIcon(modifier: Modifier = Modifier, composeInfo: ComposeInfo) {

//各元素的ComposeInfo
   val (sun, cloud, rains) = composeInfo

Box(modifier) {

//应用ComposeInfo到Modifier
       val _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 = 1f
       onDispose { }
   }

//创建动画(0f ~ 1f),用于更新ComposeInfo
   val animateFloat by animateFloatAsState(
       targetValue = trigger,
       animationSpec = tween(1000)
   ) {
    //当动画结束时,更新ComposeWeather到最新state
       setCur(composedIcon)
       trigger = 0f
   }

//根据AnimationState计算当前ComposeInfo
   val composeInfo = remember(animateFloat) {
       cur.composedIcon + (weatherIcon.composedIcon - cur.composedIcon) * animateFloat
   }

来源:https://segmentfault.com/a/1190000041308301

标签:Jetpack,Compose,天气,动画
0
投稿

猜你喜欢

  • Android仿微信照片选择器实现预览查看图片

    2022-02-03 12:25:51
  • Springboot通过run启动web应用的方法

    2021-08-30 01:57:37
  • Java聊天室之使用Socket实现通信功能

    2022-03-08 09:46:27
  • 列举java语言中反射的常用方法及实例代码

    2022-10-31 13:45:07
  • Spring Boot + thymeleaf 实现文件上传下载功能

    2022-05-22 03:56:13
  • 一篇文章带你入门java变量与类型

    2022-10-13 06:14:58
  • java基础的详细了解第四天

    2022-11-25 07:24:51
  • C#中HttpWebRequest的用法详解

    2023-06-18 22:39:27
  • WPF实现多运算符表达式计算器

    2023-07-17 10:31:40
  • Spring Boot整合mybatis使用注解实现动态Sql、参数传递等常用操作(实现方法)

    2023-03-17 07:09:32
  • C/C++在Java、Android和Objective-C三大平台下实现混合编程

    2022-01-04 16:58:08
  • 基于C#调用OCX控件的常用方法(推荐)

    2021-06-24 11:56:29
  • C# WinForm快捷键设置技巧

    2023-07-20 07:03:13
  • C#实现Array添加扩展实例

    2023-02-16 23:01:03
  • SpringBoot项目如何打war包问题详解

    2023-06-07 08:33:47
  • JAVA熔断和降级真实关系的图文详解

    2023-11-30 12:39:20
  • java数独游戏完整版分享

    2023-05-22 07:09:56
  • Java接口和抽象类有什么区别

    2021-08-21 07:01:18
  • Activiti开发环境的配置

    2021-07-31 21:57:51
  • 解决mybatis update并非所有字段需要更新问题

    2022-12-09 10:20:55
  • asp之家 软件编程 m.aspxhome.com