Stop the keyboard re-laying-out the whole screen, and float the composer

Opening the keyboard was late on 82% of frames on the Pixel while the
transcript itself cost 0.25ms of each -- the cost was everywhere else.
imePadding() sat on the activity's root box, so every frame of the IME
animation resized the entire tree: measured on the emulator (with the
new app-root timers) as a full re-measure (4.1ms), re-place (2.5ms) and
re-record (1.0ms) of everything, ~34 frames per open, while the newly
added recomposition counters read zero -- pure layout traversal, no
recomposition to fix.

So the keyboard now touches only what actually moves. The session
screen's composer (status row, suggestions, attachments, text field,
buttons) is a bottom-aligned overlay on its own layer, translated by
the IME inset read inside the graphicsLayer block -- a keyboard frame
invalidates layer properties only. The transcript box reserves the
overlay's measured height plus imePadding, and that modifier is the
whole of what the keyboard re-measures: the box's own size never
changes, so the header and everything above it are untouched. The
overlay is opaque for the one frame between it growing and the
reservation catching up. imePadding moved off the activity root onto
AppRoot's other screens, which keep the old arrangement -- none of
them has a keyboard open over anything that scrolls at 120Hz.

Same five-open protocol on the emulator, before and after: the app
root is now measured zero times (was 168), per-frame app work
7.6ms -> 1.9ms (transcript measure 1.2 + place 0.5 + record 0.2),
draw-phase p90 9.6ms -> 5.0ms, waited p50 2.6ms -> 0.4ms. What is
left per frame is the transcript's own one-box remeasure, whose
children skip measurement because their width is unchanged.

Verified the states the overlay could have broken: keyboard over a
long and a two-message conversation (content hangs from the composer
in both), a three-line draft growing the composer upward with the
reserve following, slash suggestions stacking above the field, and
the closed state identical to before. The app-root timers and the
recomposition counters stay in: they are the difference between this
report saying "draw is high" and saying where.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-31 14:49:44 -04:00
1 parent 93ce66c5f0
commit 9477cd288a
4 files changed
+184 -39

No files matched your search

