The keyboard stops hiding the messages

The app sits inside an imePadding(), because windowSoftInputMode=adjustResize
stopped doing anything at targetSdk 35. So an opening keyboard SHORTENS the
message list rather than covering it, and a LazyColumn holds its offset from
the top: the newest messages slide up out of the shorter viewport and end up
behind the composer. The keyboard hid exactly the messages somebody opened it
to answer.

The list follows the inset now. Keyed on its height rather than on a
visible/hidden flag, and scrolling without animation, because the inset arrives
as a series of values while the keyboard slides and one animated jump fired at
the start of that lands short by however much the viewport shrank afterwards.

The test reads the source, because exercising this needs a real IME and a JVM
test has none. It guards the follow against deletion; it does not prove the
behaviour, which takes a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-15 14:30:27 -04:00
co-authored by Claude Opus 5
parent 55d7feb302
commit 7b4251c5d6
2 changed files with 93 additions and 1 deletions
@@ -16,7 +16,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -68,6 +71,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -1588,7 +1592,7 @@ private fun ColumnScope.StrangerBanner(
}
/** One conversation. */
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun ColumnScope.ChatScreen(
model: MessengerViewModel,
@@ -1627,6 +1631,23 @@ fun ColumnScope.ChatScreen(
LaunchedEffect(chat.lines.size) {
if (chat.lines.isNotEmpty()) listState.animateScrollToItem(chat.lines.lastIndex)
}
// And follow the keyboard, which is the other thing that moves the bottom
// of the list.
//
// The whole app is inside an `imePadding()` (MainActivity), so an opening
// keyboard shortens this LazyColumn rather than covering it. A LazyColumn
// holds its offset from the TOP, so the newest messages slide up out of
// the shorter viewport and sit behind the composer: the keyboard appears
// to hide exactly the messages somebody just opened it to answer.
//
// Keyed on the inset's height rather than on a visible/hidden flag, and
// scrolling without animation, because the inset arrives as a series of
// values while the keyboard slides. One animated jump fired at the start
// of that lands short by however much the viewport shrank afterwards.
val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
LaunchedEffect(imeBottom) {
if (chat.lines.isNotEmpty()) listState.scrollToItem(chat.lines.lastIndex)
}
// One bar, not three stacked rows.
Row(
@@ -0,0 +1,71 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* The conversation stays pinned to its newest message when the keyboard opens.
*
* The app sits inside an `imePadding()`, so an opening keyboard SHORTENS the
* message list rather than covering it. A LazyColumn holds its offset from the
* top, so without this the newest messages slide up out of the shorter
* viewport and end up behind the composer: the keyboard hides exactly the
* messages somebody opened it to answer.
*
* This reads the source because the behaviour needs a real IME to exercise and
* a JVM test has none. It is a guard against the follow being deleted, not a
* proof that it works; that takes a device.
*/
class TheKeyboardDoesNotHideTheMessagesTest {
private val screens =
File("src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt").let {
assertTrue(it.isFile, "this test reads app/src/main/kotlin/.../ChatScreens.kt")
it.readText()
}
private val activity =
File("src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt").let {
assertTrue(it.isFile, "this test reads app/src/main/kotlin/.../MainActivity.kt")
it.readText()
}
@Test
fun `the app is laid out above the keyboard rather than under it`() {
assertTrue(
activity.contains(".imePadding()"),
"without imePadding the keyboard draws over the composer; " +
"windowSoftInputMode=adjustResize stopped doing this at targetSdk 35",
)
}
@Test
fun `the message list follows the keyboard`() {
assertTrue(
screens.contains("WindowInsets.ime.getBottom(LocalDensity.current)"),
"nothing reads the keyboard's height, so nothing can react to it",
)
val follow = screens.substringAfter("val imeBottom = ", "")
assertTrue(follow.isNotEmpty(), "imeBottom is not declared")
assertTrue(
follow.substringBefore("}").contains("LaunchedEffect(imeBottom)"),
"imeBottom is read but no effect is keyed on it",
)
}
@Test
fun `it scrolls without animating`() {
// The inset arrives as a series of values while the keyboard slides.
// One animated jump fired at the start of that lands short by however
// much the viewport shrank afterwards.
val block = screens.substringAfter("LaunchedEffect(imeBottom) {").substringBefore("}")
assertTrue(
block.contains("listState.scrollToItem("),
"the keyboard follow should scroll instantly: $block",
)
assertTrue(
!block.contains("animateScrollToItem"),
"an animated scroll cannot keep up with the inset animation: $block",
)
}
}