fix(#1247): keep the keyboard up through the whole mic hold (#1399)

This commit is contained in:
Nikhil Nair
2026-09-30 11:17:10 +03:00
committed by GitHub
parent ee1d7a0b75
commit 57be85d2f7
2 changed files with 108 additions and 74 deletions
@@ -219,6 +219,10 @@ class VoiceNoteGestureTest {
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
composeTestRule.mainClock.advanceTimeBy(600) composeTestRule.mainClock.advanceTimeBy(600)
// The strip takes the field's decoration box mid-hold; the field must
// keep its focus so the keyboard never collapses when recording starts
// (device follow-up, #1247).
composeTestRule.onNodeWithTag("chat_input").assertIsFocused()
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
composeTestRule.waitForIdle() composeTestRule.waitForIdle()
@@ -52,6 +52,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@@ -137,20 +138,31 @@ fun ChatInputBar(
// Attachment tray state // Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) } var showAttachmentTray by remember { mutableStateOf(false) }
// The voice-note strip replaces the input field, so the IME closes the // The voice-note strip renders inside the input field's decoration box, so
// moment recording starts. When the strip goes away (send, delete, or // the field stays composed and focused through the whole hold and the
// cancel), focus returns to the input and the keyboard re-opens if it was // keyboard no longer collapses when recording starts (device follow-up,
// up before — the next message must not need an extra tap (device // #1247). The restore below is only a safety net for a focus that still
// follow-up, #1247). // got lost (IME quirk); when the field kept focus, its IME state is left
// exactly as the user left it.
val inputFocusRequester = remember { FocusRequester() } val inputFocusRequester = remember { FocusRequester() }
val softwareKeyboard = LocalSoftwareKeyboardController.current val softwareKeyboard = LocalSoftwareKeyboardController.current
var inputWasFocused by remember { mutableStateOf(false) } var inputFocused by remember { mutableStateOf(false) }
var restoreInputFocus by remember { mutableStateOf(false) } var restoreInputFocus by remember { mutableStateOf(false) }
// Snapshot BEFORE the launcher flips isRecordingVoice — by the time the // While a voice note records, the composer's trailing slots must not
// recomposition swaps the field out, focus is already on its way to the // morph: the send/queue AnimatedVisibility content would be disposed
// root, so the effect alone cannot read it reliably. // under the holding finger, dropping the pointer loop (and with it the
// release). The gesture loop already ignores these slots, so hold-start
// freezes the slot layout synchronously (an effect would land a frame
// late, after the slots already collapsed) and recording-end thaws them.
val holdShowSend = remember { mutableStateOf(false) }
val holdCanInterrupt = remember { mutableStateOf(false) }
// Snapshot at hold start: whether the keyboard was up (for the restore
// safety net) and the trailing slot layout (frozen while recording, see
// above).
val handleMicHoldStart = { val handleMicHoldStart = {
restoreInputFocus = inputWasFocused restoreInputFocus = inputFocused
holdShowSend.value = hasDraft
holdCanInterrupt.value = canInterrupt
onMicHoldStart() onMicHoldStart()
} }
// A hold can arm and still fail to start the recorder (permission denied, // A hold can arm and still fail to start the recorder (permission denied,
@@ -194,6 +206,10 @@ fun ChatInputBar(
.apply() .apply()
} }
} }
val recording = isRecordingVoice
val composerShowSend = if (recording) holdShowSend.value else hasDraft
val composerCanInterrupt = if (recording) holdCanInterrupt.value else canInterrupt
val palette = composerPalette() val palette = composerPalette()
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
@@ -347,70 +363,84 @@ fun ChatInputBar(
} }
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
if (isRecordingVoice) { // The field stays composed while a voice note records —
VoiceNoteRecordingPanel( // drawn transparent, with the strip overlaid on top — so
slideProgress = voiceSlideProgress, // focus and the keyboard survive the hold (device
locked = isVoiceNoteLocked, // follow-up, #1247). The overlay is a sibling, not the
onCancel = onMicHoldCancel, // field's decoration content: decorationBox content does
modifier = Modifier.weight(1f), // not size the field, so the strip would be clipped to
) // the empty field's line height and vanish (CI, PR
} else { // #1351). Keystrokes mid-hold are swallowed, and the
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { // composer's send/stop slots stay frozen — flipping them
BasicTextField( // under the finger would drop the hold.
value = inputFieldValue, Box(
onValueChange = onInputChange, modifier =
modifier = Modifier
Modifier .weight(1f)
.weight(1f) .heightIn(min = 42.dp),
.heightIn(min = 42.dp, max = 200.dp) ) {
.padding(vertical = 4.dp) BasicTextField(
.focusRequester(inputFocusRequester) value = inputFieldValue,
.onFocusChanged { focusState -> onValueChange = { if (!isRecordingVoice) onInputChange(it) },
if (!isRecordingVoice) { modifier =
inputWasFocused = focusState.isFocused Modifier
} .fillMaxWidth()
}.testTag("chat_input"), .alpha(if (isRecordingVoice) 0f else 1f)
enabled = isConnected, .heightIn(min = 42.dp, max = 200.dp)
textStyle = .padding(vertical = 4.dp)
MaterialTheme.typography.bodyLarge.copy( .focusRequester(inputFocusRequester)
color = palette.text, .onFocusChanged { focusState ->
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, inputFocused = focusState.isFocused
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, }.testTag("chat_input"),
), enabled = isConnected,
singleLine = false, textStyle =
maxLines = 8, MaterialTheme.typography.bodyLarge.copy(
cursorBrush = SolidColor(palette.text), color = palette.text,
decorationBox = { innerTextField -> textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
Box( ),
modifier = Modifier.fillMaxWidth(), singleLine = false,
contentAlignment = maxLines = 8,
if (isInputRtl) { cursorBrush = SolidColor(palette.text),
Alignment.CenterEnd decorationBox = { innerTextField ->
} else { Box(
Alignment.CenterStart modifier = Modifier.fillMaxWidth(),
}, contentAlignment =
) { if (isInputRtl) {
CompositionLocalProvider( Alignment.CenterEnd
LocalLayoutDirection provides inputLayoutDirection, } else {
) { Alignment.CenterStart
if (inputFieldValue.text.isEmpty()) { },
Text( ) {
text = placeholderText, CompositionLocalProvider(
style = MaterialTheme.typography.bodyLarge, LocalLayoutDirection provides inputLayoutDirection,
textAlign = ) {
if (isInputRtl) TextAlign.Right else TextAlign.Left, if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
color = palette.placeholder, Text(
maxLines = 1, text = placeholderText,
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth(), textAlign =
) if (isInputRtl) TextAlign.Right else TextAlign.Left,
} color = palette.placeholder,
innerTextField() maxLines = 1,
} overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
} }
innerTextField()
} }
}, }
},
)
if (isRecordingVoice) {
// On top of the transparent field: keeps the
// strip's real 42 dp+ geometry instead of being
// clipped to the empty field's line height.
VoiceNoteRecordingPanel(
slideProgress = voiceSlideProgress,
locked = isVoiceNoteLocked,
onCancel = onMicHoldCancel,
modifier = Modifier.matchParentSize(),
) )
} }
} }
@@ -423,9 +453,9 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel, reasoningLevel = reasoningLevel,
isListening = isListening, isListening = isListening,
canSend = canSend, canSend = canSend,
showSend = hasDraft, showSend = composerShowSend,
onSend = onSend, onSend = onSend,
canInterrupt = canInterrupt, canInterrupt = composerCanInterrupt,
onStopGeneration = onStopGeneration, onStopGeneration = onStopGeneration,
onAttachTap = { showAttachmentTray = !showAttachmentTray }, onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap, onModelTap = onModelTap,