Handle physical keyboard keys on the scaffold state that is rendered
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Build Nightly APK / build (push) Has been cancelled

The key event handler was installed on the activity together with the state
instance it captured, so it could keep talking to a state that is no longer the
one that is drawn. That happens in practice: the scaffold state is recreated
when one of its rememberSaveable keys changes, which it does right after the
launcher starts (the window is measured, and the state is restored after the
configuration change), and the activity then still holds the handler of the
previous state. Typing on the home screen set the search query and started the
page transition on the detached state, so the search page never appeared, while
the query showed up in the search bar.

The handler is now created without the state as a key and asks for the current
state through a provider, and the activity's handler is refreshed on every
composition instead of only when the handler instance changes.

Verified on the Titan 2: typing on the home screen opens the search page with
the character inserted, further characters append, the text field stays
unfocused and the on-screen keyboard stays hidden.
This commit is contained in:
2026-09-10 21:44:46 +02:00
parent e004862b00
commit 5c1e9c5bf2
3 changed files with 30 additions and 11 deletions

View File

@@ -34,8 +34,8 @@ android {
applicationId = "de.mm20.launcher2"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091002
versionName = "1.40.2-typing.3"
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091003
versionName = "1.40.2-typing.4"
signingConfig = signingConfigs.getByName("debug")
}

View File

@@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -1074,14 +1075,25 @@ internal fun LauncherScaffold(
// Open the search when the user starts typing on a physical keyboard. The handler is
// installed on the activity, so it also receives key events while no text field is focused.
val typeToSearchHandler = rememberTypeToSearchHandler(state, searchVM)
DisposableEffect(typeToSearchHandler) {
val launcherActivity = activity as? SharedLauncherActivity
launcherActivity?.launcherKeyEventHandler = typeToSearchHandler
//
// The scaffold state is recreated when one of its keys changes - the window size after a
// rotation, or the state that rememberSaveable restores after the activity was recreated -
// so the handler must not keep a reference to one particular instance: it reads the current
// state through this holder, which the composition keeps up to date, and the activity's
// handler is refreshed on every composition. Without that, the handler can end up opening
// the search on a state that is no longer the one that is rendered, which looks as if the
// key press did nothing.
val currentState = remember { mutableStateOf(state) }
SideEffect {
if (currentState.value !== state) currentState.value = state
}
val typeToSearchHandler = rememberTypeToSearchHandler({ currentState.value }, searchVM)
SideEffect {
(activity as? SharedLauncherActivity)?.launcherKeyEventHandler = typeToSearchHandler
}
DisposableEffect(Unit) {
onDispose {
if (launcherActivity?.launcherKeyEventHandler === typeToSearchHandler) {
launcherActivity.launcherKeyEventHandler = null
}
(activity as? SharedLauncherActivity)?.launcherKeyEventHandler = null
}
}

View File

@@ -58,6 +58,9 @@ internal fun KeyEvent.isEnter(): Boolean {
* [SharedLauncherActivity.launcherKeyEventHandler]. It is called for every key event before the
* event is dispatched to the view hierarchy, so it also works while no text field has focus.
*
* The scaffold state the handler works on is passed as a provider rather than as a value, because
* the state is replaced when the window size changes or the launcher is recreated.
*
* While the search is driven by the physical keyboard, the search bar's text field is deliberately
* left unfocused: a focused text field would make the system show the on-screen keyboard, and the
* user is typing on a physical keyboard anyway. The handler therefore inserts and deletes the
@@ -68,7 +71,7 @@ internal fun KeyEvent.isEnter(): Boolean {
*/
@Composable
internal fun rememberTypeToSearchHandler(
state: LauncherScaffoldState,
stateProvider: () -> LauncherScaffoldState,
searchVM: SearchVM,
): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope()
@@ -76,8 +79,12 @@ internal fun rememberTypeToSearchHandler(
val sheetManager = LocalBottomSheetManager.current
val overlayManager = LocalOverlayManager.current
return remember(state, searchVM, activity, sheetManager, overlayManager) {
// Deliberately not keyed on the scaffold state: the state is recreated when the window is
// resized or the launcher is recreated, and a key must always be handled by the state that is
// rendered at that moment. stateProvider hands out that state.
return remember(searchVM, activity, sheetManager, overlayManager) {
fun handle(event: KeyEvent): Boolean {
val state = stateProvider()
// Read the preference here rather than capturing it in the composition, so that the
// current value is always used.
if (!searchVM.searchOnTyping.value || state.isLocked) return false