From 563bccb703ad1e06536ad853c5c89b226b957773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 14 Jul 2026 08:27:57 +0200 Subject: [PATCH 1/3] feat: allow GoogleMap to opt out of keyboard focus traversal Adds a focusable parameter to GoogleMap (default true, preserving the single-tab-stop behavior introduced in #935). When false, the map and its internal controls are removed from keyboard focus traversal, e.g. for maps used as decorative backgrounds behind other focusable content. Fixes #938 --- .../compose/GoogleMapFocusTraversalTests.kt | 44 +++++++++++++++++++ .../google/maps/android/compose/GoogleMap.kt | 35 +++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt index 3fa2bb1f..d00f88fc 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt @@ -16,15 +16,23 @@ package com.google.maps.android.compose +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.unit.dp import com.google.android.gms.maps.model.LatLng import org.junit.Rule import org.junit.Test @@ -77,4 +85,40 @@ class GoogleMapFocusTraversalTests { visibleMaps[1].assertIsFocused() } + + @OptIn(ExperimentalTestApi::class) + @Test + fun nonFocusableMapIsSkippedDuringTabTraversal() { + check(hasValidApiKey) { "Maps API key not specified" } + + var mapLoaded = false + composeTestRule.setContent { + Column { + Button(onClick = {}, modifier = Modifier.testTag("Button1")) { + Text("Button 1") + } + GoogleMap( + modifier = Modifier + .testTag("Map") + .size(200.dp), + focusable = false, + onMapLoaded = { mapLoaded = true }, + ) + Button(onClick = {}, modifier = Modifier.testTag("Button2")) { + Text("Button 2") + } + } + } + + composeTestRule.waitUntil(timeoutMillis = 5_000) { mapLoaded } + + composeTestRule.onNodeWithTag("Button1").requestFocus() + composeTestRule.onNodeWithTag("Button1").assertIsFocused() + + composeTestRule.onNodeWithTag("Button1").performKeyInput { + pressKey(Key.Tab) + } + + composeTestRule.onNodeWithTag("Button2").assertIsFocused() + } } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index a4e4d476..8b463fa1 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -64,6 +64,10 @@ import kotlinx.coroutines.launch * * @param modifier Modifier to be applied to the GoogleMap * @param mergeDescendants deactivates the map for accessibility purposes + * @param focusable whether the map participates in keyboard focus traversal. When true (the + * default), the map acts as a single focus stop so keyboard users can reach and control it. Set + * to false to remove the map from focus traversal entirely, e.g. when the map is used as a + * decorative background behind other focusable content. * @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's * camera state * @param contentDescription the content description for the map used by accessibility services to @@ -88,6 +92,7 @@ import kotlinx.coroutines.launch public fun GoogleMap( modifier: Modifier = Modifier, mergeDescendants: Boolean = false, + focusable: Boolean = true, cameraPositionState: CameraPositionState = rememberCameraPositionState(), contentDescription: String? = null, googleMapOptionsFactory: () -> GoogleMapOptions = { GoogleMapOptions() }, @@ -153,18 +158,14 @@ public fun GoogleMap( AndroidView( // Make the AndroidView wrapper focusable in Compose so the Compose focus - // system can target it during tab traversal. - modifier = modifier.focusable(), + // system can target it during tab traversal, unless the caller opted the map + // out of focus traversal entirely. + modifier = if (focusable) modifier.focusable() else modifier, factory = { context -> val options = googleMapOptionsFactory() cameraPositionState.isLiteMode = options.liteMode == true mapViewFactory(context, options).also { mapView -> - // Treat the MapView as a single focus stop. FOCUS_BEFORE_DESCENDANTS - // ensures the map container gets focused first, and prevents the keyboard - // focus from tabbing through all internal map elements (like zoom buttons - // or the Google logo) by default. - mapView.isFocusable = true - mapView.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS + mapView.applyFocusability(focusable) val componentCallbacks = object : ComponentCallbacks2 { override fun onConfigurationChanged(newConfig: Configuration) {} @@ -215,6 +216,7 @@ public fun GoogleMap( mapView.tag = null }, update = { mapView -> + mapView.applyFocusability(focusable) if (subcompositionJob == null) { subcompositionJob = parentCompositionScope.launchSubcomposition( mapUpdaterState, @@ -227,6 +229,23 @@ public fun GoogleMap( }) } +/** + * When [focusable] is true, treat the MapView as a single focus stop. + * FOCUS_BEFORE_DESCENDANTS ensures the map container gets focused first, and prevents the + * keyboard focus from tabbing through all internal map elements (like zoom buttons or the + * Google logo) by default. + * When [focusable] is false, remove the map and all of its internal elements from keyboard + * focus traversal altogether. + */ +private fun MapView.applyFocusability(focusable: Boolean) { + isFocusable = focusable + descendantFocusability = if (focusable) { + ViewGroup.FOCUS_BEFORE_DESCENDANTS + } else { + ViewGroup.FOCUS_BLOCK_DESCENDANTS + } +} + /** * Create and apply the [content] compositions to the map + * dispose the [Composition] when the parent composable is disposed. From ba79b26924bc4f195381e016dcd11a651a1d4153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 14 Jul 2026 10:11:40 +0200 Subject: [PATCH 2/3] test: don't wait for map load in focus traversal test Focus traversal only requires the composed view hierarchy, not loaded map tiles; waiting on onMapLoaded made the test flaky on CI emulators. Wait for the map's semantics node instead, matching the existing traversal test. --- .../android/compose/GoogleMapFocusTraversalTests.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt index d00f88fc..95d3d725 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt @@ -91,7 +91,6 @@ class GoogleMapFocusTraversalTests { fun nonFocusableMapIsSkippedDuringTabTraversal() { check(hasValidApiKey) { "Maps API key not specified" } - var mapLoaded = false composeTestRule.setContent { Column { Button(onClick = {}, modifier = Modifier.testTag("Button1")) { @@ -102,7 +101,6 @@ class GoogleMapFocusTraversalTests { .testTag("Map") .size(200.dp), focusable = false, - onMapLoaded = { mapLoaded = true }, ) Button(onClick = {}, modifier = Modifier.testTag("Button2")) { Text("Button 2") @@ -110,7 +108,14 @@ class GoogleMapFocusTraversalTests { } } - composeTestRule.waitUntil(timeoutMillis = 5_000) { mapLoaded } + // Focus traversal only requires the view hierarchy to be composed and attached, + // not the map tiles to have loaded. + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule + .onAllNodesWithTag("Map", useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + } composeTestRule.onNodeWithTag("Button1").requestFocus() composeTestRule.onNodeWithTag("Button1").assertIsFocused() From 866526eeb7958552259cf2d31ab7faf55cb22db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 14 Jul 2026 11:46:52 +0200 Subject: [PATCH 3/3] test: use focusable boxes instead of Buttons in traversal test Material Buttons are clickable-based and only accept focus in keyboard mode; requestFocus on them fails in the touch-mode test environment. Plain Modifier.focusable() elements accept focus in both modes, same as the map itself. --- .../compose/GoogleMapFocusTraversalTests.kt | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt index 95d3d725..3c6dea96 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapFocusTraversalTests.kt @@ -16,11 +16,11 @@ package com.google.maps.android.compose +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Button -import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag @@ -93,18 +93,27 @@ class GoogleMapFocusTraversalTests { composeTestRule.setContent { Column { - Button(onClick = {}, modifier = Modifier.testTag("Button1")) { - Text("Button 1") - } + // Plain focusable() elements are used instead of Buttons because + // clickable-based components only accept focus in keyboard mode, + // and the test starts in touch mode. + Box( + Modifier + .testTag("Item1") + .size(48.dp) + .focusable() + ) GoogleMap( modifier = Modifier .testTag("Map") .size(200.dp), focusable = false, ) - Button(onClick = {}, modifier = Modifier.testTag("Button2")) { - Text("Button 2") - } + Box( + Modifier + .testTag("Item2") + .size(48.dp) + .focusable() + ) } } @@ -117,13 +126,13 @@ class GoogleMapFocusTraversalTests { .isNotEmpty() } - composeTestRule.onNodeWithTag("Button1").requestFocus() - composeTestRule.onNodeWithTag("Button1").assertIsFocused() + composeTestRule.onNodeWithTag("Item1").requestFocus() + composeTestRule.onNodeWithTag("Item1").assertIsFocused() - composeTestRule.onNodeWithTag("Button1").performKeyInput { + composeTestRule.onNodeWithTag("Item1").performKeyInput { pressKey(Key.Tab) } - composeTestRule.onNodeWithTag("Button2").assertIsFocused() + composeTestRule.onNodeWithTag("Item2").assertIsFocused() } }