Compose:从自动滚动到双锚点 LazyLayout

在 ChatGPT 这类对话页里,一次回答通常不是作为完整文本一次性插入列表,而是同一条回答内容持续追加、同一个列表项不断变高。

本文的核心目标是:当列表处于自动贴底状态时,最后一项每次内容更新、重组、高度变化,都能自然跟随到底部;当用户进入自由滑动状态时,最后一项继续更新也不应该把当前位置重新拉回底部。

这篇文章记录这个目标下的迭代过程:先从正向视觉的自动滚动开始,再尝试 LazyColumn 反向视觉,然后用 snapshotFlow + scrollBy 做布局后的补偿,最后演进到一个基于 LazyLayout 的双锚点列表 AnchorLazyColumn

背景

普通列表通常以"顶部"为视觉起点:新数据插入后,只要保持当前首个可见项不变,用户就不会感觉跳动。但 ChatGPT 类流式回答列表的关键点在最后一项:

  1. 内容不足一屏时,从顶部自然摆放。
  2. 内容超过一屏后,默认贴住底部。
  3. 自动贴底状态下,最后一项重组变高时,viewport 同步跟随底部,不出现帧间空隙、追赶感或跳动。
  4. 用户向上滚动后,当前位置被锁定;底部继续追加也不抢用户视角。
  5. 用户滚回底部后,列表恢复自动跟随。

这类需求很容易被误解成"每次数据变化都滚到底部"。但真正要解决的是高度变化和滚动跟随的时序:最后一项先变高、下一帧再滚动,视觉上就会像跳动;高度变化和滚动不在同一帧完成,也会留下追赶感。

四个方案概览

这次迭代可以拆成四个方案:

  1. 正向视觉 + rememberAutoScrollListState:最后一项更新后再触发滚到底部。
  2. LazyColumn 反向视觉:使用 reverseLayout,让最后一项的高度变化更自然地发生在视觉底部。
  3. snapshotFlow + scrollBy:继续使用正向视觉,在布局完成后根据最后一项尺寸补偿滚动。
  4. 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。此时记录两个信息:

  • firstVisibleIndex
  • firstVisibleOffset

下一帧测量时,从同一个首个可见项和裁剪偏移恢复位置,再叠加本次滚动 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() 根据 canScrollBackwardcanScrollForward 返回实际消费的 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)
    },
  )
}
相关推荐
用户63421419935073 小时前
base_logger_core --- 跨平台 C++ 日志核心库
android
苦瓜花3 小时前
【Kotlin】初入门
android·开发语言·kotlin
dora4 小时前
使用 Cling 实现 DLNA 投屏:从局域网发现到媒体播放控制
android·android things·tvos
apihz5 小时前
全球域名 WHOIS 信息实时查询免费 API 接口教程,支持1000+后缀
android·网络·网络协议·tcp/ip·apache·域名·whois
非典型Android程序员6 小时前
Binder机制学习总结
android
AI2中文网7 小时前
App Inventor 2 音频播放器 vs 音效 vs 视频播放器:三个媒体组件,选错了App直接卡死
android·音视频·媒体
李剑一7 小时前
uni-app x 实现本地 JSON 文件的导入导出(APP端可用)
android·前端·uni-app
喜欢打篮球的普通人7 小时前
LLVM后端指令选择
android·java·javascript
hunterandroid7 小时前
Room 数据库迁移:让本地数据升级更稳
android·前端