Compose LazyRow 实现尾项露出与少量数据均分

0 阅读9分钟

在横向工具列表中,我们经常会遇到这样的需求:首屏完整显示几个工具,再露出下一个工具的一部分,提示用户还可以继续滑动。

比如有 10 项数据,首屏显示 6 项,第 7 项露出 30%。数据只有 3 项时,又希望可以选择保持内容宽度,或者让这 3 项均分整行。

10 项数据,完整显示 6 项并露出第 7 项约 30%

这里继续使用原生 LazyRow,通过两个 Modifier 分别获取列表可用宽度、约束 item 宽度。截图中的图标卡片请求了 width(70.dp),实际宽度仍由约束决定。露出比例针对整项区域,因此边缘可能只看到背景。

先计算尾项宽度

设可用宽度为 W,完整显示 N 项,下一项露出比例为 r,固定间距为 S,单项宽度为 P,那么:

W = N × P + N × S + r × P
P = (W - N × S) / (N + r)

这里容易漏掉一个间距:第 N 项和露出的第 N+1 项之间也要留出 S,因此需要扣除 N 个间距。

以 W=360dp、N=6、S=8dp、r=0.3 为例,每项约 49.52dp,下一项露出约 14.86dp。若页面左右各留白 16dp,W 应改成 328dp,计算时再统一转换为像素。

数据超量但 r=0 时,只完整显示 N 项,固定宽度改为:

C = (W - (N - 1) × S) / N

此时没有露出项前的间距,只扣除 N−1 个。

在测量时把宽度传给 item

公式有了,接下来需要知道 W。LazyRow 的 item 不能直接从自己的横向约束中得到整个列表的可用宽度,我们可以在外层先记录 constraints.maxWidth,再开始测量列表:

state.updateAvailableWidth(constraints.maxWidth, isLookingAhead)
val placeable = measurable.measure(constraints)

item 在自己的测量阶段读取同一个 State,再按公式约束内容。这样宽度在本轮测量中就能传递下去,不需要等 onSizeChanged 回调后重组。

宽度最终通过 Constraints 传给内容:需要固定宽度时,让最小值和最大值相等;保留自然宽度时,只收紧最大值。这样内容自己的测量仍然有效,文字是否省略、图标是否居中,可以继续由 item 决定,不需要让列表知道里面放的是什么组件。

这里记录的是父布局允许的最大宽度,不一定是列表最后占用的宽度。peekViewport 不负责撑满;调用方加上 fillMaxWidth() 才会撑满,去掉后少量内容可以收缩。

预测测量和实际测量分别保存宽度,避免相互覆盖。Modifier 最后返回内容实际测得的尺寸,用 placeRelative(0, 0) 摆放,不额外包一层容器。

只有少量数据时怎么处理

设实际数量为 M。默认 fillWhenSparse=false,当 M≤N 时只把单项最大宽度限制为前面的 C,内容较短就按自然宽度显示,剩余空间留空。

3 项数据,均分关闭

如果希望少量数据铺满,我们可以在 0<M<N 时开启均分,按实际数量计算:

F = (W - (M - 1) × S) / M

这时同时设置 minWidth 和 maxWidth,让每项固定为 F,因此可以超过原来的 C。

例如可用宽度 340px、间距 8px、目标完整显示 6 项,而实际只有 3 项:关闭均分时每项最多 50px,开启后每项固定为 108px。改变的是整个 item 的布局宽度,内容不会因此自动居中。

3 项数据,均分开启后等宽铺满

均分模式下,只有一项数据时会占满 W;空列表不创建 item,也不会除以零。恰好 M=N 时仍按自然宽度限制上限,均分开关不生效。

像素不能无限细分。固定宽度按累计边界取整,例如 100px 在无间距时分成 3 项,得到 33px、34px、33px,避免每项独立取整后误差累加。因此“等宽”允许相差 1px,露出比例也会有像素量化误差。

接入 LazyRow

每个列表创建自己的布局 State,列表和所有 item 使用同一轮返回的实例。滚动仍交给独立的 LazyListState:

@Composable
fun PeekTools(
    tools: List<String>,
    modifier: Modifier = Modifier,
    fillWhenSparse: Boolean = false,
) {
    val layoutState = rememberPeekRowLayoutState(
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    )
    val listState = rememberLazyListState()

    LazyRow(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .peekViewport(layoutState),
        state = listState,
        horizontalArrangement = layoutState.horizontalArrangement,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        itemsIndexed(tools) { index, label ->
            Text(
                text = label,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .peekItemWidth(
                        layoutState = layoutState,
                        index = index,
                        itemCount = tools.size,
                        visibleItemCount = 6,
                        peekFraction = 0.3f,
                        fillWhenSparse = fillWhenSparse,
                    )
                    .background(Color(0xFFE3EEFF))
                    .padding(horizontal = 4.dp, vertical = 12.dp),
            )
        }
    }
}