@@ -1,6 +1,8 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
@@ -93,6 +95,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR
// path lands in MainActivity and recomposes from the top.
Box(Modifier.imePadding()) {
SettingsScreen(
existing = null,
onSaved = { saved ->
@@ -101,6 +104,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
},
onBack = null,
)
}
return
}
@@ -148,8 +152,13 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
)
}
// Every screen but the session takes the keyboard as bottom padding here. The session
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
// animation is the cost that made it lag, so it moves only its composer and transcript --
// see the layout note in SessionScreen.
when (val here = screen) {
is Screen.Main ->
Box(Modifier.imePadding()) {
MainScreen(
settings = current,
reloadToken = reloadToken,
@@ -161,6 +170,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
},
onSettings = { screen = Screen.Settings },
)
}
is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
@@ -172,6 +182,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
}
is Screen.Spawn ->
Box(Modifier.imePadding()) {
SpawnScreen(
settings = current,
onSpawned = { spawned ->
@@ -180,7 +191,9 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
},
onBack = goToMain,
)
}
is Screen.Settings ->
Box(Modifier.imePadding()) {
SettingsScreen(
existing = current,
onSaved = { saved ->
@@ -190,6 +203,7 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
onBack = goToMain,
)
}
}
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session
@@ -11,7 +11,6 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
@@ -21,7 +20,9 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
@@ -91,14 +92,52 @@ class MainActivity : ComponentActivity() {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
Modifier.fillMaxSize()
// Timed like the transcript times itself, and for the same reason:
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation
// relays out and re-records this whole box.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the app root",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the app root",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: the app root",
System.nanoTime() - started,
)
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most
// phones. Without it the send row sits under
// the swipe area, where a tap is as likely to
// navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of
// the frame budget. Each screen takes the keyboard itself
// (AppRoot wraps the ordinary ones; the session screen moves
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
.navigationBarsPadding()
.imePadding()
) {
AppRoot(settingsVersion, openRequest)
}
@@ -8,15 +8,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -42,6 +47,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,10 +59,12 @@ import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -624,6 +632,7 @@ private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
@Composable
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
DebugStats.count("session screen recomposed")
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
@@ -1356,6 +1365,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) }
// The composer floats over the bottom of the screen instead of sitting under the transcript
// in one column, and the keyboard moves it by a layer translation rather than by relayout.
// With everything in one column under a root imePadding, every frame of the keyboard
// animation re-measured, re-placed and re-recorded the entire screen -- measured on the
// emulator at ~7.6ms of main-thread work per frame across ~34 frames per open, and on the
// Pixel as 82% late frames while the transcript itself cost 0.25ms. Scoped this way, a
// keyboard frame costs one layer transform for the composer and one re-measure of the
// transcript box, whose children skip measurement (width unchanged) and whose rows are
// already layers.
var composerHeight by remember { mutableIntStateOf(0) }
val imeInsets = WindowInsets.ime
val navInsets = WindowInsets.navigationBars
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1400,7 +1422,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Left of the numbers about the *conversation*, because it is the same kind of
// thing about the *app*: what this session is costing to draw. It copies rather
// than opens, because what it produces is for somewhere else -- a message to
// whoever is looking at the code -- and a screenful of timings read on the phone
// whoever is looking at the code -- and a screenful of timings read on the
// phone
// is a screenful nobody can act on.
GlyphButton(
SPEED_GLYPH,
@@ -1432,17 +1455,20 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
context.copyToClipboard("ai-app render report", report)
// Also to the log, so a session driving the app over adb can read the
// same report the button copies. The clipboard is not reachable from a
// shell, and a counter nobody can check from here is a counter that only
// shell, and a counter nobody can check from here is a counter that
// only
// gets checked by asking Iris to press a button and paste.
Log.i("ai-app", report)
// Only once it is somewhere it can be read from, so a copy that never
// happened does not throw the stack away with it.
clearCrash(context)
// Emptied by the copy, so pressing it twice measures two separate stretches
// Emptied by the copy, so pressing it twice measures two separate
// stretches
// of scrolling rather than one and then the same one again.
frames.reset()
DebugStats.reset()
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
.show()
},
)
GlyphButton(
@@ -1452,7 +1478,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
colour = usageGlyphColour(usage),
)
// What it opens is about this session, so it sits at the end of the session's
// own row. The name is the whole of what it holds today, which is why it is a cog
// own row. The name is the whole of what it holds today, which is why it is a
// cog
// and not a word: there will be more, and a bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
}
@@ -1481,7 +1508,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// is fully built, and there is no frame in which the transcript is somewhere other
// than where it was left.
val settled = !restoring
Box(Modifier.weight(1f).fillMaxWidth()) {
Box(
Modifier.weight(1f)
.fillMaxWidth()
// The room the floating composer needs, measured off it below -- reserving it
// here is what lets the composer be an overlay without covering the newest
// message -- and then the keyboard's, per frame of its animation. This modifier
// is the whole of what the keyboard re-measures: the box's own size never
// changes, so nothing above it is touched.
.padding(bottom = with(LocalDensity.current) { composerHeight.toDp() })
.imePadding()
) {
Box(Modifier.fillMaxSize()) {
TranscriptList(
units = units,
@@ -1490,8 +1527,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
modifier =
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
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, and not
// 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, and
// not
// yet taken in themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow].
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
@@ -1525,7 +1564,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
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
// the measure pass that discovered the new height, and
// a
// raw delta forces a synchronous remeasure from within
// measure, which is fatal
// ("performMeasureAndLayout called during measure").
@@ -1545,11 +1585,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
// Which half of this row the touch landed in, for
// [toggleAnchored].
// On the initial pass and consuming nothing, so every control
// On the initial pass and consuming nothing, so every
// control
// inside
// still gets the gesture exactly as it would have; only visible
// still gets the gesture exactly as it would have; only
// visible
// rows
// have one, which is what makes a detector per row affordable.
// have one, which is what makes a detector per row
// affordable.
.pointerInput(row.key) {
awaitEachGesture {
val down =
@@ -1576,15 +1619,18 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
},
isToolExpanded = { it in expandedTools },
// Anchored on the group, not the call: opening one call
// Anchored on the group, not the call: opening one
// call
// makes
// the whole group taller, and the heading the reader is
// the whole group taller, and the heading the
// reader is
// under
// is the group's.
onToolToggle = { id ->
toggleAnchored(row) {
expandedTools =
if (id in expandedTools) expandedTools - id
if (id in expandedTools)
expandedTools - id
else expandedTools + id
}
},
@@ -1614,12 +1660,19 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
is TranscriptItem.AssistantMsg ->
// A whole assistant row is only ever the reply
// still
// arriving -- every settled reply is flattened into
// block units instead; see [transcriptUnits]. Live
// arriving -- every settled reply is flattened
// into
// block units instead; see [transcriptUnits].
// Live
// is
// what earns its blocks a layer each while deltas
// what earns its blocks a layer each while
// deltas
// land.
AssistantMessage(item.text, replies, live = true)
AssistantMessage(
item.text,
replies,
live = true,
)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
@@ -1670,11 +1723,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
item.text,
style = MaterialTheme.typography.bodySmall,
color =
MaterialTheme.colorScheme.onSurfaceVariant,
MaterialTheme.colorScheme
.onSurfaceVariant,
)
is TranscriptItem.CommandRow -> CommandBubble(item.text)
is TranscriptItem.CommandRow ->
CommandBubble(item.text)
is TranscriptItem.ClearedNote -> ClearedRow()
is TranscriptItem.CompactedNote -> CompactedRow(item)
is TranscriptItem.CompactedNote ->
CompactedRow(item)
is TranscriptItem.PeerNote ->
PeerMessageRow(
item = item,
@@ -1705,12 +1761,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
//
// In the middle of the transcript rather than at either end, because it is not
// reporting on the newest message or the oldest; it is standing in for all of them.
// `settled` and not `restoring` alone, so the spinner covers the whole wait: fetching
// the history a saved position needs, and then the frames between those rows arriving
// and the layout that measures them putting the position back. They are the two halves
// `settled` and not `restoring` alone, so the spinner covers the whole wait:
// fetching
// the history a saved position needs, and then the frames between those rows
// arriving
// and the layout that measures them putting the position back. They are the two
// halves
// of the same wait and the transcript is not drawn for either.
if (!ready || !settled) {
CircularProgressIndicator(Modifier.align(Alignment.Center).size(LOADING_SPINNER))
CircularProgressIndicator(
Modifier.align(Alignment.Center).size(LOADING_SPINNER)
)
}
// Only while the newest message is off-screen. Reading back
@@ -1730,7 +1791,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// whose cost grows with how much there is to skip, which is backwards.
//
// Arriving there is all this has to do now. The newest end is where the
// content hangs from, so being at it is the whole of following it, and there
// content hangs from, so being at it is the whole of following it, and
// there
// is no separate flag to set -- which is what this press used to forget,
// landing the reader at the bottom with new messages not bringing the view
// with them.
@@ -1738,9 +1800,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp).semantics {
contentDescription = "Jump to latest"
},
Modifier.align(Alignment.BottomCenter)
.padding(bottom = 12.dp)
.semantics { contentDescription = "Jump to latest" },
) {
Chevron(
pointingUp = false,
@@ -1750,7 +1812,27 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
}
}
}
// Everything from here down floats: bottom-aligned over the transcript, moved up with
// the keyboard by a translation on its own layer. The translation is read inside the
// graphicsLayer block, so a keyboard frame invalidates layer properties only -- no
// measure, no recomposition, no re-recording of anything. Its height is reported to the
// transcript box above, which reserves that much room; the opaque background covers the
// one frame between this growing (a suggestion row, a second draft line) and that
// reservation catching up.
Column(
Modifier.align(Alignment.BottomCenter)
.fillMaxWidth()
.onSizeChanged { composerHeight = it.height }
.graphicsLayer {
translationY =
-(imeInsets.getBottom(this) - navInsets.getBottom(this))
.coerceAtLeast(0)
.toFloat()
}
.background(MaterialTheme.colorScheme.background)
) {
pendingModel?.let { chosen ->
ModelSwitchWarning(
from = modelLabel(model),
@@ -1845,7 +1927,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Asked about first, unless there is nothing to lose by it --
// see [ModelSwitchWarning].
onPick = { chosen ->
if (modelLabel(chosen) == modelLabel(model) || items.isEmpty()) {
if (
modelLabel(chosen) == modelLabel(model) || items.isEmpty()
) {
act { setSessionModel(settings, summary.id, chosen) }
} else {
pendingModel = chosen
@@ -1862,13 +1946,16 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
)
}
// The same filled shape as the button beside it, not an outlined one: these are
// two things you can do about the session, and weighting one of them as secondary
// two things you can do about the session, and weighting one of them as
// secondary
// said they were a primary action and its qualifier. What separates them is the
// colour and the mark, which is what they mean.
//
// Always here, rather than arriving with the turn as it used to. A control that
// comes and goes makes its own presence the signal, and its absence could not say
// whether there was nothing to do; a button that is always in the same place also
// comes and goes makes its own presence the signal, and its absence could not
// say
// whether there was nothing to do; a button that is always in the same place
// also
// cannot push Send off the end of the row by turning up.
val process =
when {
@@ -1895,7 +1982,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Spacer(Modifier.width(8.dp))
// The paper plane, with a clock on it while a turn is in flight: sending then
// queues the message for the next tool boundary rather than starting a turn of
// its own, and the two have to be told apart at a glance. The label says the same
// its own, and the two have to be told apart at a glance. The label says the
// same
// thing to a screen reader, which has nothing else to read.
//
// Disabled while there is nothing to send, rather than pressable and silent:
@@ -1910,12 +1998,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Glyph(
if (running) QUEUE_GLYPH else SEND_GLYPH,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
modifier =
Modifier.semantics { contentDescription = sendLabel(running) },
)
}
}
}
}
}
if (usageOpen) {
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
@@ -2097,6 +2187,7 @@ private fun SessionStatusRow(
contextTokens: Long?,
modifier: Modifier = Modifier,
) {
DebugStats.count("status row recomposed")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
@@ -116,6 +116,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
*/
@Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota