1. 背景介绍
最近参加了Compose挑战赛的终极挑战,使用Compose完成了一个天气app。之前几轮挑战我也都有参与,每次都学到不少新东西,希望在这最后一轮挑战中,活用这段时间的积累做出更加成熟的作品。
项目挑战
因为没有美工协助,所以我考虑通过代码实现app中的所有UI元素例如各种icon等,这样的UI在任何分辨率下都不会失真,而且可以更灵活地完成各种动画效果。
为了降低实现成本,我将app中的UI元素定义成偏卡通的风格,更利于代码实现:
上面的动画没有使用gif、lottie等三方资源,所有效果都基于Compose代码绘制。
MyApp:CuteWeather
App界面比较简洁,采用单页面呈现(这也是挑战赛要求),可以查看近一周的天气信息和温度走势等。
项目地址: github.com/vitaviva/co…
其中,卡通风格的天气动画算是这个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 //设置直线宽度
)
}
复制代码
上面代码绘制效果如下:
简单易用的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. 雨天效果
雨天天气的关键是如何绘制不断下落的雨水
雨滴的绘制
我们先绘制构成雨水的基本单元:雨滴
经拆解后,雨水效果可由三组雨滴构成,每一组雨滴分成上下两段,这样在运动时可以形成接连不断的效果。
我们使用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
)
}
}
复制代码
雨滴下落动画
完成雨滴的基本图形绘制后,接下来为两线段增加位移动画,形成流动的效果。
以两线段中间空隙为动画的锚点,根据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.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进行旋转,完成最终效果:
4. 雪天效果
雪天效果的关键在于雪花的飘落。
雪花的绘制
雪花的绘制非常简单,用一个圆圈代表一个雪花
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()
}
}
}
}
复制代码
最终效果如下:
5. 晴天效果
通过一个旋转的太阳代表晴天效果
太阳的绘制
太阳的图形由中心圆形和围绕圆环的等分线段组成。
@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,也可以实现旋转效果。
最后我们给太阳的圆心增加一个偏移量,让转动更加活泼:
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))
}
}
复制代码
让动画切换更加自然
当在多个天气动画之间进行切换时,我们希望能实现更自然的过渡。实现思路是将组成天气动画的各元素的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
)
}
复制代码
到此,我们就实现了天气动画的自然过度了。