利用Jetpack Compose繪制可愛的天氣動畫
1. 項目背景
最近參加了Compose挑戰(zhàn)賽的終極挑戰(zhàn),使用Compose完成了一個天氣app。之前幾輪挑戰(zhàn)也都有參與,每次都學到不少新東西。如今迎來最終挑戰(zhàn),希望能將這段時間的積累活學活用,做出更加成熟的作品。
項目挑戰(zhàn)
因為沒有美工協(xié)助,所以我考慮通過代碼實現(xiàn)app中的所有UI元素例如各種icon等,這樣的UI在任何分辨率下都不會失真,跟重要的是可以靈活地實現(xiàn)各種動畫效果。
為了降低實現(xiàn)成本,我將app中的UI元素定義成偏卡通的風格,可以更容易地通過代繪實現(xiàn):

上面的動畫沒有使用gif、lottie或者其他靜態(tài)資源,所有圖形都是基于Compose代碼繪制的。
2. MyApp:CuteWeather
App界面比較簡潔,采用單頁面呈現(xiàn)(挑戰(zhàn)賽要求),卡通風格的天氣動畫算是相對于同類app的特色:

項目地址:https://github.com/vitaviva/compose-weather
App界面構(gòu)成
App縱向劃分為幾個功能區(qū)域,每個區(qū)域都涉及到一些不同的Compose API的使用

涉及技術(shù)點較多,本文主要介紹如何使用Compose繪制自定義圖形、并基于這些圖形實現(xiàn)動畫,其他內(nèi)容有機會再單獨介紹。
3. Compose自定義繪制
像常規(guī)的Android開發(fā)一樣,除了提供各種默認的Composable控件以外,Compose也提供了Canvas用來繪制自定義UI。
其實Canvas相關(guān)API在各個平臺都大同小異,但在Compose上的使用有以下特點:
- 用聲明式的方式創(chuàng)建和使用Canvas
- 通過DrawScope提供必要的state及各種APIs
- API更簡單易用
聲明式地創(chuàng)建和使用Canvas
Compose中,Canvas作為Composable,可以聲明式地添加到其他Composable中,并通過Modifier進行配置
Canvas(modifier = Modifier.fillMaxSize()){ // this: DrawScope
//內(nèi)部進行自定義繪制
}傳統(tǒng)方式需要獲取Canvas句柄命令式的進行繪制,而Canvas{...}通過狀態(tài)驅(qū)動的方式在block內(nèi)執(zhí)行繪制邏輯、刷新UI。
強大的DrawScope
Canvas{...}內(nèi)部通過DrawScope提供必要的state用來獲取當前繪制所需環(huán)境變量,例如我們最常用的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 //設(shè)置直線寬度
)
}上面代碼繪制效果如下:

4.簡單易用的API
傳統(tǒng)的Canvas API需要進行Paint等配置;DrawScope提供的API更簡單,使用更友好。
例如繪制一個圓,傳統(tǒng)的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
) {...}看起來參數(shù)變多了,但是其實已經(jīng)通過size等設(shè)置了合適的默認值,同時省去了對Paint的創(chuàng)建和配置,使用起來更方便。
使用原生Canvas
目前DrawScope提供的API還不及原生Canvas豐富(比如不支持drawText等),當不滿足使用需求時,也可以直接使用原生Canvas對象進行繪制
drawIntoCanvas { canvas ->
//nativeCanvas是原生canvas對象,android平臺即android.graphics.Canvas
val nativeCanvas = canvas.nativeCanvas
}上面介紹了Compose Canvas的基本知識,下面結(jié)合app中的具體示例看一下實際使用效果
首先,看一下雨水的繪制過程。
5. 雨天效果
雨天天氣的關(guān)鍵是如何繪制不斷下落的雨水

雨滴的繪制
我們先繪制構(gòu)成雨水的基本單元:雨滴