Text 可以换成业务组件,把 Modifier 放在 item 唯一根节点即可。itemsIndexed 的 key、contentType 和点击行为仍按原生方式处理。

这里要注意顺序:列表外侧的 width、padding 放在 peekViewport 前面;item 的 peekItemWidth 放在自身尺寸和 padding 前面。这样页面留白先被扣除,item 内边距则计入整项宽度。

horizontalArrangement 使用 State 中的同一个配置。间距由 LazyRow 执行,item 只读取它参与宽度计算。

数据没有被截断,继续滑动就能完整看到最后一项:

滑动到末尾,最后一个工具完整可见

“完整 N 项加下一项的一部分”只描述滚动起点;自由滑动时不会自动吸附,参数变化也不会自动归零。

实际运行时遇到的循环重组

最初用 rememberUpdatedState(horizontalArrangement) 保存排列配置,再通过布局 State 的 getter 读取。真机切换均分、将数据从 3 项变成 6 项时,出现 ComposeNotIdleException,持续有待处理重组。

这条配置读写链路需要调整:将 Arrangement 直接作为布局 State 的不可变属性,用它作为 remember 的 key:

return remember(horizontalArrangement) { PeekRowLayoutState(horizontalArrangement) }

配置相等时复用实例,变化时给列表和 item 同步换成新实例,下一轮测量重新写入宽度。独立的 LazyListState 保留,滚动状态不随之重建。这里修复的是具体的配置读写关系,不能据此推断 rememberUpdatedState 在其他场景也有问题。

修复后,原来的 8 项测试在 Samsung SM-G996U1(Android 14)上全部通过。自动布局测试固定了 LocalDensity=1,文中的真机截图补充原生密度效果。这一轮验证针对独立演示页,其他机型、业务页面和上面 Text 示例的独立 Preview 还需要单独验证。

使用时留意几处边界

父布局必须提供有限宽度,并为每项至少留下 1px;N 大于 0,r 在 [0, 1) 内,固定间距有限且非负。非法配置会直接报错。

精确露出时,水平 contentPadding 保持为零,留白放在外侧。所有 item 采用相同配置,传入整行索引和完整数量,不用 requiredWidth 绕过约束。

排列建议使用 Arrangement.spacedBy。SpaceBetween 等方式还会分配剩余空间,间隔不能仅由 spacing 表达。默认 reverseLayout=false 时,LTR 向右露出、RTL 向左露出;反向列表需要另外核对起点和排列。

完整代码

以下两份代码放在同一包即可,包名按项目补充。核心仅依赖 Compose Foundation、Runtime 和 UI;上面的使用示例另外用了 Material3 的 Text。

PeekRowLayoutState.kt

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp

/** 单个 LazyRow 的排列配置和可用宽度状态,由 [peekViewport] 写入宽度、[peekItemWidth] 读取。 */
@Stable
class PeekRowLayoutState internal constructor(
    /** 传给 LazyRow 的排列方式;宽度计算从同一配置读取间距。 */
    val horizontalArrangement: Arrangement.Horizontal,
) {
    /** 当前约束允许的最大宽度,并非列表最终宽度;尚未测量时为 null。 */
    var availableWidthPx: Int? by mutableStateOf(null)
        private set

    private var lookaheadAvailableWidthPx: Int? by mutableStateOf(null)

    internal fun updateAvailableWidth(widthPx: Int, isLookingAhead: Boolean) {
        // 预测与实际测量使用不同状态,避免目标宽度和当前宽度相互触发重新测量。
        if (isLookingAhead) {
            lookaheadAvailableWidthPx = widthPx
        } else {
            availableWidthPx = widthPx
        }
    }

    internal fun requireAvailableWidth(isLookingAhead: Boolean): Int = checkNotNull(
        if (isLookingAhead) lookaheadAvailableWidthPx else availableWidthPx,
    ) {
        "Attach Modifier.peekViewport with the same PeekRowLayoutState to the LazyRow"
    }
}

@Composable
fun rememberPeekRowLayoutState(
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
): PeekRowLayoutState {
    val spacing = horizontalArrangement.spacing
    require(spacing.value.isFinite() && spacing >= 0.dp) {
        "horizontalArrangement.spacing must be finite and non-negative"
    }
    // 配置变化时同步更新列表与 item 的状态,避免在 composition 中读写排列 State 触发循环重组。
    return remember(horizontalArrangement) { PeekRowLayoutState(horizontalArrangement) }
}

