在这篇短文中, 我将引导你完成创建一个可重复使用的布局的过程, 该布局具有一个动画粘贴式title和一个可滚动的body. 对于从早期 Android 开发过渡过来的用户来说, 实现这种效果类似于使用CoordinatorLayout.
为此, 我们将使用 Compose 中的 NestedScrollConnection. 由于我更喜欢可重复使用代码的方法, 因此我会将其构建为一个独立的类. 这个类可以轻松地重复应用, 以适应各种动画标题和正文内容.
当然, 最后你会在 GitHub 代码库中找到完整的源代码;)
目录
- Density扩展
- Layout调用
- NestedScrollConnection a. progress 和 progressPx b. onPreScroll c. 正方向 d. 负方向
- 用例 -> 自动调整标题和分隔线的大小
- 源代码和最终想法
这就是我们要做的:
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 width和wrap 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!