在横向工具列表中,我们经常会遇到这样的需求:首屏完整显示几个工具,再露出下一个工具的一部分,提示用户还可以继续滑动。
比如有 10 项数据,首屏显示 6 项,第 7 项露出 30%。数据只有 3 项时,又希望可以选择保持内容宽度,或者让这 3 项均分整行。
这里继续使用原生 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,内容较短就按自然宽度显示,剩余空间留空。
如果希望少量数据铺满,我们可以在 0<M<N 时开启均分,按实际数量计算:
F = (W - (M - 1) × S) / M
这时同时设置 minWidth 和 maxWidth,让每项固定为 F,因此可以超过原来的 C。
例如可用宽度 340px、间距 8px、目标完整显示 6 项,而实际只有 3 项:关闭均分时每项最多 50px,开启后每项固定为 108px。改变的是整个 item 的布局宽度,内容不会因此自动居中。
均分模式下,只有一项数据时会占满 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)
}
}
}