使用 Jetpack Compose 做一个年度报告页面

3,548 阅读7分钟

刚刚结束的 2022 年,不少应用都给出了自己的 2022 年度报告。趁着这股热潮,我自己维护的应用《译站》 也来凑个热闹,用 Jetpack Compose 写了个报告页面。效果如下:

tutieshi_640x1422_17s.gif

效果还算不错?如果需要实际体验的,可以前往 这里 下载翻译后打开底部最右侧 tab,即可现场看到。

制作过程

观察上图,需要完成的有三个难点:

  • 闪动的数字
  • 淡出 + 向上位移的微件们
  • 有一部分微件不参与淡出(如 Spacer)

下面将详细介绍

闪动的数字

在我的上一篇文章 Jetpack Compose 十几行代码快速模仿即刻点赞数字切换效果 中,我基于 AnimatedContent 实现了 数字增加时自动做动画 的 Text,它的效果如下:

诶,既然如此,那实现这个数字跳动不就简单了吗?我们只需要让数字自动从 0 变成 目标数字,不就有了动画的效果吗?
此处我选择 Animatable ,并且使用 LauchedEffect 让数字自动开始递增,并把数字格式化为 0013(长度为目标数字的长度)传入到上次完成的微件中,这样一个自动跳动的动画就做好啦。
代码如下:

@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——布局原理与自定义布局(一) 来了解。简单来说,我们只需要依次摆放所有微件,然后把总宽度设为宽度最大值,总高度设为高度之和即可。代码如下:

@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

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 的代码完成动画效果。

// ...
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 加一,直至完成所有。代码如下:

@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 的限制。这些代码如下:

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<Boolean> = 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(
        ...
    )
}

实际使用时

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仓库 / 此处点赞 / 评论 ~