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

1. 背景介绍

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

项目挑战

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

为了降低实现成本,我将app中的UI元素定义成偏卡通的风格,更利于代码实现:

e1fe31a3c939ebf8acb3914b73242d04.png

上面的动画没有使用gif、lottie等三方资源,所有效果都基于Compose代码绘制。

MyApp:CuteWeather

App界面比较简洁,采用单页面呈现(这也是挑战赛要求),可以查看近一周的天气信息和温度走势等。

fd072935d42af79e3b0411a01c5e2f06.png

其中,卡通风格的天气动画算是这个app相对于同类应用的特色,本文将围绕这些天气动画介绍一下如何使用Compose绘制自定义图形、并基于这些图形实现动画。

2. Compose自定义绘制

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

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 //设置直线宽度

)

}

复制代码

上面代码绘制效果如下:

885d0c7acc14019b92318f8a0c770467.png

简单易用的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中的具体示例看一下实际使用效果

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

3. 雨天效果

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

89312169c556c0feb33949ea2e626751.png

雨滴的绘制

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

bcd71d101e30e955a0c5d3573b703e26.png

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

我们使用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

)

}

}

复制代码

雨滴下落动画

完成雨滴的基本图形绘制后,接下来为两线段增加位移动画,形成流动的效果。

8c6ca33e20e16721b21662e44189a871.png

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

然后以锚点为基准绘制上下两线段,就行成接连不断的动画效果了

216d9d2432d98365cd7d6929dcc0c656.png

代码如下:

@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

)

}

}

复制代码

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视图一样,自定义布局需要先后经历measure、layout两步。

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

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

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

ca3473cd3cae13e4c489fbc835db3a4a.png

4. 雪天效果

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

1c58aaf60ff77528f54a80205e101c1e.png

雪花的绘制

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

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)

)

}

}

复制代码

animateY的targetValue设为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()

}

}

}

}

复制代码

最终效果如下:

20bf7f957ce72d508413b6400c119200.png

5. 晴天效果

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

e771863d2033831e5264485c97a2b470.png

太阳的绘制

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

@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,也可以实现旋转效果。

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

2ee7b1b6f72bf9b94f7a635e19d9e6a8.png

6. 动画的组合、切换

在实现了Rain、Snow、Sun等图形后,就可以使用这些图形组合成各种天气效果了。

将图形组合成天气

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

比如,多云转阵雨,我们摆放Sun、Cloud、Rain等元素后,通过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))

}

}

复制代码

让动画切换更加自然

9b5428a8a7de86b9d673d5a5791f1781.png

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

假设所有的天气都由Cloud、Sun、Rain组成,无非就是offset、size、alpha值的不同:

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

//各元素的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

}

//使用最新的ComposeInfo显示Icon

ComposedIcon(

modifier,

composeInfo

)

}

复制代码

到此,我们就实现了天气动画的自然过度了。

7. 最后

Compose通过声明式的方式实现自定义图形及其动画,这相比与命令式的代码来的更加简单,这也让用代码替代gif等实现动画、表情成为可能,经代码绘制的效果在清晰度以及帧率上也都要远超gif,欢迎大家下载源码体验。

当然,在我看来Compose精髓绝不仅是UI,后面有机会将分享更多关于架构以及底层实现的内容,希望与大家一起学习和讨论。

相关链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值