Hold every loaded row, and page by pixels rather than by rows
The transcript is a plain Column scrolled in reverse instead of a LazyColumn. Nothing about Compose was re-measuring text that had already been drawn -- a node that is still alive and whose constraints have not changed skips measurement outright, in MeasurePassDelegate.remeasure. What was throwing that away was disposal: a lazy list drops a row the moment it leaves the viewport, and the markdown tree, the measured lines and the cached paragraph go with it. Keeping the rows is the fix, and it is what a browser-based client does that we were not. Two scroll corrections go with it, each of which had a comment explaining a way it had been seen to fire at the wrong moment. The content now hangs from its newest end, so a page of older history extends the far end and moves nothing on screen, and an arriving message extends the end the viewport is already pinned to. Following the newest message is no longer an effect that notices and corrects; it is where the content is. The same goes for the keyboard opening, which was the case that used to get missed. Paging asks its question in pixels of scroll -- how far can the reader keep going before they run out -- which is what it was always about. Rows were the wrong unit twice: a fixed count of them is a distance only by accident, and counting screenfuls of rows fixed the size of that mistake without fixing its kind. A saved position is resolved to the row that now holds that seq before the layout is asked to put it back. The events behind a row regroup between the save and the reopen, so the seq that was a row's first is often no longer any row's first, and handing the layout the saved seq named a row that did not exist -- the position was never applied and the session opened at the newest end. Verified on the emulator against a real 1,200-event transcript: the position survives leaving and reopening, jump-to-latest arrives and stays followed, and the newest message holds its place against the composer as the keyboard opens and the draft grows. Scrolling measures the same as the lazy version did (2.6% vs 2.0% janky, p50 16ms, p90 21ms, no slow UI-thread frames either way) -- the cap and the off-thread parse had already taken that cost out, so this change is about what the list can no longer do wrong rather than about frames. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3095052df0
commit
ee1c493559
2 files changed
+356
-273
No files matched your search
@@ -0,0 +1,204 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The transcript's scroll position, addressed the way the transcript itself is: a row, and how far
|
||||
* that row has been scrolled past the top of the viewport.
|
||||
*
|
||||
* Pixels are the unit underneath because this list is a plain [Column] rather than a lazy one --
|
||||
* every loaded row is composed, measured and kept, so there is a real height for every row whether
|
||||
* or not it is on screen, and no index has to stand in for one. What that buys is that scrolling
|
||||
* back over a message never rebuilds it: a Compose node that is still alive and whose constraints
|
||||
* have not changed is not re-measured at all (`MeasurePassDelegate.remeasure` returns early), so
|
||||
* the text keeps the layout it was given. A lazy list disposes a row the moment it leaves the
|
||||
* viewport, and the markdown tree, the measured lines and the cached paragraph go with it -- which
|
||||
* is the lag when scrolling back over history that has already been read.
|
||||
*
|
||||
* The content is laid out oldest-first and scrolled in reverse, which is what makes the two
|
||||
* insertions this list actually gets free rather than corrected:
|
||||
*
|
||||
* - `reverseScrolling` places the content by its *end*, so `value == 0` is the newest message and
|
||||
* growing the content at the far end -- a page of older history landing -- moves nothing on
|
||||
* screen. `maxValue` grows and `value` is left alone.
|
||||
* - A new message extends the same end the viewport is pinned to, so following it is not an effect
|
||||
* that has to notice and correct: at `value == 0` the newest content is simply what is on screen.
|
||||
* The keyboard opening is the same case from the other side -- the viewport shrinks, `maxValue`
|
||||
* grows, and the newest message is still against the bottom.
|
||||
*
|
||||
* Both of those were scroll corrections in the lazy version, each with a comment explaining a way
|
||||
* it had been seen to fire at the wrong moment.
|
||||
*/
|
||||
@Stable
|
||||
class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
|
||||
/**
|
||||
* Where each row's top edge sits inside the content, by the seq that names it.
|
||||
*
|
||||
* Written from the layout pass as rows are placed, so it describes the layout that is on
|
||||
* screen. Keyed on [TranscriptRow.startSeq] rather than on the row's display key for the reason
|
||||
* the anchor is: a tool run is renamed when the newest page starts somewhere new, and a
|
||||
* position recorded against the old name is never found again.
|
||||
*/
|
||||
private val tops = HashMap<Long, Int>()
|
||||
|
||||
/**
|
||||
* A position waiting to be put back, applied by the layout that first places its row.
|
||||
*
|
||||
* Held here rather than applied by whoever loaded the row because the pixels do not exist yet
|
||||
* at that point: a plain column has no height for a row until it has been measured. Applying it
|
||||
* from the placement is what makes the restore frame-exact -- the transcript is drawn where it
|
||||
* was left rather than drawn at the newest end and then moved, which is a journey the reader
|
||||
* would see. [settling] is the same fact asked the other way, and gates drawing.
|
||||
*/
|
||||
var pending: ScrollAnchor? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Whether a saved position is still being put back, so nothing should be drawn yet. */
|
||||
val settling: Boolean
|
||||
get() = pending != null
|
||||
|
||||
internal fun placed(seq: Long, top: Int) {
|
||||
tops[seq] = top
|
||||
pending?.let { anchor ->
|
||||
if (anchor.seq != seq) return@let
|
||||
scroll.dispatchRawDelta(
|
||||
(scroll.maxValue - top - anchor.offset - scroll.value).toFloat()
|
||||
)
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything these described is gone -- a stream reset, or a different session. */
|
||||
fun clear() = tops.clear()
|
||||
|
||||
/** Whether the newest message is on screen. See the class comment: the newest end is zero. */
|
||||
val atNewest: Boolean
|
||||
get() = scroll.value == 0
|
||||
|
||||
/**
|
||||
* How much unread history sits above the viewport, in pixels.
|
||||
*
|
||||
* This is the paging question asked in the unit it is actually about. Counting rows could not
|
||||
* answer it: a row is anything from one line to a screenful, so "three rows back" is a
|
||||
* different amount of reading depending on which three, and the count that mattered -- how far
|
||||
* somebody can scroll before running out -- was never what was measured.
|
||||
*/
|
||||
val roomAbove: Int
|
||||
get() = scroll.maxValue - scroll.value
|
||||
|
||||
/** The height of the visible area, 0 until the first measurement. */
|
||||
val viewport: Int
|
||||
get() = scroll.viewportSize
|
||||
|
||||
/** Where the reader is now, or null before anything has been laid out. */
|
||||
fun anchor(): ScrollAnchor? {
|
||||
val top = scroll.maxValue - scroll.value
|
||||
// The row covering the top of the viewport: the last one that starts at or above it.
|
||||
val at = tops.entries.filter { it.value <= top }.maxByOrNull { it.value } ?: return null
|
||||
return ScrollAnchor(at.key, top - at.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for [anchor] to be put back by the layout that places its row; see [pending].
|
||||
*
|
||||
* The caller is responsible for the row being loaded. Nothing here can wait for one that never
|
||||
* arrives, and a position held open for it would leave the transcript blank -- so a row that is
|
||||
* not in the transcript any more is [giveUp]'s case, not this one.
|
||||
*/
|
||||
fun restore(anchor: ScrollAnchor) {
|
||||
pending = anchor
|
||||
}
|
||||
|
||||
/** Draw where we are instead: the anchored row is not in this transcript any more. */
|
||||
fun giveUp() {
|
||||
pending = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls by [pixels] without animating, from wherever the caller is.
|
||||
*
|
||||
* Used to hold a row's top edge still when it changes height. Growth already goes upward
|
||||
* because the content hangs from its end, so the bottom edge is held for free and this is the
|
||||
* other case; see `holdTopEdge`.
|
||||
*/
|
||||
fun by(pixels: Int) {
|
||||
scroll.dispatchRawDelta(pixels.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTranscriptScroll(key: Any?): TranscriptScroll {
|
||||
val scroll = rememberScrollState()
|
||||
return remember(key) { TranscriptScroll(scroll) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every loaded row, composed and kept. See [TranscriptScroll] for why this is not a lazy list.
|
||||
*
|
||||
* [viewportHeight] is passed in rather than measured here because a scrollable child is measured
|
||||
* with no height bound at all, so the content cannot ask how tall the visible area is. It is what
|
||||
* holds a conversation shorter than the screen against the bottom, where the composer is, instead
|
||||
* of leaving a gap under it that cannot be scrolled away.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptColumn(
|
||||
rows: List<TranscriptRow>,
|
||||
state: TranscriptScroll,
|
||||
viewportHeight: Dp,
|
||||
contentPadding: PaddingValues,
|
||||
spacing: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
below: @Composable () -> Unit,
|
||||
row: @Composable (TranscriptRow) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.verticalScroll(state.scroll, reverseScrolling = true)
|
||||
.padding(contentPadding)
|
||||
.heightIn(min = viewportHeight)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing, Alignment.Bottom),
|
||||
) {
|
||||
rows.forEach { item ->
|
||||
// Keyed so that a row keeps its composition -- and so the state inside it, an open
|
||||
// tool call or an expanded reply, stays with the row rather than with the position.
|
||||
key(item.key) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().onPlaced {
|
||||
state.placed(item.startSeq, it.positionInParent().y.toInt())
|
||||
}
|
||||
) {
|
||||
row(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
below()
|
||||
}
|
||||
}
|
||||
|
||||
/** The gap between rows, and the room around the whole conversation. */
|
||||
val TRANSCRIPT_SPACING: Dp = 8.dp
|
||||
|
||||
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)
|
||||
Reference in new issue
Block a user