在 ChatGPT 这类对话页里,一次回答通常不是作为完整文本一次性插入列表,而是同一条回答内容持续追加、同一个列表项不断变高。
本文的核心目标是:当列表处于自动贴底状态时,最后一项每次内容更新、重组、高度变化,都能自然跟随到底部;当用户进入自由滑动状态时,最后一项继续更新也不应该把当前位置重新拉回底部。
这篇文章记录这个目标下的迭代过程:先从正向视觉的自动滚动开始,再尝试 LazyColumn 反向视觉,然后用 snapshotFlow + scrollBy 做布局后的补偿,最后演进到一个基于 LazyLayout 的双锚点列表 AnchorLazyColumn。
背景
普通列表通常以"顶部"为视觉起点:新数据插入后,只要保持当前首个可见项不变,用户就不会感觉跳动。但 ChatGPT 类流式回答列表的关键点在最后一项:
- 内容不足一屏时,从顶部自然摆放。
- 内容超过一屏后,默认贴住底部。
- 自动贴底状态下,最后一项重组变高时,viewport 同步跟随底部,不出现帧间空隙、追赶感或跳动。
- 用户向上滚动后,当前位置被锁定;底部继续追加也不抢用户视角。
- 用户滚回底部后,列表恢复自动跟随。
这类需求很容易被误解成"每次数据变化都滚到底部"。但真正要解决的是高度变化和滚动跟随的时序:最后一项先变高、下一帧再滚动,视觉上就会像跳动;高度变化和滚动不在同一帧完成,也会留下追赶感。
四个方案概览
这次迭代可以拆成四个方案:
- 正向视觉 +
rememberAutoScrollListState:最后一项更新后再触发滚到底部。 LazyColumn反向视觉:使用reverseLayout,让最后一项的高度变化更自然地发生在视觉底部。snapshotFlow + scrollBy:继续使用正向视觉,在布局完成后根据最后一项尺寸补偿滚动。AnchorLazyColumn:放弃布局后的补偿,把底部锚定和位置锁定放进LazyLayout的测量阶段。
前三个方案都建立在 LazyColumn 之上,区别只是滚动策略和视觉方向;第四个方案开始把"锚点"视为列表自身的布局契约。
阶段 1:正向视觉 + rememberAutoScrollListState
最直接的实现是保持数据自然顺序,列表正向摆放,然后在数据变化时滚到底部:
kotlin
val listState = rememberAutoScrollListState(
trigger = outputs.size to isStreaming,
)
LazyColumn(state = listState) {
items(outputs.size, key = { "output-$it" }) { index ->
OutputItem(outputs[index])
}
if (streamingText.isNotEmpty()) {
item(key = "streaming-output") {
OutputItem(streamingText)
}
}
}
rememberAutoScrollListState() 的实现把"是否自动滚动"封装到 LazyListState 外层:回到底部后恢复自动滚动,触发器变化时再调用 scrollToBottom()。
kotlin
@Composable
fun rememberAutoScrollListState(
listState: LazyListState = rememberLazyListState(),
enabled: Boolean = true,
trigger: Any?,
): LazyListState {
var autoScroll = enabled
val isAtBottom by remember {
derivedStateOf { listState.isFullyScrolledToBottom() }
}
LaunchedEffect(isAtBottom) {
if (enabled && isAtBottom) {
autoScroll = true
}
}
LaunchedEffect(trigger) {
if (autoScroll) {
listState.scrollToBottom()
}
}
return listState
}
scrollToBottom() 根据当前 layoutInfo 判断最后一项是否可见。如果最后一项不可见,直接滚到最后一项;如果最后一项已经可见但底部还有溢出,就滚动到对应偏移。
kotlin
suspend fun LazyListState.scrollToBottom() {
val lastIdx = layoutInfo.totalItemsCount - 1
if (lastIdx < 0) return
val delta = calculateBottomScrollDelta(bottomScrollSnapshot())
if (delta == null) {
animateScrollToItem(lastIdx, Int.MAX_VALUE)
return
}
if (delta > 0) {
animateScrollToItem(lastIdx, delta)
}
}
这个方案胜在简单,但它的问题也很直接:最后一项先重组并产生新的高度,列表随后再滚到底部。高度变化和滚动不是一个连续的布局结果,视觉上会出现"先顶开,再拉回"的跳动感。
它适合一次性新增完整 item,不适合最后一项在流式生成过程中频繁重组、连续变高的场景。

