diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 27fce6a..5b6b9fe 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -8,7 +8,6 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -1415,13 +1414,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // is no frame in which the transcript is somewhere other than where it was left. val settled = !restoring && !listState.settling Box(Modifier.weight(1f).fillMaxWidth()) { - BoxWithConstraints(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize()) { TranscriptColumn( rows = rows, state = listState, - // A scrollable child is measured with no height bound, so the content cannot - // ask how tall the visible area is; this is the only place that knows. - viewportHeight = maxHeight, contentPadding = TRANSCRIPT_PADDING, spacing = TRANSCRIPT_SPACING, modifier = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt index 1a8d88c..779d33c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -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() 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, 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, 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.