使用 Jetpack Compose 做一個年度報告頁面

語言: CN / TW / HK

剛剛結束的 2022 年,不少應用都給出了自己的 2022 年度報告。趁著這股熱潮,我自己維護的應用《譯站》 也來湊個熱鬧,用 Jetpack Compose 寫了個報告頁面。效果如下:

tutieshi_640x1422_17s.gif

效果還算不錯?如果需要實際體驗的,可以前往 這裡 下載翻譯後開啟底部最右側 tab,即可現場看到。

製作過程

觀察上圖,需要完成的有三個難點: - 閃動的數字 - 淡出 + 向上位移的微件們 - 有一部分微件不參與淡出(如 Spacer)

下面將詳細介紹

閃動的數字

在我的上一篇文章 Jetpack Compose 十幾行程式碼快速模仿即刻點贊數字切換效果 中,我基於 AnimatedContent 實現了 數字增加時自動做動畫 的 Text,它的效果如下:

誒,既然如此,那實現這個數字跳動不就簡單了嗎?我們只需要讓數字自動從 0 變成 目標數字,不就有了動畫的效果嗎?
此處我選擇 Animatable ,並且使用 LauchedEffect 讓數字自動開始遞增,並把數字格式化為 0013(長度為目標數字的長度)傳入到上次完成的微件中,這樣一個自動跳動的動畫就做好啦。
程式碼如下: ```kotlin @Composable fun AutoIncreaseAnimatedNumber( modifier: Modifier = Modifier, number: Int, durationMills: Int = 10000, textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp), textSize: TextUnit = 24.sp, textColor: Color = Color.Black, textWeight: FontWeight = FontWeight.Normal ) { // 動畫,Animatable 相關介紹可以見 https://compose.funnysaltyfish.fun/docs/design/animation/animatable?source=trans val animatedNumber = remember { androidx.compose.animation.core.Animatable(0f) } // 數字格式化後的長度 val l = remember { number.toString().length }

// Composable 進入 Composition 階段時開啟動畫
LaunchedEffect(number) {
    animatedNumber.animateTo(
        targetValue = number.toFloat(),
        animationSpec = tween(durationMillis = durationMills)
    )
}

NumberChangeAnimatedText(
    modifier = modifier,
    text = "%0${l}d".format(animatedNumber.value.roundToInt()),
    textPadding = textPadding,
    textColor = textColor,
    textSize = textSize,
    textWeight = textWeight
)

}

@OptIn(ExperimentalAnimationApi::class) @Composable fun NumberChangeAnimatedText( modifier: Modifier = Modifier, text: String, textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp), textSize: TextUnit = 24.sp, textColor: Color = Color.Black, textWeight: FontWeight = FontWeight.Normal, ) { Row(modifier = modifier) { text.forEach { AnimatedContent( targetState = it, transitionSpec = { slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up) } ) { char -> Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor, fontWeight = textWeight) } } } } ```

這樣就完成啦~

淡出 + 向上位移的微件們

實際上,這個標題的難點在於“們”這個字,這意味著不但要完成“向上+淡出”的效果,還要有序,一個一個來。
對於這個問題,因為我的需求很簡單:所有微件豎著排列,自上而下逐漸淡出。因此,我選擇的解決思路是:自定義佈局。(這不一定是唯一的思路,如果你有更好的方法,也歡迎一起探討)。下面我們慢慢拆解:

微件豎著放

這其實是最簡單的一步,你可以閱讀我曾經寫的 深入Jetpack Compose——佈局原理與自定義佈局(一) 來了解。簡單來說,我們只需要依次擺放所有微件,然後把總寬度設為寬度最大值,總高度設為高度之和即可。程式碼如下: ```kotlin @Composable fun AutoFadeInComposableColumn( modifier: Modifier = Modifier, content: @Composable FadeInColumnScope.() -> Unit ) { val measurePolicy = MeasurePolicy { measurables, constraints -> val placeables = measurables.map { measurable -> measurable.measure(constraints.copy(minHeight = 0, minWidth = 0)) }

    var y = 0
    // 寬度:父元件允許的最大寬度,高度:微件高之和
    layout(constraints.maxWidth, placeables.sumOf { it.height }) {
        // 依次擺放
        placeables.forEachIndexed { index, placeable ->
            placeable.placeRelativeWithLayer(0, y){
                alpha = 1
            }
            y += placeable.height
        }.also {
            // 重置高度
            y = 0
        }
    }
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)

} ```

上面的例子就是最簡單的自定義佈局了,它可以實現內部的 Composable 從上到下豎著排列。注意的是,在 place 的時候,我們使用了 placeRelativeWithLayer ,它可以調整元件的 alpha(還有 rotation/transform),這個未來會被用於實現淡出效果。

