羨慕大勞星空頂?不如跟我一起使用 Jetpack compose 繪製一個星空背景(帶流星動畫)

語言: CN / TW / HK

我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第3篇文章,點擊查看活動詳情

前言

背景

作為一個自詡的電影愛好者,經常會在半夜看電影,看完後就會順道去豆瓣標記一下看過,再看看別人對這個電影的理解。

某日深夜,看完電影后,順手打開了豆瓣的 書影音記錄 這個功能,起初並沒有注意到這個頁面的背景有什麼東西,我以為只是一個普通的深色背景而已,直至一道流星突然劃過屏幕!

好漂亮!我這才發現原來這個頁面的背景是一個星空!時不時的還會有流星飛過!

這麼漂亮的背景,不仿寫一下真的對不起它了!

這個頁面靜態時是這樣的:

s1.png

我把內容拉到最後,然後錄製了一個動圖,可以看到流星飛過的樣子:

s2.gif

實現效果

這次依然使用 JetpackPack Compose 作為 UI 框架來實現。

最終實現效果如圖:

p1.gif

代碼地址

完整代碼地址:starrySky

實現

分析背景組成

繁星

在開始實現之前,我們首先要分析一下豆瓣的這個背景都有些什麼元素,它們的運行邏輯是什麼。

我們先看一下這張僅有背景的截圖:

s3.png

顯而易見,該頁面以純黑色作為底色,然後點綴了一些白色或者説灰色的圓形小點,即繁星。

我原本以為這些繁星應該是隨機生成的,但是經過我的觀察和測試,實際上這些繁星都是固定不變的,我猜測這其實就是一整個靜態圖片。

但是我想實現不是這種的,如果只是一張靜態圖片那還有什麼意思呢?

所以我準備更改為隨機生成星星,且可以自定義星星的尺寸、顏色等參數。

流星

流星相對來説稍微複雜那麼一點點,我做了一張流星局部放大且減速的動圖:

s4.gif

從上面這個減速動圖中可以看出,流星的生成有如下幾個要點:

  1. 流星剛出現時有一個透明度逐漸減小的漸變效果
  2. 流星從出現到結束,一直都在沿着一條直線平移
  3. 流星剛出現時較短,並且逐漸變長,但是在達到一定長度後就不再變化

compose 自定義繪製基礎知識

分析完這個頁面由什麼構成的後,我們先別急着直接開始寫,我先擴展幾個關於 compose 自定義繪製的基礎知識,後面會用到。

DrawScope

首先,在compose中如果想要自己繪製的話,需要在 DrawScope 中才能使用我們在 view 中熟悉的 drawXXX 繪製相應的圖形。

那麼,怎麼才能使用 DrawScope 呢?

我們可以直接使用 Canvans ,它的 onDraw 參數接收的就是一個作用域為 DrawScope 的匿名函數,我們可以在這個函數中進行我們的繪製操作,例如,這裏我使用 drawRect 畫了一個白色的矩形:

p2.png

不過,仔細想想,我們這裏的需求,直接使用 Canvans 合適嗎?

我們需要做的只是一個背景啊,直接使用 Canvans 雖然也能實現我們的需求,但是總覺得怪怪的。

不用擔心,compose 還有一個地方也提供了 DrawScope ,那就是在 Modifier 中,在 Modifier 中自定義繪製的話特別適合於給已有的佈局加東西。

而 Modifier 中有三個繪製相關的 API 可以使用,分別是 drawWithContentdrawBehinddrawWithCache

其中,drawWithContent 是和上面的 Canvans 差不多,並且可以通過更改 drawContent() 的位置,來實現控制繪製內容和這個控件原有內容的位置關係。

drawBehind 顧名思義就是把我們的內容放到原有內容之下,嗯?這不就是我們要的嗎?繪製背景嘛。其實使用 drawWithContent 可以實現和這個 API 完全一致的效果,但是這裏咱們直接使用這個就行。

drawWithCache 看名字就知道,是帶有緩存的繪製,我們可以緩存住一些不需要改變的對象,避免重複創建對象的開銷。

關於這三個 API 的使用可以參考 自定義繪製

給自定義繪製內容添加動畫

知道了往哪兒繪製圖形後,下一步是瞭解一下如何給自定義繪製內容添加動畫效果。

其實,給繪製內容添加動畫效果和給普通的 compose 控件加動畫基本一致。

