Notice a row arriving, and stop subcomposing the list at the keyboard

Two faults from making the retained window lazy, both of them mine.

A screen of blank between the last message and the box it was typed in.
The window was recomputed when the view had moved far enough, and a
message arriving does not move the view -- so the new row fell outside
the window and stood in as a spacer of its guessed height. The version
number the check compares against is only bumped by the recompute it
guards, so asking before refreshing meant never noticing. It refreshes
first now, and the window also watches how many rows there are, because
a row arriving is the case it exists to catch and the one that does not
announce itself through the scroll position.

And the keyboard, which was the most expensive thing on the screen. The
visible height came from a `BoxWithConstraints` wrapped around the
transcript -- that is a `SubcomposeLayout`, and the IME animation changes
the visible height on every frame of its slide, so the entire transcript
was being subcomposed again for each of them. The same number read in
the layout phase, from the scroll container's own measurement, makes it a
relayout instead, and the rows keep the measurements they already have.

Checked on the emulator: the newest message sits against the composer
with the keyboard up, and there is no gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 03:41:10 -04:00
1 parent 2b24362cc4
commit 9052e5f55e
2 files changed
+29 -9

No files matched your search

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -18,6 +17,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -28,6 +28,7 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
@@ -89,8 +90,13 @@ class TranscriptScroll(internal val scroll: ScrollState) {
private var rowIndex = HashMap<Long, Int>()
private var topsStale = true
/** How many rows the list is drawing, so the window notices one arriving. */
var rowCount: Int by mutableIntStateOf(0)
private set
internal fun laidOut(order: List<Long>, spacing: Int, padTop: Int) {
this.order = order
rowCount = order.size
this.spacing = spacing
this.padTop = padTop
rowIndex = HashMap(order.size)
@@ -286,10 +292,16 @@ class TranscriptScroll(internal val scroll: ScrollState) {
* either side and this moves it in steps of two, so the margin absorbs the staleness.
*/
internal fun trackRetained() {
// Before the comparison, not after it. Rows arriving is the case this exists to catch and
// it does not move the view: a message sent lands at the newest end, and if the range is
// not recomputed the new row is outside it and stands in as a spacer of its guessed height
// -- a screen of blank between the last message and the box it was typed in. The version
// it is compared against is only bumped by this call, so asking first meant never noticing.
refreshTops()
val viewportTop = scroll.maxValue - scroll.value
val step = (scroll.viewportSize * RETAIN_STEP_SCREENS).coerceAtLeast(1)
val moved = viewportTop - rangeAt
if (retained.isEmpty() || moved > step || moved < -step || topsVersion != rangeVersion) {
if (retained.isEmpty() || topsVersion != rangeVersion || moved > step || moved < -step) {
retained = retainedRange(viewportTop)
}
}
@@ -410,7 +422,6 @@ fun rememberTranscriptScroll(key: Any?): TranscriptScroll {
fun TranscriptColumn(
rows: List<TranscriptRow>,
state: TranscriptScroll,
viewportHeight: Dp,
contentPadding: PaddingValues,
spacing: Dp,
modifier: Modifier = Modifier,
@@ -438,7 +449,20 @@ fun TranscriptColumn(
modifier
.verticalScroll(state.scroll, reverseScrolling = true)
.padding(contentPadding)
.heightIn(min = viewportHeight)
// As tall as the visible area at least, so a conversation shorter than the screen sits
// against the composer rather than leaving a gap under it that cannot be scrolled away.
//
// Taken in the layout phase from the scroll container's own measurement, rather than
// from a `BoxWithConstraints` around this. That is a `SubcomposeLayout`, and the
// keyboard opening changes the visible height on every frame of its animation -- so
// the whole transcript was being subcomposed again for each of those frames, which is
// what made bringing the keyboard up cost more than anything else on the screen. Read
// here it is a relayout, and the rows keep the measurements they already have.
.layout { measurable, constraints ->
val placeable =
measurable.measure(constraints.copy(minHeight = state.scroll.viewportSize))
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}
.fillMaxWidth()
// Once for the whole list, not once per row: this is where a saved position is put
// back, and by placement the scroll container's own measurements describe this layout.