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
+699 -554

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,14 +95,16 @@ 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.
SettingsScreen(
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.Main
},
onBack = null,
)
Box(Modifier.imePadding()) {
SettingsScreen(
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.Main
},
onBack = null,
)
}
return
}
@@ -148,19 +152,25 @@ 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 ->
MainScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
)
Box(Modifier.imePadding()) {
MainScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
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,23 +182,27 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
}
is Screen.Spawn ->
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToMain,
)
Box(Modifier.imePadding()) {
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToMain,
)
}
is Screen.Settings ->
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToMain()
},
onBack = goToMain,
)
Box(Modifier.imePadding()) {
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToMain()
},
onBack = goToMain,
)
}
}
// Last, so it draws over the screen above rather than under it: these are stacked in the Box