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..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,15 +16,23 @@ 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.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,54 @@ class GoogleMapFocusTraversalTests { visibleMaps[1].assertIsFocused() } + + @OptIn(ExperimentalTestApi::class) + @Test + fun nonFocusableMapIsSkippedDuringTabTraversal() { + check(hasValidApiKey) { "Maps API key not specified" } + + composeTestRule.setContent { + Column { + // 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, + ) + Box( + Modifier + .testTag("Item2") + .size(48.dp) + .focusable() + ) + } + } + + // 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("Item1").requestFocus() + composeTestRule.onNodeWithTag("Item1").assertIsFocused() + + composeTestRule.onNodeWithTag("Item1").performKeyInput { + pressKey(Key.Tab) + } + + composeTestRule.onNodeWithTag("Item2").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.