Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() },
Expand Down Expand Up @@ -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) {}
Expand Down Expand Up @@ -215,6 +216,7 @@ public fun GoogleMap(
mapView.tag = null
},
update = { mapView ->
mapView.applyFocusability(focusable)
if (subcompositionJob == null) {
subcompositionJob = parentCompositionScope.launchSubcomposition(
mapUpdaterState,
Expand All @@ -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.
Expand Down
Loading