Jetpack Compose粘性标题的一种可行实现

在这篇短文中, 我将引导你完成创建一个可重复使用的布局的过程, 该布局具有一个动画粘贴式title和一个可滚动的body. 对于从早期 Android 开发过渡过来的用户来说, 实现这种效果类似于使用CoordinatorLayout.

为此, 我们将使用 Compose 中的 NestedScrollConnection. 由于我更喜欢可重复使用代码的方法, 因此我会将其构建为一个独立的类. 这个类可以轻松地重复应用, 以适应各种动画标题和正文内容.

当然, 最后你会在 GitHub 代码库中找到完整的源代码;)

目录

  1. Density扩展
  2. Layout调用
  3. NestedScrollConnection a. progress 和 progressPx b. onPreScroll c. 正方向 d. 负方向
  4. 用例 -> 自动调整标题和分隔线的大小
  5. 源代码和最终想法

这就是我们要做的:

1. Density扩展

作为热身, 我们需要一些扩展来将 dp 转换为 px, 将 px 转换为 dp. 这很容易实现:

kotlin 复制代码
object ExtensionDensity {

    inline val Dp.toPx @Composable get() = this.toPx(LocalDensity.current.density)
    fun Dp.toPx(density: Float) = value * density

    val Int.toDp @Composable get() = this.toDp(LocalDensity.current.density)
    fun Int.toDp(density: Float) = Dp((this / density))

    val Float.toDp @Composable get() = this.toDp(LocalDensity.current.density)
    fun Float.toDp(density: Float) = Dp((this / density))

}

// Now it can be easily used:

// - inside a @Composable scope
@Composable
fun anyComposableScope(
    pxSizeInt: Int,
    pxSizeFloat: Float,
    dpSize: Dp,
) {
     log.d("TAG", "pxSizeInt to dp: ${pxSizeInt.toDp}")
     log.d("TAG", "pxSizeFloat to dp: ${pxSizeFloat.toDp}")

     log.d("TAG", "dpSize to px: ${pxSizeFloat.toPx}")
}

// - inside any scope (need to supply the density)
fun anyNotComposableScope(
    density: Float,
    pxSizeInt: Int,
    pxSizeFloat: Float,
    dpSize: Dp,
) {
     log.d("TAG", "pxSizeInt to dp: ${pxSizeInt.toDp(density)}")
     log.d("TAG", "pxSizeFloat to dp: ${pxSizeFloat.toDp(density)}")

     log.d("TAG", "dpSize to px: ${pxSizeFloat.toPx(density)}")
}

你会注意到, 我经常将函数, 扩展等封装在一个命名的对象中. 我发现这种方式有利于以命名空间的形式组织代码. 虽然 Kotlin 本身没有命名空间, 但使用对象也能达到类似的效果.

2. Layout调用

这部分将展示我们需要调用的函数, 以便使用我们的动画粘贴头布局:

kotlin 复制代码
object ColumnCollapsibleHeader {

    data class Properties(
        val min: Dp = 0.dp,  // Min header size
        val max: Dp = Dp.Infinity, // Max header size
    )

    @Composable
    private fun rememberCollapsibleHeaderState(
        maxPxToConsume: Float,
        initialProgress: Float = 1f,
        initialScroll: Int = 0,
    ) = remember {

        // This class will be dissected in the following section.
        CollapsibleHeaderState(maxPxToConsume, initialProgress, initialScroll)
    }

    @Composable
    operator fun invoke(
        modifier: Modifier = Modifier,
        properties: Properties,

        // header will be inside a BoxScope
        header: @Composable BoxScope.(progress: Float, progressPx: Dp) -> Unit,
        
        // body will ne inside a ColumnScope and vertically scrollable
        body: @Composable ColumnScope.() -> Unit,

    ) {
        val density = LocalDensity.current.density
        val sizePx = remember {
            (properties.max - properties.min).toPx(density)
        }

        val collapsibleHeaderState = rememberCollapsibleHeaderState(sizePx) 

        Column(
            modifier = modifier
        ) {

            Box(
                modifier = Modifier.fillMaxWidth()
            ) {
                header(
                    // we give to the header the progress (0-1.0f)
                    collapsibleHeaderState.progress,
                    // we give also to the header the progress in dp
                    properties.min + collapsibleHeaderState.progressPx.toDp(density)
                )
            }

            Column(
                Modifier
                    .fillMaxSize()
 
                    // here we intercept the scroll and do our math 
                    // to consummed it or not
                    .nestedScroll(collapsibleHeaderState)
                    .verticalScroll(collapsibleHeaderState.scrollState)
            ) {
                body()
            }
        }

    }
}