例如,我給上面這個矩形添加一個旋轉動畫可以這樣寫:

```kotlin @Preview @Composable fun PreviewTest() { Column( Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { var state by remember { mutableStateOf(true) }

    val rotateValue by animateFloatAsState(targetValue = if (state) 90f else 0f)

    Canvas(
        modifier = Modifier.size(100.dp).clickable { state = !state }
        , onDraw = {
        withTransform(
            {
                rotate(rotateValue)
            }
        ) {
            drawRect(Color.White)
        }
    })
}

} ```

可以看到,與正常用法幾乎沒有區別,這裏演示的是使用 draw 中的變換功能,旋轉當前繪製的矩形,旋轉的角度則由 animateFloatAsState 來提供,這樣就實現了一個簡單的旋轉動畫。

開始實現

基礎結構

由於我們最終會在 Modifier 中進行繪製,如果直接寫的話會顯得很臃腫,而且也無法多次使用,所以我們需要實現一個 Modifier 的擴展函數,使用時只需要直接調用這個擴展函數即可:

kotlin fun Modifier.drawStarrySkyBg() : Modifier = composed { drawBehind { // ...... } }

使用時直接調用 Modifier.drawStarrySkyBg() 即可。

另外,在上面我們介紹過,可以使用 drawWithCache 緩存對象,為了性能更好,這裏應該使用 drawWithCache 而不是直接使用 drawBehind

```kotlin fun Modifier.drawStarrySkyBg() : Modifier = composed { drawWithCache { // …… // 可以在這裏初始化對象,這裏的內容不會被 recompose

    onDrawBehind { 
        // ……
        // 這裏和 drawBehind 一樣,可以在這裏進行繪製
    }
}

} ```

繪製純色背景

首先,我們直接繪製一個佔滿畫布的矩形將背景覆蓋掉,達到更改背景顏色的目的:

```kotlin fun Modifier.drawStarrySkyBg( background: Color = Color.Black, ) : Modifier = composed { drawWithCache { // ……

    onDrawBehind {
        // ……
        // 繪製背景
        drawRect(color = background)
    }
}

} ```

繪製星星

星星的繪製比較簡單,直接使用 drawCircle 繪製圓形即可。

但是,這裏我們需要實現的是,星星的位置、大小、顏色應該是隨機的。

所以我們首先需要定義一個數據類 StarInfo 用於存放星星信息,然後在 CacheDrawScope 中初始化好星星信息,在 DrawScope 中直接根據這個信息繪製即可:

kotlin data class StarInfo( val offset: Offset, val color: Color, val radius: Float )

當然,隨機的顏色和尺寸應該是預設一組,而非真的完全隨機,所以給這個函數添加參數

kotlin fun Modifier.drawStarrySkyBg( // …… starNum: Int = 20, // 需要生成多少個星星 starColorList: List<Color> = listOf(Color(0x99CCCCCC), Color(0x99AAAAAA), Color(0x99777777)), starSizeList: List<Float> = listOf(0.8f, 0.9f, 1.2f), // …… )

需要注意的是,這裏的 starSizeList 並不是真正的圓形尺寸,而是縮放係數,因為圓形尺寸是按照當前可繪製區域的尺寸計算出來的,如果直接寫死尺寸,會不太美觀。

然後,定義並初始化星星信息:

```kotlin drawWithCache { val random = Random(seed) val startInfoList = mutableListOf()

// 添加星星數據
for (i in 0 until starNum) {
    val sizeScale = starSizeList.random(random)

    startInfoList.add(
        StarInfo(
            Offset( // 隨機生成座標
                random.nextDouble(size.width.toDouble()).toFloat(), 
                random.nextDouble(size.height.toDouble()).toFloat()
            ),
            starColorList.random(random),  // 隨機選擇一個預設顏色
            size.width / 200 * sizeScale  // 尺寸為可繪製區域大小的 1/200 並乘以隨機選擇到的縮放係數
        )
    )
}

// ……

} ```

上面代碼中的 size 是當前可繪製區域的尺寸信息。

最後,開始繪製:

```kotlin onDrawBehind { // ……

// 繪製星星
for (star in startInfoList) {
    drawCircle(color = star.color, center = star.offset, radius = star.radius)
}

// ……

}

```

繪製流星

繪製流星部分我們將分為三步走:

  1. 繪製出流星
  2. 讓流星動起來
  3. 給流星加上一點細節

首先,我們需要繪製出流星的圖案。

其實,這個流星無非就是一條直線,所以,我們只需要使用 drawLine 繪製直線即可。

drawLine 需要三個必須的參數:

  1. color: Color, 直線的顏色
  2. start: Offset, 直線的起點座標
  3. end: Offset, 直線的終點座標

為了提高擴展性,我們將顏色提出作為 drawStarrySkyBg 的參數,同時,流星並不是橫平豎直的,而是有一定傾斜角度的,所以我們還要提供一個角度參數,另外,流星的線段寬度我們也提出來作為一個參數:

kotlin fun Modifier.drawStarrySkyBg( // …… meteorColor: Color = Color.White, meteorRadian: Double = 0.7853981633974483, // 這裏的角度是弧度,相當於45度 meteorStrokeWidth: Float = 1f, // …… )

然後,繪製出一幀的流星:

kotlin drawLine( color = meteorColor, start = Offset(currentStartX, currentStartY), end = Offset(currentEndX, currentEndY), strokeWidth = meteorStrokeWidth )

流星應該是從出現到結束一直都是在運動的,不可能是靜態的,所以上面這個只是繪製出了流星某一個時刻的狀態,所以我稱之為繪製出了一幀。上面的起點座標和終點座標也應該是實時計算出來。

至於怎麼計算的,我們先按下不表,先來説説怎麼模擬流星的運動軌跡。

即,讓流星動起來。

如果想要讓繪製的內容動起來,理所當然的會想到應該使用動畫相關的API,仔細分析一下我們這裏的流星動畫,它應該是無限運行的,因為流星需要一直都有,不能説是飛一次就銷燬了是吧?

所以這裏我們應該使用無限動畫API rememberInfiniteTransition()

但是,應該將什麼參數作為動畫的值呢?

流星的座標? 時間?

為了方便理解,這裏我們選擇使用時間作為動畫值,而座標由時間來實時計算出來。

因為如果直接將座標作為動畫值的話,不方便編寫算法,同時也不好做出一些擴展。

編寫動畫參數如下:

kotlin val deltaMeteorAnim = rememberInfiniteTransition() val meteorTimeAnim by deltaMeteorAnim.animateFloat( initialValue = 0f, targetValue = 300f, // 這個值其實可以根據時間、速度、指定長度、以及當前繪製區域可用大小計算出來,但是我懶得算了,就直接寫死一個比較大的值了 animationSpec = infiniteRepeatable( animation = tween(durationMillis = meteorTime, delayMillis = meteorScaleTime, easing = LinearEasing) ) )

這裏我們使用 meteorTimeAnim 作為模擬的時間值,需要注意的是這個值並不是和現實時間對應的,只是一個模擬變化值。

這個值將會無限的重複運行,每次運行都會間隔 meteorScaleTime 毫秒,並且單次運行持續時間為 meteorTime 毫秒。運行的內容是將 meteorTimeAnim 線性的從 0 過渡到 300。

上面提到的這幾個參數都抽出來作為函數的參數:

kotlin fun Modifier.drawStarrySkyBg( // …… meteorTime: Int = 1500, meteorScaleTime: Int = 3000, // …… )

既然選擇了時間作為變化的值,那麼對於流星的運動,我們可以直接按照 時間x速度 來計算出它的運動路程,因此,再抽出一個參數作為速度:

kotlin fun Modifier.drawStarrySkyBg( // …… meteorVelocity: Float = 10f, // …… )

需要注意的是,這裏速度也只是一個模擬值,並不是真正的速度。

有了時間和速度我們就可以計算出流星實時運行的座標值了,對了,上面我們已經説了流星不是橫平豎直的飛行的,而是有一個角度的,所以實際座標值計算應該是:

```kotlin val cosAngle = cos(meteorRadian).toFloat() val sinAngle = sin(meteorRadian).toFloat()

// 計算當前起點座標 currentStartX = startX + meteorVelocity * meteorTimeAnim * cosAngle currentStartY = startY + meteorVelocity * meteorTimeAnim * sinAngle ```

其中,startXstartY 是我們隨機生成的一個初始座標,因為流星每次出現的初始位置應該是隨機的而不是固定在一個地方,所以我們給他加了一個初始座標。

當然,這個只是計算流星的起點座標,對於終點座標,我們則需要做一些處理。

還記得嗎?上面我們分析的時候説過,流星的長度並不是一開始就是目標長度的,而是從 0 開始逐漸伸長到目標長度的。

所以我們需要在流星長度未達到目標長度時,讓流星的終點座標"跑"的比起點座標快:

kotlin // 如果長度未達到目標長度,則開始增長長度,具體表現為計算終點座標時,速度是起點的兩倍 if (currentLength < meteorLength) { currentEndX = startX + meteorVelocity * 2 * meteorTimeAnim * cosAngle currentEndY = startY + meteorVelocity * 2 * meteorTimeAnim * sinAngle } else { // 已達到目標長度,直接用起點座標加上目標長度即可得到終點座標 currentLength = meteorLength currentEndX = currentStartX + meteorLength * cosAngle currentEndY = currentStartY + meteorLength * sinAngle }

在這裏,我們直接把終點座標運行的速度設置為起點座標的兩倍,其實這裏可以編寫一個更復雜的加速度算法,使得流星運行起來更自然,更舒適,但是這裏我們就不寫這麼複雜了,感興趣的可以自己修改。

其中,當前流星長度的計算公式為:

kotlin // 只有未達到目標長度才實時計算當前長度 if (currentLength != meteorLength) { currentLength = sqrt( (currentEndX - currentStartX).pow(2) + (currentEndY - currentStartY).pow(2) ) }

這就是數學中的計算兩點之間的距離公式,這裏就不展開講了,感興趣的可以自己去看看。

由於受到浮點數計算精度影響還有為了性能更優,我們只會在目標長度和當前實際長度不一致時才計算當前長度。

並且我們會在當前長度大於或等於目標長度時就直接把目標長度複製給當前長度,確保它倆能保持一致。

對了,流星的目標長度同樣是抽出來作為函數的一個參數:

kotlin fun Modifier.drawStarrySkyBg( // …… meteorLength: Float = 500f, // …… )

經過上面的計算,我們就能夠得到一個飛翔的流星了。

接下來,就是給這個流星的動畫加上一點細節。

首先是流星剛出來時的透明度過度動畫:

```kotlin val meteorAlphaAnima by deltaMeteorAnim.animateFloat( initialValue = 0f, targetValue = 1000f, // 透明度的動畫時長應該是整體動畫的 1/10 。這裏直接使用1000作為目標值 animationSpec = infiniteRepeatable( animation = tween(durationMillis = meteorTime, delayMillis = meteorScaleTime, easing = LinearEasing) ) )

// ……

// 繪製流星 drawLine( // …… alpha = (meteorAlphaAnima / 100).coerceAtMost(1f) )

```

在這裏,我們透明度的動畫值依舊使用的是和時間一樣的無限動畫,只不過我們把目標值設置為了 1000, 然後在實際使用時將其除以 100 , 並且保證透明度不大於 1 (該參數不能大於1)。

這樣處理的目的是使得透明度動畫能夠保持和時間的同步,並且確保透明度會在時間走了 1/10 時完全不透明,即只有最開始的 1/10 時間有透明度過渡效果。

其他的一些小細節,諸如流星已經飛出屏幕邊界後就不再計算和繪製、流星初始座標隨機生成的邊界控制、流星可以使用無限拖尾等這裏就不再贅述,感興趣的可以直接看代碼。

代碼非常簡單,只有不到200行。

地址:starrySky

預覽效果

這個函數封裝好後使用十分簡單,只需要在想要添加星空背景的組件的 modifier 參數加上 .drawStarrySkyBg() 即可,例如:

```kotlin Column( Modifier .fillMaxSize() .drawStarrySkyBg(), // 給這個 Column 加上星空背景 verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { var text by remember { mutableStateOf("Hello equationl \n at starry sky\n${System.currentTimeMillis()}") }

Text(
    text = text,
    color = Color.White,
    fontSize = 32.sp,
    modifier = Modifier.clickable {
        text = "Hello equationl  \n at starry sky\n${System.currentTimeMillis()}"
    }
)

} ```

參考資料

  1. Exploring Jetpack Compose Canvas: the power of drawing
  2. Jetpack Compose 繪製 Canvas,DrawScope, 以及Modifier.drawWithContent,BlendMode講解
  3. Custom Canvas Animations in Jetpack Compose
  4. Compose 自定義繪製