刚刚完毕的 2022 年,不少运用都给出了自己的 2022 年度报告。趁着这股热潮,我自己保护的运用《译站》 也来凑个热闹,用 Jetpack Compose 写了个报告页面。作用如下:

作用还算不错?假如需求实践体验的,能够前往 这儿 下载翻译后翻开底部最右侧 tab,即可现场看到。

制作进程

调查上图,需求完结的有三个难点:

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

下面将具体介绍

闪烁的数字

在我的上一篇文章 Jetpack Compose 十几行代码快速仿照立刻点赞数字切换作用 中,我基于 AnimatedContent 完结了 数字增加时主动做动画 的 Text,它的作用如下:

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

诶,已然如此,那完结这个数字跳动不就简略了吗?咱们只需求让数字主动从 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库房 / 此处点赞 / 谈论 ~