一個一個淡出

到了關鍵的一步了。我們不妨想一想,淡出就是 alpha 從 0->1,y 偏移從 offsetY -> 0 的過程,因此我們只需要在 place 時控制一下兩者的值就行。作為一個動畫過程,自然可以使用 Animatable。現在的問題是:需要幾個 Animatable 呢?
自然,你可以選擇使用 n 個 Animatable 分別控制 n 個微件,不過考慮到同一時刻其實只有一個 @Composable 在做動畫,因此我選擇只用一個。因此我們需要增加一些變數: - currentFadeIndex 記錄當前是哪個微件在播放動畫 - finishedFadeIndex 記錄播放完成的最後一個微件的 index,用於檢查動畫是否結束了

實話說這兩個變數或許可以合成一個,不過既然寫成了兩個,那就先這樣寫下去吧。
兩個狀態可以只放到 Layout 裡面,也可以放到專門的 State 中,考慮到外部可能要用到(嘿嘿,其實是真的要用到)兩個值,我們單獨寫一個 State 吧 ```kotlin class AutoFadeInColumnState { var currentFadeIndex by mutableStateOf(-1) var finishedFadeIndex by mutableStateOf(0)

companion object {
    val Saver = listSaver<AutoFadeInColumnState, Int>(
        save = { listOf(it.currentFadeIndex, it.finishedFadeIndex) },
        restore = {
            AutoFadeInColumnState().apply {
                currentFadeIndex = it[0]; finishedFadeIndex = it[1]
            }
        }
    )
}

}

@Composable fun rememberAutoFadeInColumnState(): AutoFadeInColumnState { return rememberSaveable(saver = AutoFadeInColumnState.Saver) { AutoFadeInColumnState() } } ```