当我们要使用我们的布局时, 我们需要提供以下属性:

  • 最小标题高度
  • 最大标题高度

我们还提供了Composable内容:

  • 标题内容将是full widthwrap height. 为了方便标题内容动画的制作, 函数将以单位归一化进度(0 到 1)的形式接收进度, 并直接以 dp 为单位接收进度. 这些值可以直接用于标题内容的任何Modifier中.
  • 正文内容将full width并占据剩余的全部高度. 此外, 它还将嵌入垂直滚动布局中.

3. NestedScrollConnection

Compose 中的 NestedScrollConnection 可以让你在滚动被其他人使用之前将其拦截. 更妙的是, 你可以在滚动传送给下一个接收者之前, 部分或全部消耗掉滚动.

明白了吧! 这就为我们创建一个粘性动画标题打开了大门, 它可以消耗滚动来实现各种动画目的, 如展开或缩小等. 这种交互方式与可滚动的正文内容无缝整合, 确保了完美流畅的用户体验. 让我们深入了解并开始使用!

a. progress和progressPx

kotlin 复制代码
private class CollapsibleHeaderState(
    private val maxPxToConsume: Float, // Maximum px we allow to be consumed
    initialProgress: Float,
    initialScroll: Int,
) : NestedScrollConnection {

        val scrollState = ScrollState(initialScroll) // Compose scrollState

        // mutable state to update the view
        // you can't see it now, but we are inside a remember
        private val _progress = mutableStateOf(initialProgress)
 
        var progress: Float // progress 0-1
            get() = _progress.value
            set(value) {
                pxConsumed = maxPxToConsume * (1f - value)
            }

        val progressPx get() = maxPxToConsume * progress // progress in px

        // remember pxConsumed
        private var pxConsumed: Float = maxPxToConsume * (1f - initialProgress) // initial value
            set(value) {
                field = value
                
                //update progress 0-1 at each set
                _progress.value = (1f - value / maxPxToConsume)
            }

....

}
  • "scrollState"是我们需要提供给"verticalScroll" Modifier的 Compose 状态. 这个变量会告诉我们实际的滚动位置.
  • "progress"和"progressPx"将作为公共输出变量, 供标题动画使用.
  • "pxConsumed"表示我们消耗的实际像素值, 而不是转发给垂直滚动视图(我们的正文)的像素值.

b. OnPreScroll

onPreScroll 是 NestedScrollConnection 的一个可覆盖方法. 它为我们提供了需要被某个人占用的可用滚动位置. 我们将返回已决定消耗的偏移值, 如果我们什么都不消耗, 则偏移值为零. 所有剩余的未消耗值将被分派给下一个注册的消费者.

kotlin 复制代码
private class CollapsibleHeaderState(
    private val maxPxToConsume: Float, // Maximum px we allow to be consumed
    initialProgress: Float,
    initialScroll: Int,
) : NestedScrollConnection {

....

        // convenient method to compute the scroll direction
        private fun isDirectionPositive(value: Float) = value.sign < 0f

        // here where we will intercept the scroll
        // we receive the available px scrolled by user 
        // and we return the amount we have consummed
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            return if (isDirectionPositive(available.y)) {
                onDirectionPositive(available)
            } else {
                onDirectionNegative(available)
            }
        }

....

}

在这个函数中, 我只需检查滚动的方向, 并调用另一个具有描述性名称的函数, 以提高可读性.

c. 正方向

正方向表示滚动显示底部内容, 这意味着我们正在向屏幕顶部滚动. 因此, 可用的 y 轴为负值. 诚然, 这种逻辑可能有点令人费解!

kotlin 复制代码
private fun onDirectionPositive(available: Offset): Offset {
    if (progress <= 0f) {
        return Offset.Zero 
    }
    val allowedToBeConsumed = maxPxToConsume - pxConsumed
    val notConsumed = (abs(available.y) - allowedToBeConsumed)
    if (notConsumed <= 0f) {
        pxConsumed -= available.y
        return available
    }
    pxConsumed = maxPxToConsume.toFloat()
    return Offset(0f, -allowedToBeConsumed)
}

如果进度已经为 0, 表示最小值, 我们就不会消耗更多.

否则, 我们将消耗允许消耗的所有值(属性中的 maxValue 减 minValue]). pxConsumed 会一直更新, 并自动更新进度.

我不会在这里讨论 minHeight 和 maxHeight. 它们隐含在 maxPxToConsume 中. 由于此 CollapsibleHeaderState 可用于任何用途(仅沿垂直轴), 而且在此上下文中我根本没有注意到标题, 也没有利用它来制作动画或限制此标题的高度.

d. 负方向

最后一部分涉及负方向, 表示我们正在向设备底部滚动, 从顶部显示更多内容. 这次可用的 y 轴为正方向.

