Jetpack Compose 波浪進度載入

語言: CN / TW / HK

theme: smartblue highlight: androidstudio


我正在參加中秋創意投稿大賽,詳情請看:中秋創意投稿大賽

受到 波浪動畫很常見,但這個波浪元件絕對不常見 這篇文章的啟發,我為 Compose 寫了一個波浪效果的進度載入庫,API 的設計上符合 Compose 的開發規範,使用非常簡便。

1. 使用方式

在 root 的 build.gradle 中引入 jitpack

groovy allprojects { repositories { ... maven { url 'https://jitpack.io' } } }

在 module 的 build.gradle 中引入 ComposeWaveLoading 的最新版本

groovy dependencies { implementation 'com.github.vitaviva:ComposeWaveLoading:$latest_version' }

2. API 設計思想

```kotlin

Box { WaveLoading ( progress = 0.5f // 0f ~ 1f ) { Image( painter = painterResource(id = R.drawable.logo_tiktok), contentDescription = "" ) } }

`` 傳統的 UI 開發方式中,設計這樣一個波浪控制元件,一般會使用自定義 View 並將 Image 等作為屬性傳入。 而在 Compose 中,我們讓WaveLoadingImage以組合的方式使用,這樣的 API 更加靈活,WaveLoding的內部可以是Image,也可以是Text亦或是其他Composable`。波浪動畫不拘泥於某一特定 Composable, 任何 Composable 都可以以波浪動畫的形式展現, 通過 Composable 的組合使用,擴大了 “能力” 的覆蓋範圍。

3. API 引數介紹

kotlin @Composable fun WaveLoading( modifier: Modifier = Modifier, foreDrawType: DrawType = DrawType.DrawImage, backDrawType: DrawType = rememberDrawColor(color = Color.LightGray), @FloatRange(from = 0.0, to = 1.0) progress: Float = 0f, @FloatRange(from = 0.0, to = 1.0) amplitude: Float = defaultAmlitude, @FloatRange(from = 0.0, to = 1.0) velocity: Float = defaultVelocity, content: @Composable BoxScope.() -> Unit ) { ... }

引數說明如下:

|引數| 說明| |--|--| |progress| 載入進度 | |foreDrawType| 波浪圖的繪製型別: DrawColor 或者 DrawImage | |backDrawType| 波浪圖的背景繪製| |amplitude| 波浪的振幅, 0f ~ 1f 表示振幅在整個繪製區域的佔比| |velocity|波浪移動的速度| |content| 子Composalble|

接下來重點介紹一下 DrawType

DrawType

波浪的進度體現在前景(foreDrawType)和後景(backDrawType)的視覺差,我們可以為前景後景分別指定不同的 DrawType 改變波浪的樣式。

kotlin sealed interface DrawType { object None : DrawType object DrawImage : DrawType data class DrawColor(val color: Color) : DrawType } 如上,DrawType 有三種類型:

  • None: 不進行繪製
  • DrawColor:使用單一顏色繪製
  • DrawImage:按照原樣繪製

以下面這個 Image 為例, 體會一下不同 DrawType 的組合效果

|index|backDrawType|foreDrawType | 說明 | |--|--|--| --| |1|DrawImage| DrawImage| 背景灰度,前景原圖| |2|DrawColor(Color.LightGray)|DrawImage|背景單色,前景原圖 | |3|DrawColor(Color.LightGray)|DrawColor(Color.Cyan)| 背景單色,前景單色| |4|None|DrawColor(Color.Cyan)| 無背景,前景單色|

如下圖中,第二排是前景原圖,第三排是前景單色

ezgif.com-gif-maker (12).gif

下圖展示無背景色的情況

ezgif.com-gif-maker (13).gif

注意 backDrawType 設定為 DrawImage 時,會顯示為灰度圖。

4. 原理淺析

簡單介紹一下實現原理。為了便於理解,程式碼經過簡化處理,完整程式碼可以在 github 檢視

這個庫的關鍵是可以將 WaveLoading {...} 內容取出,加以波浪動畫的形式顯示。所以需要將子 Composalbe 轉成 Bitmap 進行後續處理。

4.1 獲取 Bitmap

我在 Compose 中沒找到獲取點陣圖的辦法,所以用了一個 trick 的方式, 通過 Compose 與 Android 原生檢視良好的互操作性,先將子 Composalbe 顯示在 AndroidView 中,然後通過 native 的方式獲取 Bitmap:

