Close a card on the point that was touched
Collapsing held one of the row's edges -- whichever the tap was nearer -- which is right for opening and wrong for closing: the row that shuts leaves a heading where a screenful of card was, and both its old edges can be a screen's length from the finger that shut it. It now keeps the touched point itself, which for a closed card is the same thing as landing under the hand that closed it. Opening is unchanged and deliberately so: those rows are small, every point in them is within a heading's height of both edges, and the edge pressed is what the reader wants held rather than a fraction of an unbounded expansion. One number carries both readings -- the share of the row's height above the touch, spent as it is on a close and rounded to the nearer edge on an open. The scroll offset the correction asks for goes negative on a close, and has to: that is the list being asked for the rows below what it has composed, which is where the newer content comes from when a card gives a screenful back. It was clamped at zero, which was invisible while every correction was a row growing and is what left closes uncorrected. Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest, and on the emulator against the sandbox: a 1441px card closed at a quarter of its height put the collapsed card's top at 743px against 743 predicted, and opening a card by its heading still holds the heading still.
This commit is contained in:
1 parent
827a30768c
commit
463acb28fa
2 files changed
+108
-46
No files matched your search
@@ -1178,15 +1178,28 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
|
||||
question *was*, and "Deny" alone does not say Allow was the alternative.
|
||||
One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer
|
||||
typed into **Other** matches no option, so that one is still written out.
|
||||
- **Expanding a row keeps still the end nearest the tap**: touch a row's
|
||||
upper half and its top edge holds, so it opens downwards; touch its
|
||||
lower half and the bottom edge holds, as the list does by default. Which
|
||||
half, rather than which control, so everything that opens behaves alike
|
||||
whether or not it has a control at each end. The transcript is laid out
|
||||
from the bottom, so a bottom edge is anchored for free and the top one
|
||||
has to be arranged: `Modifier.holdTopEdge` asks the list to shift during
|
||||
the *layout* phase, before anything is drawn. From an effect instead,
|
||||
the wrong position is drawn once first, which reads as a flick.
|
||||
- **Opening a row keeps still the end nearest the tap, and closing one
|
||||
keeps still the point touched** (2026-09-16). A row about to open is
|
||||
small, so both its edges are within a heading's height of the finger and
|
||||
the wanted one is the edge pressed: touch the upper half and the top
|
||||
edge holds, so it opens downwards; touch the lower half and the bottom
|
||||
edge holds, as the list does by default. A row about to close leaves
|
||||
almost nothing of itself, and the only place the closed card can
|
||||
sensibly appear is under the finger that closed it — the case that
|
||||
settles it is a card taller than the screen, where holding either edge
|
||||
throws the card a screen's length from the hand. One number carries
|
||||
both: the share of the row's height above the touch, spent as it is on a
|
||||
close and rounded to the nearer edge on an open. Which half, rather than
|
||||
which control, so everything behaves alike whether or not it has a
|
||||
control at each end. The transcript is laid out from the bottom, so a
|
||||
bottom edge is anchored for free and anything else has to be arranged:
|
||||
`Modifier.holdTouchedPoint` asks the list to shift during the *layout*
|
||||
phase, before anything is drawn. From an effect instead, the wrong
|
||||
position is drawn once first, which reads as a flick. The scroll offset
|
||||
that asks for it **goes negative on a close** — that is the list being
|
||||
asked for the rows below what it has composed — and clamping it at zero,
|
||||
which is what the growing-only version did, leaves every close
|
||||
uncorrected.
|
||||
- **The full-screen image lives on the screen, not in the row that drew
|
||||
the thumbnail** (`SessionImageViewer`). A `Read` whose result is an image
|
||||
is a row of one call until the next call arrives and makes it a group — a
|
||||
|
||||
@@ -82,6 +82,7 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -168,25 +169,34 @@ private data class HistoryLoadSignal(
|
||||
)
|
||||
|
||||
/**
|
||||
* Which row was asked to hold its top edge, and how tall it was when it last measured.
|
||||
* Which row was asked to hold a point of itself still, and what share of its height change that
|
||||
* point is worth; see [holdTouchedPoint].
|
||||
*
|
||||
* Deliberately *not* snapshot state, which is the point of the class. Both 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 by the resize it was set for, so it cannot be spent on an unrelated one.
|
||||
*/
|
||||
private class TopEdgeHold {
|
||||
private class TouchHold {
|
||||
var key: Any? = null
|
||||
|
||||
/**
|
||||
* 1 holds the row's top edge, 0 its bottom, and anything between the point that share names.
|
||||
*/
|
||||
var share = 0f
|
||||
}
|
||||
|
||||
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
|
||||
/** One row's height between layouts, so a change in it can be noticed. See [holdTouchedPoint]. */
|
||||
private class LastHeight {
|
||||
var value: Int? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Which row the last touch landed in, and whether it landed in the row's top half -- the end that
|
||||
* row should hold when it changes height; see [holdTopEdge].
|
||||
* Which row the last touch landed in, and where down that row it landed -- as the share of the
|
||||
* row's height that sits above the touch; see [toggleAnchored].
|
||||
*
|
||||
* A share rather than a distance because that is what both readings of the touch want: a close
|
||||
* spends it as it is, and an open rounds it to the nearer edge.
|
||||
*
|
||||
* One slot rather than a map, because only the touch about to toggle something matters:
|
||||
* [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each
|
||||
@@ -194,22 +204,23 @@ private class LastHeight {
|
||||
*/
|
||||
private class LastTouch {
|
||||
var key: Any? = null
|
||||
var high = false
|
||||
var share = 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps this row's top edge where it is when the row changes height, if it was asked to.
|
||||
* Keeps a point of this row still when the row changes height, if it was asked to.
|
||||
*
|
||||
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is
|
||||
* why it is a modifier rather than an effect. A correction posted to a coroutine arrives a frame or
|
||||
* more after the layout it is correcting, so the wrong position is drawn once first -- visible as a
|
||||
* flick, and worse the faster the screen refreshes.
|
||||
*
|
||||
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of
|
||||
* exactly that much leaves the top edge where it was.
|
||||
* [hold] is given how far the list has to scroll. The row's bottom edge is held by the list and all
|
||||
* growth goes upward, so scrolling by the whole change keeps the top edge where it was, by none of
|
||||
* it the bottom edge, and by [TouchHold.share] of it the point that share names.
|
||||
*/
|
||||
@Composable
|
||||
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier {
|
||||
private fun Modifier.holdTouchedPoint(key: Any, held: TouchHold, hold: (Int) -> Unit): Modifier {
|
||||
val last = remember { LastHeight() }
|
||||
return onSizeChanged { size ->
|
||||
val previous = last.value
|
||||
@@ -217,8 +228,9 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni
|
||||
// A first measurement has no previous height to have moved from, and a row that came back
|
||||
// after being scrolled away is a first measurement again.
|
||||
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
|
||||
val share = held.share
|
||||
held.key = null
|
||||
hold(size.height - previous)
|
||||
hold(((size.height - previous) * share).roundToInt())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +265,7 @@ fun SessionScreen(
|
||||
val isSubagent = subagent != null
|
||||
val address = TranscriptAddress(summary.id, subagent?.id)
|
||||
val scope = rememberCoroutineScope()
|
||||
val topEdgeHeld = remember { TopEdgeHold() }
|
||||
val touchHeld = remember { TouchHold() }
|
||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
|
||||
var backgroundTasks by
|
||||
@@ -575,20 +587,35 @@ fun SessionScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a row's height while the end the reader touched stays where it is.
|
||||
* Changes a row's height while what the reader touched stays where it is.
|
||||
*
|
||||
* The transcript is laid out from the bottom, so every row's *bottom* edge is what the list
|
||||
* holds still and all growth goes upward. That is what a tap in a row's lower half already
|
||||
* gets. A tap in the upper half is the other case -- left alone it sends the heading under the
|
||||
* reader's finger up off the screen -- and that one asks the row to hold its top edge instead.
|
||||
* holds still and all growth goes upward. A tap anywhere in a row that is about to grow or
|
||||
* shrink therefore travels, and how far it travels depends on where down the row it landed.
|
||||
*
|
||||
* Which half decides it, rather than which control was pressed, so everything that opens
|
||||
* [closing] is the difference between the two ends of that, and they want different answers.
|
||||
* Shutting a row leaves almost nothing of it, so the point under the finger is the only place
|
||||
* the closed row can sensibly appear -- a row whose top is far off the screen is the case that
|
||||
* settles it, since holding either edge throws the card the length of the screen away from the
|
||||
* hand that shut it. Opening one is the opposite: the row is small, every point in it is within
|
||||
* a heading's height of both edges, and what the reader wants held is the edge they pressed --
|
||||
* so the touch is rounded to the nearer of the two rather than splitting the difference and
|
||||
* carrying the heading part of the way up an unbounded expansion.
|
||||
*
|
||||
* Where the touch landed decides it, rather than which control was pressed, so everything
|
||||
* behaves the same way whether or not it has a control at each end.
|
||||
*
|
||||
* The correction itself belongs to the measurement -- see [holdTopEdge].
|
||||
* The correction itself belongs to the measurement -- see [holdTouchedPoint].
|
||||
*/
|
||||
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding {
|
||||
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
|
||||
fun toggleAnchored(row: TranscriptRow, closing: Boolean, toggle: () -> Unit) = expanding {
|
||||
if (lastTouch.key == row.key) {
|
||||
val share = if (closing) lastTouch.share else if (lastTouch.share > 0.5f) 1f else 0f
|
||||
// Nothing to correct at zero: the bottom edge is what the list holds on its own.
|
||||
if (share > 0f) {
|
||||
touchHeld.key = row.key
|
||||
touchHeld.share = share
|
||||
}
|
||||
}
|
||||
toggle()
|
||||
}
|
||||
|
||||
@@ -1584,25 +1611,33 @@ fun SessionScreen(
|
||||
is TranscriptUnit.Whole -> {
|
||||
val row = unit.row
|
||||
Box(
|
||||
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
||||
// A *request*, not a raw scroll delta: this runs inside
|
||||
// the measure pass that discovered the new height, and
|
||||
// a raw delta forces a synchronous remeasure from
|
||||
// within measure, which is fatal. The request is
|
||||
// applied by the same frame's next remeasure, so the
|
||||
// correction still lands before anything is drawn.
|
||||
// Reads unobserved, or this row's measure would inherit
|
||||
// the scroll position as a dependency and remeasure on
|
||||
// every frame.
|
||||
Modifier.holdTouchedPoint(row.key, touchHeld) { by ->
|
||||
// A *request*, not a raw scroll delta: this runs
|
||||
// inside the measure pass that discovered the new
|
||||
// height, and a raw delta forces a synchronous
|
||||
// remeasure from within measure, which is fatal. The
|
||||
// request is applied by the same frame's next
|
||||
// remeasure, so the correction still lands before
|
||||
// anything is drawn. Reads unobserved, or this row's
|
||||
// measure would inherit the scroll position as a
|
||||
// dependency and remeasure on every frame.
|
||||
//
|
||||
// The offset goes negative on a close, and has to:
|
||||
// that is the list being asked for room below what it
|
||||
// has composed, which is where the newer rows come
|
||||
// from when a card gives a screenful back. Clamping it
|
||||
// at zero -- which is what this did while every
|
||||
// correction was a row *growing* -- leaves a close
|
||||
// uncorrected, and the card lands wherever the list
|
||||
// felt like putting it.
|
||||
Snapshot.withoutReadObservation {
|
||||
listState.requestScrollToItem(
|
||||
listState.firstVisibleItemIndex,
|
||||
(listState.firstVisibleItemScrollOffset + grew)
|
||||
.coerceAtLeast(0),
|
||||
listState.firstVisibleItemScrollOffset + by,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Which half of this row the touch landed in, for
|
||||
// Where down this row the touch landed, for
|
||||
// [toggleAnchored]. On the initial pass and consuming
|
||||
// nothing, so every control inside still gets the gesture;
|
||||
// only visible rows have one, which is what makes a
|
||||
@@ -1615,7 +1650,12 @@ fun SessionScreen(
|
||||
pass = PointerEventPass.Initial,
|
||||
)
|
||||
lastTouch.key = row.key
|
||||
lastTouch.high = down.position.y < size.height / 2f
|
||||
lastTouch.share =
|
||||
1f -
|
||||
(down.position.y / size.height).coerceIn(
|
||||
0f,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -1625,7 +1665,10 @@ fun SessionScreen(
|
||||
group = row,
|
||||
expanded = row.key in expandedGroups,
|
||||
onToggle = {
|
||||
toggleAnchored(row) {
|
||||
toggleAnchored(
|
||||
row,
|
||||
closing = row.key in expandedGroups,
|
||||
) {
|
||||
expandedGroups =
|
||||
if (row.key in expandedGroups)
|
||||
expandedGroups - row.key
|
||||
@@ -1637,7 +1680,10 @@ fun SessionScreen(
|
||||
// call makes the whole group taller, and the
|
||||
// heading the reader is under is the group's.
|
||||
onToolToggle = { id ->
|
||||
toggleAnchored(row) {
|
||||
toggleAnchored(
|
||||
row,
|
||||
closing = id in expandedTools,
|
||||
) {
|
||||
expandedTools =
|
||||
if (id in expandedTools)
|
||||
expandedTools - id
|
||||
@@ -1682,7 +1728,10 @@ fun SessionScreen(
|
||||
tool = item,
|
||||
expanded = item.id in expandedTools,
|
||||
onToggle = {
|
||||
toggleAnchored(row) {
|
||||
toggleAnchored(
|
||||
row,
|
||||
closing = item.id in expandedTools,
|
||||
) {
|
||||
expandedTools =
|
||||
if (item.id in expandedTools)
|
||||
expandedTools - item.id
|
||||
|
||||
Reference in new issue
Block a user