阶段 2:LazyColumn 反向视觉
第二个方案是使用 reverseLayout = true,让 index 0 出现在视觉底部:
kotlin
LazyColumn(
state = listState,
reverseLayout = true,
) {
if (streamingText.isNotEmpty()) {
item(key = "streaming-output") {
OutputItem(streamingText)
}
}
for (offset in outputs.indices) {
val sourceIndex = outputs.lastIndex - offset
item(key = "output-$sourceIndex") {
OutputItem(outputs[sourceIndex])
}
}
}
它的好处是最后一项重组、高度变化和视觉底部的关系更自然,流式增长时比正向视觉后置滚动更顺。但它仍然有一个关键问题:当用户处于自由滑动状态时,只要最后一项仍然在可见区域内,最后一项继续重组变高,列表仍可能被新的高度变化向下带动。
也就是说,反向视觉缓解了自动贴底时的动画问题,但没有很好地区分"自动贴底"和"自由滑动"两个状态。

阶段 3:snapshotFlow + scrollBy
第三个方案回到正向视觉,但不再只依赖"数据变化后滚到底部",而是监听最后一项高度变化。大致思路是:
kotlin
LazyColumn(
state = listState,
modifier = Modifier
.stickToBottom(
listState = listState,
enabled = followLatest,
)
.autoScrollAware(
onUserInteracted = { followLatest = false },
onUserInteractionEnd = {
followLatest = listState.isFullyScrolledToBottom()
},
),
) {
items(outputs.size, key = { outputs[it].id }) { index ->
OutputItem(outputs[index])
}
}
autoScrollAware() 负责识别用户是否进入自由滑动状态。手指按下时关闭自动贴底;手指松开后,再根据当前位置是否已经到底部决定是否恢复自动贴底。
kotlin
fun Modifier.autoScrollAware(
enabled: Boolean = true,
onUserInteracted: () -> Unit,
onUserInteractionEnd: () -> Unit
): Modifier = if (!enabled) this else pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.changes.any { it.pressed }) {
onUserInteracted()
}
val allReleased = event.changes.all { !it.pressed }
if (allReleased) {
onUserInteractionEnd()
}
}
}
}
stickToBottom() 在布局提交后监听最后一个可见项高度变化,再计算最后一项是否溢出底部。如果溢出,就通过 scrollBy() 补偿这一次高度变化带来的底部差值。
kotlin
fun Modifier.stickToBottom(
listState: LazyListState,
enabled: Boolean = true
): Modifier = composed {
if (enabled) {
LaunchedEffect(listState) {
var lastTrackedSize by mutableIntStateOf(0)
snapshotFlow {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()
if (lastVisible != null && lastVisible.index == info.totalItemsCount - 1) {
lastVisible.size
} else {
lastTrackedSize
}
}.collect { currentSize ->
if (currentSize == lastTrackedSize) return@collect
val overflow = with(listState.layoutInfo) {
val last = visibleItemsInfo.lastOrNull() ?: return@collect
if (last.index != totalItemsCount - 1) return@collect
last.offset + last.size - viewportEndOffset
}
if (overflow > 0) {
listState.scrollBy(overflow.toFloat())
}
lastTrackedSize = currentSize
}
}
}
this
}
这个方案的好处是改动小,也能覆盖一部分"最后一项变高后保持底部"的场景。流式内容区域可以配合 animateContentSize(),让高度变化本身不再显得突兀;再通过 snapshotFlow + scrollBy() 在布局完成后修正底部位置。
它解决了方案一明显的跳动问题,但仍然有时序缺口:最后一项重组后的高度变化先完成,snapshotFlow 再读到新的 layoutInfo 并滚动。高度变化和滑动到底部不是同一帧内的同一个布局结果,所以在连续追加时仍然会有轻微的追赶感。