```kotlin @Composable fun WaveLoading (...) { Box {

    var _bitmap by remember {
        mutableStateOf(Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565))
    }

    AndroidView(
        factory = { context ->
            // Creates custom view
            object : AbstractComposeView(context) {

                @Composable
                override fun Content() {
                    Box(Modifier.wrapContentSize(){
                        content()
                    }
                }


                override fun dispatchDraw(canvas: Canvas?) {
                    val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
                    val canvas2 = Canvas(source)
                    super.dispatchDraw(canvas2)
                    _bitmap = bmp

                }

            }
        }

    )


    WaveLoadingInternal(bitmap = _bitmap)

}

} ```

AndroidView 是一個可以繪製 Composable 的原生控制元件,我們將 WaveLoading 的子 Composable 放在其 Content 中,然後在 dispatchDraw 中繪製時,將內容繪製到我們準備好的 Bitmap 中。

4.2 繪製波浪線

我們基於 Compose 的 Canvas 繪製波浪線,波浪線通過 Path 承載 定義 WaveAnim 用來進行波浪線的繪製

```kotlin internal data class WaveAnim( val duration: Int, val offsetX: Float, val offsetY: Float, val scaleX: Float, val scaleY: Float, ) {

private val _path = Path()

//繪製波浪線
internal fun buildWavePath(
    dp: Float,
    width: Float,
    height: Float,
    amplitude: Float,
    progress: Float
): Path {

    var wave = (scaleY * amplitude).roundToInt() //計算拉伸之後的波幅

    _path.reset()
    _path.moveTo(0f, height)
    _path.lineTo(0f, height * (1 - progress))

    // 通過正弦曲線繪製波浪
    if (wave > 0) {
            var x = dp
            while (x < width) {
                _path.lineTo(
                    x,
                    height * (1 - progress) - wave / 2f * Math.sin(4.0 * Math.PI * x / width)
                        .toFloat()
                )
                x += dp
            }
    }

    _path.lineTo(width, height * (1 - progress))
    _path.lineTo(width, height)
    _path.close()
    return _path
}

} ```

如上,波浪線 Path 通過正弦函式繪製。

4.3 波浪填充

有了 Path ,我們還需要填充內容。填充的內容前文已經介紹過,或者是 DrawColor 或者 DrawImage。 繪製 Path 需要定義 Paint

kotlin val forePaint = remember(foreDrawType, bitmap) { Paint().apply { shader = BitmapShader( when (foreDrawType) { is DrawType.DrawColor -> bitmap.toColor(foreDrawType.color) is DrawType.DrawImage -> bitmap else -> alphaBitmap }, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP ) } }

Paint 使用 Shader 著色器繪製 Bitmap, 當 DrawType 只繪製單色時, 對點陣圖做單值處理: ```kotlin /* * 點陣圖單色化 / fun Bitmap.toColor(color: androidx.compose.ui.graphics.Color): Bitmap { val bmp = Bitmap.createBitmap( width, height, Bitmap.Config.ARGB_8888 ) val oldPx = IntArray(width * height) //用來儲存原圖每個畫素點的顏色資訊 getPixels(oldPx, 0, width, 0, 0, width, height) //獲取原圖中的畫素資訊

val newPx = oldPx.map {
    color.copy(Color.alpha(it) / 255f).toArgb()
}.toTypedArray().toIntArray()
bmp.setPixels(newPx, 0, width, 0, 0, width, height) //將處理後的畫素資訊賦給新圖
return bmp

} ```

4.4 波浪動畫

最後通過 Compose 動畫讓波浪動起來

```kotlin val transition = rememberInfiniteTransition()

val waves = remember(Unit) {
    listOf(
        WaveAnim(waveDuration, 0f, 0f, scaleX, scaleY),
        WaveAnim((waveDuration * 0.75f).roundToInt(), 0f, 0f, scaleX, scaleY),
        WaveAnim((waveDuration * 0.5f).roundToInt(), 0f, 0f, scaleX, scaleY)
    )
}

val animates :  List<State<Float>> = waves.map { transition.animateOf(duration = it.duration) }

`` 為了讓波浪更有層次感,我們定義三個WaveAnim` 以 Set 的形式做動畫

最後,配合 WaveAnim 將波浪的 Path 繪製到 Canvas 即可

```kotlin Canvas{

    drawIntoCanvas { canvas ->

        //繪製後景
        canvas.drawRect(0f, 0f, size.width, size.height, backPaint)


        //繪製前景
        waves.forEachIndexed { index, wave ->

            canvas.withSave {

                val maxWidth = 2 * scaleX * size.width / velocity.coerceAtLeast(0.1f)
                val maxHeight = scaleY * size.height

                canvas.drawPath (
                    wave.buildWavePath(
                        width = maxWidth,
                        height = maxHeight,
                        amplitude = size.height * amplitude,
                        progress = progress
                    ), forePaint
                )
            }

        }
    }
}

```

原始碼:https://github.com/vitaviva/ComposeWaveLoading