Ask for the scroll in the gesture, not from the layout

A correction made from the layout is a frame late whatever phase it is made in:
from placement it is never picked up by another measure and does nothing at all,
and from measure it lands on the next frame with the uncorrected one drawn
first. That is the flick when a card is opened, and it is why a close could
finish somewhere other than where it was aimed -- the two are the same fault.

Asked for at the tap instead, the request is consumed by the same measure pass
that first lays the row out at its new size, so the resize is drawn once, in its
right place. What makes that possible is that none of the three positions needs
to know the new height. A top edge holds by placing the item *above* the row
where it already is -- that item's bottom edge is the row's top edge whatever
becomes of the row, and it does not have to be composed for the list to place
it. A close places the row itself against the height it had at the moment it was
opened, which is the height it is going back to.

So the measure-phase hold, its modifier and the list's `afterMeasure` hook are
all gone, and this is 75 lines shorter than the version that could not do it.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against a real imported conversation as well as the sandbox:
a group closed by its heading lands centred on the tap (1467..1593 for a tap at
1530), a card closed at 1500 and at 1800 lands centred on each, opening by a
heading holds the heading to the pixel, and opening or closing a real call
inside a group of two leaves that group's heading exactly where it was.
This commit is contained in:
iris-ai committed 2026-09-16 03:08:17 -04:00
1 parent 581e07624f
commit 914985b8b2
3 files changed
+58 -133

No files matched your search