缺陷不足
这个方案的问题不在于某一个 API 用错,而是补偿时机天然偏晚。
LazyColumn 已经完成一次测量与布局后,snapshotFlow 才能读到新的 layoutInfo。此时如果最后一项在流式输出中持续变高,列表会经历"内容高度先变化,再滚动修正到底部"的过程。修正足够快时肉眼不明显,但在连续追加、动画、输入法变化或低帧率场景里,就容易出现底部短暂脱锚或滚动追赶感。
第二个问题是"跟随底部"和"锁定当前位置"不是简单的布尔开关。用户第一次拖动时,列表需要从底部跟随切到位置锁定;当用户滚回底部,又要恢复底部跟随。这个转换如果交给外部业务状态维护,调用方就很容易在每次数据变化时反复调用 scrollToBottom(),最终把用户刚刚翻到的历史位置抢走。
第三个问题是惯性滚动。自定义拖动如果只处理 pointer delta,松手后的 fling 不会自然延续;如果使用标准滚动容器,又很难在测量阶段实现真正的底部锚定。
所以新方案不再把"底部跟随"当成布局后的补偿动作,而是把它做成列表本身的测量契约。
新方案:双锚点 AnchorLazyColumn
AnchorLazyColumn 定义两个锚点模式:
kotlin
enum class AnchorMode {
Bottom,
Locked,
}
Bottom 表示 viewport 固定在内容底部。适合初始化、自动跟随最新输出、最后一项持续变高等场景。
Locked 表示 viewport 固定在当前首个可见项和裁剪偏移。适合用户手动向上翻阅历史。
外部调用方式保持简单:
kotlin
val state = rememberAnchorLazyListState()
AnchorLazyColumn(
modifier = Modifier.fillMaxSize(),
state = state,
) {
items(
count = outputs.size,
key = { index -> outputs[index].id },
) { index ->
OutputItem(outputs[index].text)
}
if (streamingText.isNotEmpty()) {
unstableItem(key = "streaming-output") {
OutputItem(streamingText)
}
}
}
最终方案的关键是把"最后一项高度变化后应该贴底"放进测量阶段处理。Bottom 模式下,每次 measure 都从最后一项向上测量并计算底部对齐位置;最后一项重组变高时,新的高度和新的摆放位置属于同一次布局结果,不需要等布局完成后再补一次滚动。
当用户手动滑动后,状态切到 Locked,列表记录当前首个可见项和裁剪偏移。后续最后一项继续重组时,只要用户没有回到底部,就不会因为最后一项仍在可见区域内而继续向下带动 viewport。
迭代流程
1. 先保留 LazyColumn,修正高度动画和滚动时机
第一步没有急着重写列表,而是先把问题收敛到最后一项高度变化:
- 用
animateContentSize()代替自定义高度动画,避免布局阶段拿到的是动画中的假高度。 - 用
snapshotFlow监听布局完成后的最后一项尺寸。 - 如果最后一项越过 viewport 底部,就用
scrollBy()补齐差值。
这一步能解决一部分流式输出追底问题,也能验证"高度变化后需要跟随底部"这个方向是正确的。
2. 发现后置补偿无法消除帧间差
继续压测后可以看到,后置补偿始终晚于布局。只要流式内容追加频率足够高,或者最后一项高度变化较明显,就会出现视觉上的追赶。
这个现象说明根因不是"滚动命令发得不够快",而是"底部锚定发生得太晚"。要做到稳定,就必须在同一次 measure pass 内确定内容摆放位置。
3. 改成 LazyLayout,自定义 Bottom 测量
Bottom 模式下,测量从最后一项向上进行:
kotlin
var totalHeight = 0
var index = itemCount - 1
while (index >= 0 && totalHeight < viewportHeight) {
val placeable = measure(index)
totalHeight += placeable.height
index--
}
val baselineY = if (totalHeight < viewportHeight) {
0
} else {
viewportHeight - totalHeight
}
内容不足一屏时从顶部放;内容超过一屏时整体上移,让最后一项底部刚好贴住 viewport 底部。这样最后一项变高时,新的高度会在测量阶段直接影响摆放结果,不再需要布局后的滚动补偿。
4. 用户滚动后切到 Locked
用户第一次滚动时,状态从 Bottom 切到 Locked。此时记录两个信息:
firstVisibleIndexfirstVisibleOffset
下一帧测量时,从同一个首个可见项和裁剪偏移恢复位置,再叠加本次滚动 delta。这样即使底部继续追加内容,用户正在看的位置也不会被拉走。
5. 回到底部时自动恢复 Bottom
Locked 模式向下滚动到内容底部后,不应该永远停留在 Locked。否则下一次流式输出又会把底部推出 viewport。
新方案在测量阶段判断已经到达底部后,直接复用本次已经测好的 Placeable 完成底部对齐,同时把状态切回 Bottom。
这里有一个 Compose 测量细节:同一次 measure pass 不能重复 measure() 同一个 Measurable。因此不能在 measureLocked() 发现到底后再调用一次 measureBottom(),而是要复用当前 pass 已经得到的 placeable。
6. 接入 scrollable,补齐 fling
最终滚动输入使用 Modifier.scrollable:
kotlin
val scrollableState = rememberScrollableState { delta ->
state.scrollBy(delta)
}
LazyLayout(
modifier = modifier.scrollable(
state = scrollableState,
orientation = Orientation.Vertical,
reverseDirection = true,
),
measurePolicy = { constraints ->
measureImpl(state, this, items.size, constraints)
},
)
scrollBy() 根据 canScrollBackward 和 canScrollForward 返回实际消费的 delta。到达边界后返回 0f,fling 就能自然停止。

使用建议
AnchorLazyColumn 适合"底部增长 + 允许翻阅历史"的列表,不建议把它当成通用 LazyColumn 替代品。
比较推荐的使用边界是:
- 稳定历史项使用
items()。 - 最后一项如果 key 不变但内容频繁变化,使用
unstableItem()。 - 只有在明确需要回到底部时,调用
state.anchorToBottom()。 - 不要在每次数据变化时强制回到底部,否则会破坏 Locked 模式。
总结
底部增长列表的关键不是"怎么更快地 scrollToBottom",而是"viewport 的锚点到底归谁维护"。
当锚点放在业务层,调用方需要同时处理数据追加、用户手势、输入法、动画和边界,很容易变成一组互相竞争的滚动命令。
当锚点放进列表测量模型里,问题会清晰很多:
Bottom负责最新内容稳定贴底。Locked负责用户翻阅时位置不变。scrollable负责拖动与 fling。- 调用方只负责提供稳定项和流式项。
这也是这次从 LazyColumn + 后置补偿 迭代到 LazyLayout + 双锚点测量 的核心原因。
完整代码
kotlin
internal data class BottomScrollSnapshot(
val totalItemsCount: Int,
val viewportEndOffset: Int,
val lastVisibleItemIndex: Int?,
val lastVisibleItemOffset: Int,
val lastVisibleItemSize: Int
)
internal fun isFullyScrolledToBottom(snapshot: BottomScrollSnapshot, tolerance: Int = 5): Boolean {
if (snapshot.totalItemsCount == 0) return true
val lastVisibleIndex = snapshot.lastVisibleItemIndex ?: return false
return lastVisibleIndex == snapshot.totalItemsCount - 1 &&
snapshot.lastVisibleItemOffset + snapshot.lastVisibleItemSize <= snapshot.viewportEndOffset + tolerance
}
internal fun calculateBottomScrollDelta(snapshot: BottomScrollSnapshot): Int? {
val lastVisibleIndex = snapshot.lastVisibleItemIndex ?: return null
if (snapshot.totalItemsCount == 0 || lastVisibleIndex != snapshot.totalItemsCount - 1) {
return null
}
return (snapshot.lastVisibleItemOffset + snapshot.lastVisibleItemSize - snapshot.viewportEndOffset)
.coerceAtLeast(0)
}
/**
* 支持自动滑动的 ListState
*/
@Composable
fun rememberAutoScrollListState(
listState: LazyListState = rememberLazyListState(),
enabled: Boolean = true,
trigger: Any?,
): LazyListState {
// 是否启用自动滚动
var autoScroll = enabled
// 监听是否滑动到底部(推荐使用 derivedStateOf 优化性能)
val isAtBottom by remember {
derivedStateOf { listState.isFullyScrolledToBottom() }
}
// 如果回到底部,则恢复自动滚动
LaunchedEffect(isAtBottom) {
if (enabled && isAtBottom) {
autoScroll = true
}
}
LaunchedEffect(trigger) {
if (autoScroll) {
listState.scrollToBottom()
}
}
return listState
}
/**
* 自动滑动感知
*/
fun Modifier.autoScrollAware(
enabled: Boolean = true,
onUserInteracted: () -> Unit,
onUserInteractionEnd: () -> Unit
): Modifier = if (!enabled) this else pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
// 检测用户是否按下(触摸开始)
if (event.changes.any { it.pressed }) {
onUserInteracted()
}
// 所有手指都抬起(或取消
val allReleased = event.changes.all { !it.pressed }
if (allReleased) {
onUserInteractionEnd()
}
}
}
}
// 扩展:判断 LazyColumn 是否滑动到底部
fun LazyListState.isFullyScrolledToBottom(): Boolean {
return isFullyScrolledToBottom(bottomScrollSnapshot())
}
suspend fun LazyListState.scrollToBottom() {
val lastIdx = layoutInfo.totalItemsCount - 1
if (lastIdx < 0) return
val delta = calculateBottomScrollDelta(bottomScrollSnapshot())
if (delta == null) {
animateScrollToItem(lastIdx, Int.MAX_VALUE)
return
}
if (delta > 0) {
animateScrollToItem(lastIdx, delta)
}
}
suspend fun LazyListState.pinToBottom() {
val lastIdx = layoutInfo.totalItemsCount - 1
if (lastIdx < 0) return
val delta = calculateBottomScrollDelta(bottomScrollSnapshot())
when {
delta == null -> scrollToItem(lastIdx)
delta > 0 -> scrollBy(delta.toFloat())
}
}
private fun LazyListState.bottomScrollSnapshot(): BottomScrollSnapshot {
val layoutInfo = layoutInfo
val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
return BottomScrollSnapshot(
totalItemsCount = layoutInfo.totalItemsCount,
viewportEndOffset = layoutInfo.viewportEndOffset,
lastVisibleItemIndex = lastItem?.index,
lastVisibleItemOffset = lastItem?.offset ?: 0,
lastVisibleItemSize = lastItem?.size ?: 0
)
}
/**
* 监听列表最后一项的内容增长(如流式输出场景),自动用 [scrollBy] 修正滚动位置。
*
* 与 [androidx.compose.animation.animateContentSize] 配合使用:布局阶段返回真实尺寸,动画仅在绘制层生效,
* [snapshotFlow] 在布局提交后读取最终值并修正滚动,避免 animateHeight + scrollBy 的帧同步问题。
*
* 使用方式:
* ```
* LazyColumn(
* modifier = Modifier
* .stickToBottom(listState, enabled = followLatest)
* ) { ... }
* ```
*
* @param listState 列表的 [LazyListState]
* @param enabled 是否启用跟随,通常绑定到 followLatest 状态
*/
fun Modifier.stickToBottom(
listState: LazyListState,
enabled: Boolean = true
): Modifier = composed {
if (enabled) {
LaunchedEffect(listState) {
var lastTrackedSize by mutableIntStateOf(0)
snapshotFlow {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()
if (lastVisible != null && lastVisible.index == info.totalItemsCount - 1) {
lastVisible.size
} else {
lastTrackedSize
}
}.collect { currentSize ->
if (currentSize == lastTrackedSize) return@collect
val overflow = with(listState.layoutInfo) {
val last = visibleItemsInfo.lastOrNull() ?: return@collect
if (last.index != totalItemsCount - 1) return@collect
last.offset + last.size - viewportEndOffset
}
if (overflow > 0) {
listState.scrollBy(overflow.toFloat())
}
lastTrackedSize = currentSize
}
}
}
this
}
kotlin
// ──────────────────────────────────────────
// State
// ──────────────────────────────────────────
enum class AnchorMode {
/** 底部跟随:viewport 固定在内容底部,适合流式输出等持续增长场景 */
Bottom,
/** 位置锁定:滚动位置锚定,适合手动翻阅历史等场景 */
Locked,
}
@Stable
class AnchorLazyListState {
var anchorMode by mutableStateOf(AnchorMode.Bottom)
private set
internal var firstVisibleIndex = 0
internal var firstVisibleOffset = 0
private var pendingScrollDelta = 0f
private var scrollGeneration by mutableIntStateOf(0)
private var canScrollBackward = false
private var canScrollForward = false
fun anchorToBottom() {
pendingScrollDelta = 0f
canScrollForward = false
if (anchorMode != AnchorMode.Bottom) {
anchorMode = AnchorMode.Bottom
}
}
fun anchorToLocked() {
if (anchorMode != AnchorMode.Locked) {
anchorMode = AnchorMode.Locked
}
}
/**
* 滚动增量,首次拖拽时自动切换为 Locked 模式。
*/
fun scrollBy(delta: Float): Float {
val reachesBoundary = when {
delta < 0f -> !canScrollBackward
delta > 0f -> !canScrollForward
else -> true
}
if (reachesBoundary) {
return 0f
}
if (anchorMode == AnchorMode.Bottom) {
anchorToLocked()
}
pendingScrollDelta += delta
scrollGeneration++
return delta
}
internal fun consumeScrollDelta(): Float {
scrollGeneration
return pendingScrollDelta.also { pendingScrollDelta = 0f }
}
internal fun updateScrollBounds(canScrollBackward: Boolean, canScrollForward: Boolean) {
this.canScrollBackward = canScrollBackward
this.canScrollForward = canScrollForward
}
}
@Composable
fun rememberAnchorLazyListState(): AnchorLazyListState = remember { AnchorLazyListState() }
// ──────────────────────────────────────────
// Content DSL
// ──────────────────────────────────────────
interface AnchorLazyListScope {
fun items(
count: Int,
key: (index: Int) -> Any,
contentType: (index: Int) -> Any? = { null },
itemContent: @Composable (index: Int) -> Unit,
)
/** 不复用的列表项,适合内容频繁变化且 key 不变的场景(如流式文本)。 */
fun unstableItem(
key: Any,
contentType: Any? = null,
content: @Composable () -> Unit,
)
}
private class AnchorLazyListScopeImpl : AnchorLazyListScope {
private val _items = mutableListOf<LazyListItem>()
val items: List<LazyListItem> get() = _items
override fun items(
count: Int,
key: (index: Int) -> Any,
contentType: (index: Int) -> Any?,
itemContent: @Composable (index: Int) -> Unit,
) {
repeat(count) { i ->
_items.add(LazyListItem(key = key(i), contentType = contentType(i), content = { itemContent(i) }))
}
}
override fun unstableItem(key: Any, contentType: Any?, content: @Composable () -> Unit) {
_items.add(LazyListItem(key, contentType, content))
}
}
private data class LazyListItem(
val key: Any,
val contentType: Any?,
val content: @Composable () -> Unit,
)
// ──────────────────────────────────────────
// ItemProvider
// ──────────────────────────────────────────
private class AnchorLazyItemProvider(private val items: List<LazyListItem>) : LazyLayoutItemProvider {
override val itemCount: Int get() = items.size
override fun getKey(index: Int): Any = items[index].key
override fun getContentType(index: Int): Any? = items[index].contentType
@Composable
override fun Item(index: Int, key: Any) = items[index].content()
}
// ──────────────────────────────────────────
// Measure policy
// ──────────────────────────────────────────
private data class MeasuredItem(val index: Int, val placeable: Placeable, val y: Int)
private fun measureBottom(
state: AnchorLazyListState,
scope: LazyLayoutMeasureScope,
itemCount: Int,
width: Int,
vpHeight: Int,
): MeasureResult {
val itemConstraints = Constraints(maxWidth = width)
val measured = mutableListOf<Pair<Int, Placeable>>()
// 从最后一项向上测量,累积足够填满 viewport 的高度
var totalHeight = 0
var idx = itemCount - 1
var firstIdx = idx
while (idx >= 0 && totalHeight < vpHeight) {
val p = scope.compose(idx).firstOrNull()?.measure(itemConstraints)
if (p != null) {
totalHeight += p.height
measured.add(0, idx to p)
}
firstIdx = idx
idx--
}
state.firstVisibleIndex = firstIdx
// 内容不够高则从顶部开始放,溢出时整体上移对齐底部
val baselineY = if (totalHeight < vpHeight) 0 else vpHeight - totalHeight
state.firstVisibleOffset = if (baselineY < 0) -baselineY else 0
state.updateScrollBounds(
canScrollBackward = firstIdx > 0 || state.firstVisibleOffset > 0,
canScrollForward = false,
)
val visible = mutableListOf<MeasuredItem>()
var y = baselineY
for ((i, p) in measured) {
visible.add(MeasuredItem(i, p, y))
y += p.height
}
return scope.layout(width, vpHeight) {
visible.forEach { it.placeable.place(0, it.y) }
}
}
private fun measureLocked(
state: AnchorLazyListState,
scope: LazyLayoutMeasureScope,
itemCount: Int,
width: Int,
vpHeight: Int,
): MeasureResult {
val itemConstraints = Constraints(maxWidth = width)
val visible = mutableListOf<MeasuredItem>()
val measured = mutableMapOf<Int, Placeable>()
fun measure(index: Int): Placeable? {
measured[index]?.let { return it }
return scope.compose(index).firstOrNull()?.measure(itemConstraints)?.also {
measured[index] = it
}
}
// 从上次首个可见项及其裁剪偏移恢复位置,再应用本帧滚动增量。
var idx = state.firstVisibleIndex.coerceIn(0, (itemCount - 1).coerceAtLeast(0))
var y = -state.firstVisibleOffset - state.consumeScrollDelta().toInt()
// 内容向下移动时,从前面的 item 补齐顶部。
while (y > 0 && idx > 0) {
idx--
val p = measure(idx) ?: break
y -= p.height
}
if (idx == 0 && y > 0) {
y = 0
}
// 内容向上移动时,跳过已经完全离开 viewport 的 item。
var first = measure(idx)
while (first != null && y + first.height <= 0 && idx < itemCount - 1) {
y += first.height
idx++
first = measure(idx)
}
val firstIdx = idx
val firstY = y
// 从首个可见项向后填充 viewport。
while (idx < itemCount && y < vpHeight) {
val p = measure(idx)
if (p != null) {
visible.add(MeasuredItem(idx, p, y))
y += p.height
}
idx++
}
// 到达底部后复用本次 Placeable 完成对齐,不能在同一 measure pass 中再次测量。
if (idx == itemCount && y <= vpHeight) {
val measuredContentHeight = y - firstY
val shift = if (firstIdx == 0 && measuredContentHeight <= vpHeight) {
-firstY
} else {
vpHeight - y
}
if (shift > 0) {
for (i in visible.indices) {
visible[i] = visible[i].copy(y = visible[i].y + shift)
}
var prependIdx = firstIdx
var prependY = firstY + shift
while (prependY > 0 && prependIdx > 0) {
prependIdx--
val p = measure(prependIdx) ?: break
prependY -= p.height
visible.add(0, MeasuredItem(prependIdx, p, prependY))
}
}
visible.firstOrNull()?.let {
state.firstVisibleIndex = it.index
state.firstVisibleOffset = (-it.y).coerceAtLeast(0)
}
state.updateScrollBounds(
canScrollBackward = state.firstVisibleIndex > 0 || state.firstVisibleOffset > 0,
canScrollForward = false,
)
state.anchorToBottom()
return scope.layout(width, vpHeight) {
visible.forEach { it.placeable.place(0, it.y) }
}
}
state.firstVisibleIndex = firstIdx
state.firstVisibleOffset = (-firstY).coerceAtLeast(0)
state.updateScrollBounds(
canScrollBackward = firstIdx > 0 || state.firstVisibleOffset > 0,
canScrollForward = idx < itemCount || y > vpHeight,
)
return scope.layout(width, vpHeight) {
visible.forEach { it.placeable.place(0, it.y) }
}
}
private fun measureImpl(
state: AnchorLazyListState,
scope: LazyLayoutMeasureScope,
itemCount: Int,
constraints: Constraints,
): MeasureResult {
val vpWidth = constraints.maxWidth
val vpHeight = constraints.maxHeight
if (itemCount == 0) {
state.updateScrollBounds(canScrollBackward = false, canScrollForward = false)
return scope.layout(vpWidth, vpHeight) {}
}
return when (state.anchorMode) {
AnchorMode.Bottom -> measureBottom(state, scope, itemCount, vpWidth, vpHeight)
AnchorMode.Locked -> measureLocked(state, scope, itemCount, vpWidth, vpHeight)
}
}
// ──────────────────────────────────────────
// Composable
// ──────────────────────────────────────────
/**
* 双锚点懒加载列表:Bottom 模式 viewport 固定在内容底部(布局阶段原子锚定,无帧间隙),
* Locked 模式滚动位置锚定(与普通 LazyColumn 行为一致)。
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnchorLazyColumn(
modifier: Modifier = Modifier,
state: AnchorLazyListState = rememberAnchorLazyListState(),
content: AnchorLazyListScope.() -> Unit,
) {
val scope = AnchorLazyListScopeImpl()
scope.content()
// LazyLayout 会缓存 item composition;Provider 必须随内容更新,否则相同 key 的流式项不会重组。
val items = scope.items.toList()
val itemProviderState = rememberUpdatedState<LazyLayoutItemProvider>(AnchorLazyItemProvider(items))
val itemProvider: () -> LazyLayoutItemProvider = remember { { itemProviderState.value } }
val scrollableState = rememberScrollableState { delta ->
state.scrollBy(delta)
}
LazyLayout(
itemProvider = itemProvider,
modifier = modifier
.clipToBounds()
.scrollable(
state = scrollableState,
orientation = Orientation.Vertical,
reverseDirection = true,
),
measurePolicy = { constraints ->
measureImpl(state, this, items.size, constraints)
},
)
}