接下來,為我們的自定義 Composable 新增幾個引數吧 @Composable fun AutoFadeInComposableColumn( modifier: Modifier = Modifier, state: AutoFadeInColumnState = rememberAutoFadeInColumnState(), fadeInTime: Int = 1000, // 單個微件動畫的時間 fadeOffsetY: Int = 100, // 單個微件動畫的偏移量 content: @Composable FadeInColumnScope.() -> Unit )

接下來就是關鍵,修改 place 的程式碼完成動畫效果。 kotlin // ... placeables.forEachIndexed { index, placeable -> // @1 實際的 y,對於動畫中的微件減去偏移量,對於未動畫的微件不變 val actualY = if (state.currentFadeIndex == index) { y + (( 1 - fadeInAnimatable.value) * fadeOffsetY).toInt() } else { y } placeable.placeRelativeWithLayer(0, actualY){ // @2 alpha = if (index == state.currentFadeIndex) fadeInAnimatable.value else if (index <= state.finishedFadeIndex) 1f else 0f } y += placeable.height }.also { y = 0 } 相較於之前,程式碼有兩處主要更改。@1 處更改微件的 y,對於動畫中的微件減去偏移量,對於未動畫的微件不變,以實現 “位移” 的效果; @2 處則設定 alpha 值實現淡出效果,具體邏輯如下: - 如果是正在動畫的那個,alpha 就是當前動畫的值,實現漸漸淡出的效果 - 否則,對於已經執行完動畫的,alpha 正常為 1;否則為 0(還沒輪到它們顯示)

接下來,問題在於執行完一個如何執行下一個了。我的思路是這樣的:新增一個 LauchedState(state.currentFadeIndex) 使得在 currentFadeIndex 變化時(這表示當前執行動畫的微件變了)重新把 Animatable 置0,開啟動畫效果。動畫完成後又把 currentFadeIndex 加一,直至完成所有。程式碼如下: ```kotlin @Composable fun xxx(...){ LaunchedEffect(state.currentFadeIndex){ if (state.currentFadeIndex == -1) { // 找到第一個需要漸入的元素 state.currentFadeIndex = 0 } // 開始動畫 fadeInAnimatable.animateTo( targetValue = 1f, animationSpec = tween( durationMillis = fadeInTime, easing = LinearEasing ) ) // 動畫播放完了,更新 finishedFadeIndex state.finishedFadeIndex = state.currentFadeIndex // 全部動畫完了,退出 if(state.finishedFadeIndex >= whetherFadeIn.size - 1) return@LaunchedEffect

    state.currentFadeIndex += 1
    fadeInAnimatable.snapTo(0f) // snapTo(0f) 無動畫直接置0 
}

} ```

到這裡,一個 內部子微件依次淡出 的自定義佈局已經基本完成了。下面問題來了:在 Compose 中,我們使用 Spacer 建立間隔,但是往往 Spacer 是不需要動畫的。因此我們需要支援一個特性:允許設定某些 Composable 不做動畫,也就是直接跳過它們。這種子微件告訴父微件資訊的時期,當然要交給 ParentData 來做

允許部分 Composable 不做動畫

要了解 ParentData,您可以參考我的文章 深入Jetpack Compose——佈局原理與自定義佈局(四)ParentData,此處不再贅述。
我們新增一個 class FadeInColumnData(val fade: Boolean = true) 和 對應的 Modifier,用於指定某些 Composable 跳過動畫。考慮到這個特定的 Modifier 只能用在我們這個佈局,因此需要加上 scope 的限制。這些程式碼如下: ```kotlin class FadeInColumnData(val fade: Boolean = true) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any = this@FadeInColumnData }

interface FadeInColumnScope { @Stable fun Modifier.fadeIn(whetherFadeIn: Boolean = true): Modifier }

object FadeInColumnScopeInstance : FadeInColumnScope { override fun Modifier.fadeIn(whetherFadeIn: Boolean): Modifier = this.then(FadeInColumnData(whetherFadeIn)) } ```

有了這個,我們上面的佈局也得做相應的更改,具體來說: - 需要增加一個列表 whetherFadeIn 記錄 ParentData 提供的值 - 開始的動畫 index 不再是 0 ,而是找到的第一個需要做動畫的元素 - currentFadeIndex 的更新需要找到下一個需要做動畫的值

具體程式碼如下:

``` @Composable fun AutoFadeInComposableColumn() { var whetherFadeIn: List = arrayListOf() // ...

LaunchedEffect(state.currentFadeIndex){
    // 等待初始化完成
    while (whetherFadeIn.isEmpty()){ delay(50) }
    if (state.currentFadeIndex == -1) {
        // 找到第一個需要漸入的元素
        state.currentFadeIndex = whetherFadeIn.indexOf(true)
    }
    // 開始動畫
    //  - state.currentFadeIndex = 0
    for (i in state.finishedFadeIndex + 1 until whetherFadeIn.size){
        if (whetherFadeIn[i]){
            state.currentFadeIndex = i
            fadeInAnimatable.snapTo(0f)
            break
        }
    }
}

val measurePolicy = MeasurePolicy { measurables, constraints ->
    // ...
    whetherFadeIn = placeables.map { placeable ->
        ((placeable.parentData as? FadeInColumnData) ?: FadeInColumnData()).fade
    }

    // 寬度:父元件允許的最大寬度,高度:微件高之和
    layout(constraints.maxWidth, placeables.sumOf { it.height }) {
        // ...
    }
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)

} ```

完成啦!

一點小問題

事實上,整個佈局的大體到目前已經趨於完成,不過目前有點小問題:對於 AutoIncreaseAnimatedNumber ,它的動畫執行時機是錯誤的。你可以想象:儘管數字沒有顯示出來(alpha 為 0),但實際上它已經被擺放了,因此數字跳動的動畫已經開始了。對於這個問題,我的解決方案是為 AutoIncreaseAnimatedNumber 額外新增一個 Boolean 引數 startAnim,只有該值為 true 時才真正開始執行動畫。

那麼 startAnim 什麼時候為 true 呢?就是 currentFadeIndex == 這個微件的 Index 時,這樣就可以手工指定什麼時候開始動畫了。
程式碼如下: ``` @Composable fun AutoIncreaseAnimatedNumber( startAnim: Boolean = true, ... ) { // Composable 進入 Composition 階段,且 startAnim 為 true 時開啟動畫 LaunchedEffect(number, startAnim) { if (startAnim) animatedNumber.animateTo( targetValue = number.toFloat(), animationSpec = tween(durationMillis = durationMills) ) }

NumberChangeAnimatedText(
    ...
)

} ```

實際使用時 kotlin Row(verticalAlignment = Alignment.CenterVertically) { AnimatedNumber(number = 110, startAnim = state.currentFadeIndex == 7) // 或者 >=,如果動畫時間長於 fadeInTime 的話 ResultText(text = "次") }

完工!

Pager?

如你所想,整體的佈局是用 Pager 實現的,這個用的是 google/accompanist: A collection of extension libraries for Jetpack Compose 內的實現。鑑於不是本篇重點,此處略過,感興趣的可以看下面的程式碼。

程式碼

完整程式碼見 FunnyTranslation/AnnualReportScreen.kt at compose

如果有用,歡迎 Star倉庫 / 此處點贊 / 評論 ~