kotlin 复制代码
private fun onDirectionNegative(available: Offset): Offset {
    if (progress >= 1f) {
        return Offset.Zero
    }
    val availableToBeConsumed = available.y - scrollState.value
    if (availableToBeConsumed <= 0f) {
        return Offset.Zero
    }
    val allowedToBeConsumed = pxConsumed
    val notConsumed = availableToBeConsumed - allowedToBeConsumed
    if (notConsumed <= 0) {
        pxConsumed -= availableToBeConsumed
        return available
    }
    pxConsumed = 0f
    return Offset(0f, allowedToBeConsumed)

}

如果进度已经为 1, 表示最大值, 我们就不会消耗更多内容.

我们等待可用的 y 值超过当前的 scrollstate 值, 这表示我们已经进入可以开始消耗的区域.

之后的计算与正方向相同. 我们消耗我们能消耗的, 但绝不会超过可用的. 只是需要注意有符号的数字.

我明白, 这并不容易理解. 为了更好地理解, 我记录了一些数字, 以便准确理解.

4. 用例 -> 自动调整标题和分隔线的大小

现在, 我将演示如何在任何屏幕上使用它. 在动画部分, 我将保持简单, 只有一个自动调整大小的标题和一个以最小尺寸显示的水平分隔线.

ini 复制代码
ColumnCollapsibleHeader(
    modifier = Modifier.fillMaxSize(),
    properties = ColumnCollapsibleHeader.Properties(
        min = 40.dp,
        max = 120.dp
    ),
    header = { progress, progressDp ->
        Header(progress = progress, progressDp = progressDp)
    },
    body = {
        (1..20).forEach {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(60.dp)
                    .padding(vertical = 2.dp)
                    .background(generateRandomColor())
            )
        }
    }
)

body将只是一些随机颜色的矩形. 至于标题, 它将与下面相似:

ini 复制代码
private const val DIVIDER_HEADER_VISIBILITY_START = 0.3f

@Composable
fun BoxScope.Header(
    progress: Float,
    progressDp: Dp
) {
    Column(
        modifier = Modifier.height(progressDp), // Here the height ajustement
        verticalArrangement = Arrangement.Bottom
    ) {
        Text(
            modifier = Modifier
                .padding(
                    // Here padding of the title
                    // - small height header -> small padding 8dp
                    // - big height header -> big padding 32.dp
                    // title will move horizontally 
                    start = 8.dp + (24.dp * progress),
                    end = 8.dp,
                    top = 4.dp,
                    bottom = 4.dp
                ),
            text = "Header Title",
            style = MaterialTheme.typography.headlineLarge,
            // Here the resizing from 16sp to 54sp
            fontSize = (16 + ((54 - 16) * progress)).sp
        )
        Divider(
            modifier = Modifier
                .fillMaxWidth()
                .alpha(
                    // Here the appearance, disappearance of the divider
                    if(progress >= DIVIDER_HEADER_VISIBILITY_START) {
                        0.0f
                    }
                    else {
                        (DIVIDER_HEADER_VISIBILITY_START - progress) / DIVIDER_HEADER_VISIBILITY_START
                    }
                ),
            thickness = 4.dp,
            color = Color.Black
        )

    }
}

正如你所看到的, 使用这种布局非常简单, 只要有一点想象力, 你就可以制作任何动画. 你甚至不需要限制标题的高度. 只需利用 progress/progressDp 就可以制作任何你想要的动画!

5. 5. 源代码和最终想法

没有最后的想法, 只需在这个源代码库中找到源代码即可:

tezov.medium.adr.collpsible_header_layout

Happy Coding! Stay GOLDEN!

相关推荐
miao_zz1 小时前
基于react native的锚点
android·react native·react.js
安卓美女1 小时前
Android自定义View性能优化
android·性能优化
Dingdangr2 小时前
Android中的四大组件
android
mg6683 小时前
安卓玩机工具-----无需root权限 卸载 禁用 删除当前机型app应用 ADB玩机工具
android
安卓机器4 小时前
Android架构组件:MVVM模式的实战应用与数据绑定技巧
android·架构
码龄3年 审核中4 小时前
MySQL record 05 part
android·数据库·mysql
服装学院的IT男4 小时前
【Android 13源码分析】WindowContainer窗口层级-1-初识窗口层级树
android
技术无疆5 小时前
Hutool:Java开发者的瑞士军刀
android·java·开发语言·ide·spring boot·gradle·intellij idea
qluka9 小时前
Android 应用安装-提交阶段
android
ansondroider9 小时前
Android MediaPlayer + GLSurfaceView 播放视频
android·opengl·mediaplayer·glsurfaceview