keyboard jumpy fix

This commit is contained in:
2026-03-03 21:56:13 +00:00
parent 9114b3189e
commit 0c6f0bc2c7
3 changed files with 80 additions and 14 deletions

View File

@@ -17,3 +17,4 @@ See `patterns.md` for detailed design conventions.
- MainScreen uses `@Immutable` data class for ProfileSheetState (good practice) - MainScreen uses `@Immutable` data class for ProfileSheetState (good practice)
- Drag gesture threshold: 60f px for swipe-open/close channel list - Drag gesture threshold: 60f px for swipe-open/close channel list
- LazyListState for channel list is owned by ViewModel (correct — survives recomposition) - LazyListState for channel list is owned by ViewModel (correct — survives recomposition)
- IME insets: enableEdgeToEdge() is active; Scaffold default contentWindowInsets excludes IME; MessageTimeline Column must carry `.imePadding()` before static padding — without it the keyboard overlays the input bar. AndroidView EditText does not auto-participate in Compose IME avoidance. Never use windowSoftInputMode=adjustResize with edge-to-edge.

View File

@@ -19,7 +19,8 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"> android:launchMode="singleTop"
android:windowSoftInputMode="adjustNothing">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -44,6 +45,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
@@ -289,7 +292,8 @@ fun MessageTimeline(
.padding( .padding(
top = contentPadding.calculateTopPadding(), top = contentPadding.calculateTopPadding(),
bottom = contentPadding.calculateBottomPadding(), bottom = contentPadding.calculateBottomPadding(),
), )
.imePadding(),
) { ) {
if (selectedChannel != null) { if (selectedChannel != null) {
if (selectedThread != null) { if (selectedThread != null) {
@@ -1372,18 +1376,78 @@ private fun MessageInput(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
TextField( val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
value = text, val onSurface = MaterialTheme.colorScheme.onSurface
onValueChange = { text = it }, val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) }, val textStyle = MaterialTheme.typography.bodyLarge
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp), val density = LocalDensity.current
maxLines = 8, AndroidView(
colors = TextFieldDefaults.colors( factory = { ctx ->
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, object : android.widget.EditText(ctx) {
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, override fun requestRectangleOnScreen(rect: android.graphics.Rect?, immediate: Boolean): Boolean {
unfocusedIndicatorColor = Color.Transparent, // Disable system scroll-into-view; Compose imePadding() handles it
focusedIndicatorColor = Color.Transparent, return false
), }
}.apply {
hint = "Message #$channelName"
setHintTextColor(onSurfaceVariant.toArgb())
setTextColor(onSurface.toArgb())
setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, textStyle.fontSize.value)
background = android.graphics.drawable.GradientDrawable().apply {
setColor(surfaceVariant.toArgb())
cornerRadius = with(density) { 8.dp.toPx() }
}
setPadding(
with(density) { 16.dp.toPx().toInt() },
with(density) { 12.dp.toPx().toInt() },
with(density) { 16.dp.toPx().toInt() },
with(density) { 12.dp.toPx().toInt() },
)
maxLines = 8
inputType = android.text.InputType.TYPE_CLASS_TEXT or
android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
isSingleLine = false
// Prevent EditText from fighting with Compose's imePadding()
imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI
addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable?) {
text = s?.toString() ?: ""
}
})
androidx.core.view.ViewCompat.setOnReceiveContentListener(
this,
arrayOf("image/*"),
) { _, payload ->
val clip = payload.clip
var remaining = payload
for (i in 0 until clip.itemCount) {
val uri = clip.getItemAt(i).uri
if (uri != null) {
attachedUris = attachedUris + uri
}
}
// Return null to indicate all content was consumed
if (clip.itemCount > 0 && (0 until clip.itemCount).any { clip.getItemAt(it).uri != null }) null
else remaining
}
}
},
update = { editText ->
if (editText.text.toString() != text) {
editText.setText(text)
editText.setSelection(text.length)
}
editText.hint = "Message #$channelName"
},
modifier = Modifier
.weight(1f)
.heightIn(max = 160.dp),
) )
if (emojiPacks.isNotEmpty()) { if (emojiPacks.isNotEmpty()) {
IconButton(onClick = { showEmojiPackPicker = true }) { IconButton(onClick = { showEmojiPackPicker = true }) {