@@ -61,7 +61,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
@@ -167,52 +166,6 @@ private data class HistoryLoadSignal(
val oldestSeq: Long,
)
/**
* Where a row has been asked to put itself, and how close it has got; see [holdTouchedPoint] and
* `SessionScreen`'s `settleHold`.
*
* Positions here are the list's own: the distance of an edge *above* the bottom of the viewport,
* which is what [LazyListItemInfo.offset] measures and the coordinate the whole correction is done
* in. [target] is the point the row has to arrive at -- the finger for a row shutting, its own top
* edge as it was for one opening -- and [onCentre] says which part of the row arrives there: the
* middle of it for a close, since what is left of a shut card is a heading and the finger should be
* on it wherever down the card it pressed, and the top edge for an open, where the row is a heading
* already and the edge pressed is what the reader wants held.
*
* Deliberately *not* snapshot state, which is the point of the class. The fields are written from
* the layout phase; a snapshot write there that composition reads would schedule another
* recomposition, and the correction has to land inside the frame already being laid out. [key] is
* cleared once the row has arrived, so a hold cannot be spent on an unrelated resize.
*/
private class TouchHold {
var key: Any? = null
var target = 0
var onCentre = false
/** Where the row is in the list, which is what the correction is asked for against. */
var index = 0
/** The row's height as this pass measured it, which its own modifier reports. */
var height = 0
/** Passes spent waiting for the row's own height; see `settleHold`. */
var passes = 0
}
/** How many passes a hold waits for its row to be measured before giving up on it. */
private const val HOLD_PASSES = 4
/**
* Reports this row's height to a hold on it, which is the one thing the hold cannot measure itself.
*
* In the measure phase, so the height is this pass's before the list's own measure hook reads it --
* see `settleHold`, which is where the correction is made and why it is not made here.
*/
@Composable
private fun Modifier.holdTouchedPoint(key: Any, held: TouchHold): Modifier = onSizeChanged { size ->
if (held.key == key) held.height = size.height
}
/**
* Which row the last touch landed in, and where in that row it landed.
*
@@ -259,7 +212,10 @@ fun SessionScreen(
val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope()
val touchHeld = remember { TouchHold() }
// What each open row measured before it was opened, which is the height it goes back to when
// the reader shuts it -- and the one thing a correction made in the gesture cannot look up.
// Only rows the reader has opened are in it, so it is as small as the screen's open set.
val closedHeights = remember { mutableMapOf<Any, Int>() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
var backgroundTasks by
@@ -613,7 +569,17 @@ fun SessionScreen(
* Where the touch landed decides the rest, rather than which control was pressed, so everything
* behaves the same way whether or not it has a control at each end.
*
* Asking for it is `settleHold`'s, which puts the row there once its new height is known.
* The scroll that arranges it is asked for here, in the gesture, rather than from the layout
* that discovers the row's new height. That is what makes it invisible: a request made now is
* consumed by the same measure pass that first lays the row out at its new size, so the resize
* is drawn once, in its right place. Made from the layout instead it is a *frame* late, and the
* frame before it -- the row resized and nothing corrected -- is drawn: opening a card flicked.
*
* None of the three positions needs to know the new height, which is what lets them be asked
* for before it exists. A top edge holds by placing the item *above* the row where it already
* is, since that item's bottom edge is the row's top edge whatever happens to the row. A close
* places the row itself, using the height it had at the moment it was opened: what the reader
* is shutting is the card they opened, so that is the height it is going back to.
*/
fun toggleAnchored(
row: TranscriptRow,
@@ -621,65 +587,36 @@ fun SessionScreen(
inner: Boolean = false,
toggle: () -> Unit,
) = expanding {
val item = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == row.key }
val onCentre = closing && !inner
// What is left is an open in the row's lower half, which wants its bottom edge held -- and
// that is what the list does on its own, so there is nothing to ask for.
val wanted = onCentre || inner || lastTouch.y < lastTouch.height / 2
if (lastTouch.key == row.key && item != null && wanted) {
// An item's offset is its bottom edge, counted up from the bottom of the viewport, and
// the row sits on that edge with its gap above it -- so its top is that far up plus its
// height, and the finger is that less how far down the row it landed.
val top = item.offset + lastTouch.height
touchHeld.key = row.key
touchHeld.index = item.index
touchHeld.onCentre = onCentre
touchHeld.target = if (onCentre) top - lastTouch.y else top
touchHeld.height = 0
touchHeld.passes = 0
val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.key == row.key }
if (lastTouch.key == row.key && item != null) {
// An item's offset is its bottom edge, counted up from the bottom of the viewport. The
// row sits on that edge with its gap above it, so the item's own top is the edge the
// row above meets, and the finger is the row's height less how far down it landed.
val above = item.offset + item.size
val touch = item.offset + lastTouch.height - lastTouch.y
val closed = if (closing) closedHeights[row.key] else null
when {
// The card the reader is shutting, centred on the finger that shut it.
closing && !inner && closed != null ->
listState.requestScrollToItem(item.index, closed / 2 - touch)
// Everything above the change stays where it is: the row's top edge, which is the
// bottom edge of the item above it. That item does not have to be composed for the
// list to place it, which matters -- the top of a group taller than the screen is
// exactly where nothing above it is.
(inner || lastTouch.y < lastTouch.height / 2) &&
item.index + 1 < info.totalItemsCount ->
listState.requestScrollToItem(item.index + 1, -above)
// What is left is an open in the row's lower half, which wants its bottom edge
// held, and that is what the list does on its own.
else -> {}
}
// Its height as it stands is the height it goes back to when it is shut again.
if (!closing && !inner) closedHeights[row.key] = lastTouch.height
}
toggle()
}
/**
* Puts the held row where the touch on it asked for, in the pass that measured its new height.
*
* Called from inside the list's own measure ([TranscriptList]'s `afterMeasure`), which is the
* only place a correction lands: a scroll asked for from the placement phase is not picked up
* by another measure and so does nothing at all, and one posted to a coroutine arrives a frame
* late, which is the flick this whole mechanism exists to avoid.
*
* One request, never a walk towards the answer. A correction asking for the error it can see
* and then asking again is the obvious way to stop guessing at what the list will do with a
* request -- and it is wrong here, because the pass that answers is a *frame* later and the one
* in between is drawn: opening a card visibly stepped. So the position is stated outright
* rather than converged on, which the list can do exactly: [LazyListState.requestScrollToItem]
* places the row itself, wherever it has ended up and whether or not it is still on screen, and
* everything else follows from where that puts it.
*/
fun settleHold() {
if (touchHeld.key == null) return
// The row has not measured yet -- the pass that measures it is the one to correct in, and
// this one has nothing to work from. Bounded, so a row that never measures lets the hold go
// rather than keeping it for an unrelated resize.
val height = touchHeld.height
if (height == 0) {
if (touchHeld.passes++ < HOLD_PASSES) return
touchHeld.key = null
return
}
touchHeld.key = null
// Where the row's bottom edge has to be for the part that matters to land on the target,
// and then the same in the list's own terms: a scroll offset is that distance counted the
// other way, negative when the edge ends up above the bottom of the viewport. Negative is
// both allowed and what a close needs -- it is the list being asked for the rows below the
// card that has been giving the screen its whole height.
val wanted = touchHeld.target - if (touchHeld.onCentre) height / 2 else height
// Reads unobserved: this runs inside the list's measure, and observing the scroll position
// here would make every frame's measure depend on it.
Snapshot.withoutReadObservation { listState.requestScrollToItem(touchHeld.index, -wanted) }
}
/**
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
*
@@ -1617,7 +1554,6 @@ fun SessionScreen(
selection = selection,
modifier =
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
afterMeasure = ::settleHold,
below = {
// The last thing in the transcript, because that is where they are in
// the session's reading of events: after everything it has taken in,
@@ -1684,7 +1620,7 @@ fun SessionScreen(
is TranscriptUnit.Whole -> {
val row = unit.row
Box(
Modifier.holdTouchedPoint(row.key, touchHeld)
Modifier
// Where down this row the touch landed, for
// [toggleAnchored]. On the initial pass and consuming
// nothing, so every control inside still gets the gesture;
@@ -57,16 +57,6 @@ fun TranscriptList(
onRetryHistory: () -> Unit,
selection: SelectionState,
modifier: Modifier = Modifier,
/**
* Called inside the list's own measure, once its items have been measured and its
* [LazyListState.layoutInfo] describes this pass rather than the last one.
*
* It is where a correction to the scroll position belongs, and the only place one lands: a
* scroll asked for during the *placement* phase is not picked up by another measure, so it does
* nothing at all until something else moves the list. Asked for here it is served by this
* frame's next pass, before anything is drawn. See `SessionScreen`'s hold.
*/
afterMeasure: () -> Unit = {},
below: @Composable () -> Unit,
unit: @Composable (TranscriptUnit) -> Unit,
) {
@@ -83,7 +73,6 @@ fun TranscriptList(
.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
afterMeasure()
DebugStats.record(
"measure: the whole transcript",
System.nanoTime() - started,