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:
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
Reference in new issue
Block a user