經(jīng)拆解后,雨水效果可由三組雨滴構(gòu)成,每一組雨滴分成上下兩端,這樣在運動時就可以形成接連不斷的雨水效果。我們使用drawLine繪制每一段黑線,設(shè)置適當?shù)?code>stokeWidth,并通過cap設(shè)置端點的圓形效果:
@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, //設(shè)置寬度
cap = StrokeCap.Round//頭部圓形
)
// line2同上
drawLine(
Color.Black,
Offset(x, line2y1),
Offset(x, line2y2),
strokeWidth = width,
cap = StrokeCap.Round
)
}
}雨滴下落動畫
完成基本圖形的繪制后,接下來為兩線段實現(xiàn)循環(huán)往復(fù)的位移動畫,形成雨水的流動效果。

以兩線段中間空隙為動畫的錨點,根據(jù)animationState設(shè)置其y軸位置,讓其從繪制區(qū)域的頂端移動到低端(0 ~ size.hight),然后restart這個動畫。
以錨點為基準繪制上下兩線段,就可以行成接連不斷的雨滴效果了

代碼如下:
@Composable
fun rainDrop() {
//循環(huán)播放的動畫 ( 0f ~ 1f)
val animateTween by rememberInfiniteTransition().animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(durationMillis, easing = LinearEasing),
RepeatMode.Restart //start動畫
)
)
Canvas(modifier) {
// scope : 繪制區(qū)域
val width = size.width
val x: Float = size.width / 2
// width/2是strokCap的寬度,scopeHeight處預(yù)留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很難準確布局三個雨滴的相對位置。因此考慮轉(zhuǎn)而使用Compose的自定義布局,以提高靈活性和準確性:
Layout(
modifier = modifier.rotate(30f), //雨滴旋轉(zhuǎn)角度
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。
跟傳統(tǒng)Android視圖一樣,自定義布局需要先后經(jīng)歷measure、layout兩步。
measrue:measurables返回所有待測量的子Composable,constraints類似于MeasureSpec,封裝父容器對子元素的布局約束。measurable.measure()中對子元素進行測量
layout:placeables返回測量后的子元素,依次調(diào)用placeable.place()對雨滴進行布局,通過xPosition預(yù)留雨滴在x軸的間隔
經(jīng)過layout之后,通過 modifier.rotate(30f) 對Composable進行旋轉(zhuǎn),完成最終效果:

7.. 雪天效果
雪天效果的關(guān)鍵在于雪花的飄落。

雪花的繪制
雪花的繪制非常簡單,用一個圓圈代表一個雪花
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)
)
}雪花飄落動畫
雪花飄落的過程相對于雨滴墜落要復(fù)雜一些,由三個動畫組成:
- 下降:通過改變y軸位置實現(xiàn) (0f ~ 2.5f)
- 左右飄移:通過該表x軸的offset實現(xiàn) (-1f ~ 1f)
- 逐漸消失:通過改變alpha實現(xiàn)(1f ~ 0f)
借助InfiniteTransition同步控制多個動畫,代碼如下:
@Composable
private fun Snowdrop(
modifier: Modifier = Modifier,
durationMillis: Int = 1000 // 雪花飄落動畫的druation
) {
//循環(huán)播放的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結(jié)束
val animateAlpha by transition.animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
tween(durationMillis, easing = FastOutSlowInEasing),
)
)
Canvas(modifier) {
val radius = size.width / 2
// 圓心位置隨AnimationState改變,實現(xiàn)雪花飄落的效果
val _center = center.copy(
x = center.x + center.x * animateX,
y = center.y + center.y * animateY
)
drawCircle(
color = Color.White.copy(alpha = animateAlpha),//alpha值的變化實現(xiàn)雪花消失效果
center = _center,
radius = radius,
)
drawCircle(
color = Color.Black.copy(alpha = animateAlpha),
center = _center,
radius = radius,
style = Stroke(width = radius * 0.5f)
)
}
}animateY的targetValue設(shè)為2.5f,讓雪花的運動軌跡更長,看起來更加真實
雪花的自定義布局
像雨滴一樣,對雪花也使用Layout自定義布局
@Composable
fun Snow(
modifier: Modifier = Modifier,
animate: Boolean = false,
) {
Layout(
modifier = modifier,
content = {
//擺放三個雪花,分別設(shè)置不同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()
}
}
}
}最終效果如下:

8. 晴天效果
通過一個旋轉(zhuǎn)的太陽代表晴天效果

太陽的繪制
太陽的圖形由中間的圓形和圍繞圓環(huán)的等分豎線組成。
@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軸坐標。
太陽的旋轉(zhuǎn)
太陽的旋轉(zhuǎn)動畫很簡單,通過Modifier.rotate不斷轉(zhuǎn)動Canvas即可。
@Composable
fun Sun(modifier: Modifier = Modifier) {
//循環(huán)動畫
val animateTween by rememberInfiniteTransition().animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(5000), RepeatMode.Restart)
)
Canvas(modifier.rotate(animateTween)) {// 旋轉(zhuǎn)動畫
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,也可以實現(xiàn)旋轉(zhuǎn)效果。
最后我們給太陽的圓心增加一個偏移量,讓轉(zhuǎn)動更加活潑:

9. 動畫的組合、切換
上面分別實現(xiàn)了Rain、Snow、Sun等圖形,接下來使用這些元素組合成各種天氣效果。
將圖形組合成天氣
Compose的聲明式語法非常有利于UI的組合:
比如,多云轉(zhuǎn)陣雨,我們擺放Sun、Cloud、Rain等元素后,通過Modifier調(diào)整各自位置即可:
@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))
}
}讓動畫切換更加自然

當在多個天氣動畫之間進行切換時,我們希望能實現(xiàn)更自然的過渡。實現(xiàn)思路是將組成天氣動畫的各元素的Modifier信息變量化,然后通過Animation進行改變state 假設(shè)所有的天氣都可以由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,根據(jù)ComposeInfo實現(xiàn)不同的天氣組合
@Composable
fun ComposedIcon(modifier: Modifier = Modifier, composeInfo: ComposeInfo) {
//各元素的ComposeInfo
val (sun, cloud, rains) = composeInfo
Box(modifier) {
//應(yīng)用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,并在其發(fā)生更新時使用動畫進行過度:
@Composable
fun ComposedWeather(modifier: Modifier, composedIcon: ComposedIcon) {
val (cur, setCur) = remember { mutableStateOf(composedIcon) }
var trigger by remember { mutableStateOf(0f) }
DisposableEffect(composedIcon) {
trigger = 1f
onDispose { }
}
//創(chuàng)建動畫(0f ~ 1f),用于更新ComposeInfo
val animateFloat by animateFloatAsState(
targetValue = trigger,
animationSpec = tween(1000)
) {
//當動畫結(jié)束時,更新ComposeWeather到最新state
setCur(composedIcon)
trigger = 0f
}
//根據(jù)AnimationState計算當前ComposeInfo
val composeInfo = remember(animateFloat) {
cur.composedIcon + (weatherIcon.composedIcon - cur.composedIcon) * animateFloat
}以上就是利用Jetpack Compose繪制可愛的天氣動畫的詳細內(nèi)容,更多關(guān)于Jetpack Compose繪制動畫的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android斷點續(xù)傳下載器JarvisDownloader的示例
本篇文章主要介紹了Android斷點續(xù)傳下載器JarvisDownloader的示例,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-05-05
flutter PageView實現(xiàn)左右滑動切換視圖
這篇文章主要為大家詳細介紹了flutter PageView實現(xiàn)左右滑動切換視圖,具有一定的參考價值,感興趣的小伙伴們可以參考一下2019-07-07
Android藍牙服務(wù)查找附近設(shè)備分析探索
這篇文章主要介紹了Android藍牙服務(wù)實現(xiàn)查找附近設(shè)備,了解內(nèi)部原理是為了幫助我們做擴展,同時也是驗證了一個人的學習能力,如果你想讓自己的職業(yè)道路更上一層樓,這些底層的東西你是必須要會的2023-01-01
Android進程間大數(shù)據(jù)通信LocalSocket詳解
這篇文章主要為大家介紹了Android進程間大數(shù)據(jù)通信LocalSocket詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-03-03
Android開發(fā)學習之WallPaper設(shè)置壁紙詳細介紹與實例
這篇文章主要介紹了Android開發(fā)學習之WallPaper設(shè)置壁紙詳細介紹與實例,有需要的朋友可以參考一下2013-12-12

