在这篇短文中, 我将引导你完成创建一个可重复使用的布局的过程, 该布局具有一个动画粘贴式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!