From 5c1e9c5bf2af862a02a03d2c034f3ef076bbb8f6 Mon Sep 17 00:00:00 2001 From: Jonas Haugesen Date: Thu, 10 Sep 2026 21:44:46 +0200 Subject: [PATCH] Handle physical keyboard keys on the scaffold state that is rendered 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. --- app/app/build.gradle.kts | 4 +-- .../ui/launcher/scaffold/LauncherScaffold.kt | 26 ++++++++++++++----- .../launcher/searchbar/TypeToSearchHandler.kt | 11 ++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 798d6028c..0a0d0a258 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -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") } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt index 4ee10e18a..cdfe921f5 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt @@ -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 } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/TypeToSearchHandler.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/TypeToSearchHandler.kt index 7706f373f..415a3fb41 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/TypeToSearchHandler.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/TypeToSearchHandler.kt @@ -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