/**
 * 在测量 LazyRow 及其 item 之前记录有限的可用最大宽度,不改变传给列表的约束。
 *
 * 列表是否撑满由调用方决定,需要时在前面添加 fillMaxWidth。
 * 数据较少且未要求撑满时,列表最终宽度可小于记录的可用最大宽度。
 * 外侧 padding、width 等尺寸修饰符应放在本修饰符之前;水平 contentPadding 保持为零。
 * [state] 只能绑定一个 LazyRow。宽度在测量阶段传递,不使用 onSizeChanged 后再重组。
 */
fun Modifier.peekViewport(state: PeekRowLayoutState): Modifier = layout { measurable, constraints ->
    require(constraints.hasBoundedWidth) { "peekViewport requires a bounded width" }
    state.updateAvailableWidth(constraints.maxWidth, isLookingAhead)
    val placeable = measurable.measure(constraints)
    layout(placeable.width, placeable.height) {
        placeable.placeRelative(0, 0)
    }
}

PeekItemWidth.kt

import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.constrainWidth
import kotlin.math.floor
import kotlin.math.roundToLong

/**
 * 直接约束 LazyRow item 根节点的宽度,无需额外的 Box 容器。
 *
 * 应放在 item 根节点 Modifier 链的尺寸修饰符之前,使 padding 等计入整项宽度。
 * [index] 为整行数据的零基索引,用于分配取整像素;[itemCount] 为整行的完整数据数量。
 * 默认在数量不超过 [visibleItemCount] 时只限制最大宽度,超量时固定宽度;内容不用 requiredWidth 绕开约束。
 * [fillWhenSparse] 开启时,数量不足则改按实际数量等宽铺满,可超过原单项最大宽度。
 * 此开关不影响数量恰好等于或超过 [visibleItemCount] 的布局。
 * [peekFraction] 是下一项布局区域的可见比例,只在首项处于滚动起点且数据超量时生效。
 * 外层列表必须绑定使用同一 [layoutState] 的 [peekViewport] 及 horizontalArrangement。
 * 精确露出要求所有 item 采用一致配置,水平 contentPadding 为零;留白放在 peekViewport 之前。
 * 整数像素分配可能使 item 宽度相差 1px。滚动状态、方向、对齐等由原生 LazyRow 管理。
 * 本修饰符不裁切、不设置点击或对齐,保留 LazyItemScope 的原生 parent data 修饰符能力。
 */
fun Modifier.peekItemWidth(
    layoutState: PeekRowLayoutState,
    index: Int,
    itemCount: Int,
    visibleItemCount: Int,
    peekFraction: Float = 0.3f,
    fillWhenSparse: Boolean = false,
): Modifier {
    require(visibleItemCount > 0) { "visibleItemCount must be positive" }
    require(peekFraction.isFinite() && peekFraction >= 0f && peekFraction < 1f) {
        "peekFraction must be in [0, 1)"
    }
    require(itemCount > 0) { "itemCount must be positive" }
    require(index in 0 until itemCount) { "index must be within itemCount" }

    return layout { measurable, constraints ->
        // 仅在测量中读取;外层已在本轮测量开始时写入宽度,不依赖下一帧重组。
        val availableWidthPx = layoutState.requireAvailableWidth(isLookingAhead)
        val hasOverflow = itemCount > visibleItemCount
        val shouldFillSparseItems = fillWhenSparse && itemCount < visibleItemCount
        val hasFixedWidth = hasOverflow || shouldFillSparseItems
        val fullItemCount = if (shouldFillSparseItems) itemCount else visibleItemCount
        val hasPeek = hasOverflow && peekFraction > 0f
        val gapCount = if (hasPeek) fullItemCount else fullItemCount - 1
        val spacingPx = layoutState.horizontalArrangement.spacing.roundToPx()
        val itemSpacePx = availableWidthPx.toDouble() - gapCount.toDouble() * spacingPx
        val slots = fullItemCount.toDouble() + if (hasPeek) peekFraction.toDouble() else 0.0
        val itemWidthPx = itemSpacePx / slots
        require(itemWidthPx >= 1.0) { "Viewport is too narrow for visibleItemCount and horizontalArrangement.spacing" }

        val widthPx = if (hasFixedWidth) {
            // 按累计边界分配像素,避免每项独立取整后把最后一个完整项挤出视口。
            val startPx = (index * itemWidthPx).roundToLong()
            val endPx = ((index + 1.0) * itemWidthPx).roundToLong()
            (endPx - startPx).toInt()
        } else {
            floor(itemWidthPx).toInt()
        }
        val constrainedWidthPx = constraints.constrainWidth(widthPx)
        val itemConstraints = constraints.copy(
            minWidth = if (hasFixedWidth) constrainedWidthPx else constraints.minWidth,
            maxWidth = constrainedWidthPx,
        )
        val placeable = measurable.measure(itemConstraints)
        layout(placeable.width, placeable.height) {
            placeable.placeRelative(0, 0)
        }
    }
}