Perform all map navigation operations using only the keyboard.

Use case
Use this pattern when your app must remain fully usable without a pointing device. Supporting keyboard-only pan, zoom, rotate, and identify is a core accessibility requirement for users who rely on assistive technologies or cannot use a mouse, and it also benefits users who prefer keyboard-driven workflows.
How to use the sample
When the sample is launched, a fixed area of interest appears centered over the map, and any features inside it are automatically selected and labeled 1 – 9. As you navigate, the selection and labels update to match the features currently inside the area of interest.
Use the arrow keys to pan and + / - to zoom. Use Shift + ← / → to rotate, with N resetting the map to north. Press 1 – 9 to show a callout for the matching numbered feature, and press C to clear the callout.
How it works
- Create a
Mapwith a basemap and add aFeatureLayer. - Overlay a fixed-size Box on the
MapViewto mark the area of interest. - Listen for
onNavigationChangedto re-run the selection after every pan, zoom, or rotation. - Convert the rectangle’s screen bounds to a map-space
EnvelopeusingMapViewProxy.screenToLocationOrNull. - Build
QueryParameterswith the envelope geometry andSpatialRelationship.Intersects, then callFeatureTable.queryFeatures. - Call
FeatureLayer.SelectFeatureon each returned feature, and add a numberedTextSymbolgraphic to aGraphicsOverlayat each feature’s location. - Handle
KeyEventto show callouts via the number keys and to dismiss the callout on C.
Relevant API
- Envelope
- FeatureLayer
- Graphic
- GraphicsOverlay
- Map
- MapView
About the data
This sample uses a Redlands restaurants feature layer covering food establishments in Redlands, California. Each feature represents a single restaurant.
Additional information
The map view supports built-in keyboard shortcuts for pan (arrow keys), zoom (+ / -), rotate (Shift + ← / →), and reset to north (N). See Navigate a map view for the complete list of built-in interactions.
Tags
accessibility, accessible, identify, inclusive, input, interaction, keyboard, navigation, selection, WCAG
Sample code
/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.components
import android.app.Applicationimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateListOfimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.compose.ui.input.key.Keyimport androidx.compose.ui.unit.IntSizeimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.data.Featureimport com.arcgismaps.data.QueryFeatureFieldsimport com.arcgismaps.data.QueryParametersimport com.arcgismaps.data.ServiceFeatureTableimport com.arcgismaps.data.SpatialRelationshipimport com.arcgismaps.geometry.Envelopeimport com.arcgismaps.geometry.GeometryEngineimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.layers.FeatureLayerimport com.arcgismaps.mapping.symbology.HorizontalAlignmentimport com.arcgismaps.mapping.symbology.SimpleLineSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolStyleimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyleimport com.arcgismaps.mapping.symbology.SimpleRendererimport com.arcgismaps.mapping.symbology.TextSymbolimport com.arcgismaps.mapping.symbology.VerticalAlignmentimport com.arcgismaps.mapping.view.DrawStatusimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.ScreenCoordinateimport com.arcgismaps.toolkit.geoviewcompose.MapViewProxyimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport kotlinx.coroutines.Jobimport kotlinx.coroutines.cancelAndJoinimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.flow.firstimport kotlinx.coroutines.launch
// Fixed size for the area of interest used to identify features around the center of the screen.const val AREA_OF_INTEREST_SIZE = 400F
// Limit the number of selectable features 9 for keyboard navigation (1-9).const val MAX_SELECTABLE_FEATURES = 9
class NavigateMapViewAndIdentifyFeaturesWithKeyboardViewModel(app: Application) : AndroidViewModel(app) {
// Redlands restaurants service feature table. private val restaurantsFeatureTable = ServiceFeatureTable( uri = "https://services2.arcgis.com/ZQgQTuoyBrtmoGdP/arcgis/rest/services/redlands_food/FeatureServer/0" )
// Feature layer to display the restaurants from feature table. private val restaurantsLayer = FeatureLayer.createWithFeatureTable( featureTable = restaurantsFeatureTable ).apply { // Symbolize each restaurant as a filled circle with a white outline. renderer = SimpleRenderer( SimpleMarkerSymbol( style = SimpleMarkerSymbolStyle.Circle, color = Color.restaurantMarkerFill, size = 12f ).apply { outline = SimpleLineSymbol( style = SimpleLineSymbolStyle.Solid, color = Color.white, width = 1.5f ) } ) }
// Create a light gray basemap centered on Redlands using the restaurants layer. val arcGISMap = ArcGISMap(BasemapStyle.ArcGISLightGray).apply { initialViewpoint = Viewpoint( center = Point( x = -117.1825, y = 34.0556, spatialReference = SpatialReference.wgs84() ), scale = 2500.0 ) operationalLayers.add(restaurantsLayer) }
// Create a MapViewProxy to perform identify and screen to location operations. val mapViewProxy = MapViewProxy()
// Overlay for the numbered 1-9 labels corresponding to the selected features. val labelsOverlay = GraphicsOverlay()
// Create a message dialog view model for handling error messages val messageDialogVM = MessageDialogViewModel()
// StateFlow to track the draw status of the MapView. private val _mapViewDrawStatus = MutableStateFlow<DrawStatus>(DrawStatus.InProgress) val mapViewDrawStatus = _mapViewDrawStatus.asStateFlow()
// Show the overflow message when there are more than nine features identified. var isOverflowMessageVisible by mutableStateOf(false) private set
// Index of the currently selected feature in the selectableFeatures list or null if no feature is selected. var selectedFeatureIndex by mutableStateOf<Int?>(null) private set private val selectableFeatures = mutableStateListOf<OrderedFeature>()
// Expose the list of features that can be selected for callout display. val orderedFeatures: List<OrderedFeature> get() = selectableFeatures
// Track the size of the MapView to build the area of interest envelope. private var mapViewSize = IntSize.Zero
// Job for refreshing the identify job to ensure only one job is happening at a time. private var identifyFeaturesJob: Job? = null
init { viewModelScope.launch { arcGISMap.load().onFailure { messageDialogVM.showMessageDialog(it) } mapViewDrawStatus.first { it == DrawStatus.Completed } identifyFeatures() } }
/** * Update the size of the MapView, used to build the area of interest. */ fun updateMapViewSizeAndIdentify(size: IntSize) { val shouldIdentify = mapViewSize == IntSize.Zero && size != IntSize.Zero && _mapViewDrawStatus.value == DrawStatus.Completed mapViewSize = size if (shouldIdentify) { identifyFeatures() } }
/** * Handle changes to the MapView's draw status. */ fun handleDrawStatusChanged(drawStatus: DrawStatus) { _mapViewDrawStatus.value = drawStatus }
/** * Handle changes to the MapView's navigation status, when navigation stops refresh identified features. */ fun refreshFeaturesAfterNavigation(isNavigating: Boolean) { if (!isNavigating) { identifyFeatures() } }
/** * Show a callout for the feature from selectable features list. */ fun selectFeatureForCallout(index: Int): Boolean { if (index !in selectableFeatures.indices) return false selectedFeatureIndex = index return true }
/** * Dismiss the currently shown callout. */ fun dismissCallout() { selectedFeatureIndex = null }
/** * Identify features that intersect with the envelope, * to update selection and labels for identified features, * then update the list of selectable features for callout display. */ private fun identifyFeatures() { val previousIdentifyFeaturesJob = identifyFeaturesJob identifyFeaturesJob = viewModelScope.launch { // Cancel any ongoing identify job. previousIdentifyFeaturesJob?.cancelAndJoin()
// Retrieve the area of interest envelope centered on the screen. val areaOfInterest = buildAreaOfInterestEnvelope() ?: return@launch
// Resets the previous selection state. clearPreviousSelectionState()
// Query for features that intersect with envelope. val queryParameters = QueryParameters().apply { geometry = GeometryEngine.normalizeCentralMeridian(areaOfInterest) spatialRelationship = SpatialRelationship.Intersects returnGeometry = true } val queryResult = restaurantsFeatureTable.queryFeatures( parameters = queryParameters, queryFeatureFields = QueryFeatureFields.LoadAll ).getOrElse { messageDialogVM.showMessageDialog(it) return@launch }
// Order features by their screen position relative to the center of the screen val orderedFeatures = queryResult .mapNotNull { feature -> val point = feature.geometry as? Point ?: return@mapNotNull null val screenCoordinate = mapViewProxy.locationToScreenOrNull(point) ?: return@mapNotNull null OrderedFeature( feature = feature, point = point, name = getFeatureName(feature = feature), screenCoordinate = screenCoordinate ) } .sortedWith( compareBy<OrderedFeature> { it.screenCoordinate.y } .thenBy { it.screenCoordinate.x } )
// Update state if there are more features than the maximum selectable features. isOverflowMessageVisible = orderedFeatures.size > MAX_SELECTABLE_FEATURES
// Update states of the selectable list, selects features, and add labels. orderedFeatures.forEachIndexed { index, orderedFeature -> restaurantsLayer.selectFeature(orderedFeature.feature) if (index >= MAX_SELECTABLE_FEATURES) return@forEachIndexed labelsOverlay.graphics.add( Graphic( geometry = orderedFeature.point, symbol = createLabelSymbol(index + 1, orderedFeature) ) ) selectableFeatures.add(orderedFeature) } } }
/** * Build a fixed size envelope centered on the screen to be used as the area of interest for identifying features. */ private fun buildAreaOfInterestEnvelope(): Envelope? { val halfWidth = AREA_OF_INTEREST_SIZE / 2.0 val centerX = mapViewSize.width / 2.0 val centerY = mapViewSize.height / 2.0 val minPoint = mapViewProxy.screenToLocationOrNull( ScreenCoordinate(x = centerX - halfWidth, y = centerY - halfWidth) ) val maxPoint = mapViewProxy.screenToLocationOrNull( ScreenCoordinate(x = centerX + halfWidth, y = centerY + halfWidth) )
return if (minPoint != null && maxPoint != null) { Envelope(minPoint, maxPoint) } else { null } }
/** * Resets previous selection states for new identify operations. */ private fun clearPreviousSelectionState() { restaurantsLayer.clearSelection() labelsOverlay.graphics.clear() selectableFeatures.clear() isOverflowMessageVisible = false dismissCallout() }
/** * Create a text symbol for labeling identified features with their index and name. */ private fun createLabelSymbol(index: Int, orderedFeature: OrderedFeature): TextSymbol { val labelText = orderedFeature.name?.let { "$index: $it" } ?: index.toString() return TextSymbol( text = labelText, color = Color.labelText, size = 15f, horizontalAlignment = HorizontalAlignment.Center, verticalAlignment = VerticalAlignment.Top ).apply { haloColor = Color.white haloWidth = 2f offsetY = -14f } }
/** * Get the name of the feature from its attributes. */ private fun getFeatureName( feature: Feature, fallbackName: String? = null ): String? { val featureName = feature.attributes.entries .firstOrNull { (key, _) -> key.equals("name", ignoreCase = true) } ?.value ?.toString() ?.trim() return featureName?.takeIf { it.isNotBlank() } ?: fallbackName }}
data class OrderedFeature( val feature: Feature, val point: Point, val name: String?, val screenCoordinate: ScreenCoordinate) { /** * Format the feature details (latitude and longitude) for display in the callout. */ fun formatedFeatureDetails(): String { val wgs84Point = GeometryEngine.projectOrNull( geometry = point, spatialReference = SpatialReference.wgs84() ) ?: return ""
return buildString { appendLine("Lat: ${"%.6f".format(wgs84Point.y)}") append("Lon: ${"%.6f".format(wgs84Point.x)}") } }}
/** * Maps number keys 1-9 to feature indices 0-8. Returns null for non-number keys. */internal fun numberKeyToFeatureIndex(key: Key): Int? = when (key) { Key.One, Key.NumPad1 -> 0 Key.Two, Key.NumPad2 -> 1 Key.Three, Key.NumPad3 -> 2 Key.Four, Key.NumPad4 -> 3 Key.Five, Key.NumPad5 -> 4 Key.Six, Key.NumPad6 -> 5 Key.Seven, Key.NumPad7 -> 6 Key.Eight, Key.NumPad8 -> 7 Key.Nine, Key.NumPad9 -> 8 else -> null}
private val Color.Companion.restaurantMarkerFill: Color get() = fromRgba(11, 79, 138, 255)
private val Color.Companion.labelText: Color get() = fromRgba(31, 35, 40, 255)
internal val Color.Companion.selectionHalo: Color get() = fromRgba(190, 24, 93, 255)/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.screens.NavigateMapViewAndIdentifyFeaturesWithKeyboardScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { SampleAppTheme { Surface(color = MaterialTheme.colorScheme.background) { NavigateMapViewAndIdentifyFeaturesWithKeyboardScreen() } } } }}/* Copyright 2026 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.screens
import android.view.Viewimport android.view.ViewGroupimport androidx.compose.animation.animateContentSizeimport androidx.compose.foundation.borderimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Spacerimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.sizeimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.LaunchedEffectimport androidx.compose.runtime.getValueimport androidx.compose.runtime.snapshotFlowimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.input.key.Keyimport androidx.compose.ui.input.key.KeyEventTypeimport androidx.compose.ui.input.key.keyimport androidx.compose.ui.input.key.onPreviewKeyEventimport androidx.compose.ui.input.key.typeimport androidx.compose.ui.layout.onSizeChangedimport androidx.compose.ui.platform.LocalDensityimport androidx.compose.ui.platform.LocalViewimport androidx.compose.ui.res.stringResourceimport androidx.compose.ui.unit.dpimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.Colorimport com.arcgismaps.LoadStatusimport com.arcgismaps.mapping.view.DrawStatusimport com.arcgismaps.mapping.view.MapViewimport com.arcgismaps.mapping.view.SelectionPropertiesimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.Rimport com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.components.AREA_OF_INTEREST_SIZEimport com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.components.NavigateMapViewAndIdentifyFeaturesWithKeyboardViewModelimport com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.components.numberKeyToFeatureIndeximport com.esri.arcgismaps.sample.navigatemapviewandidentifyfeatureswithkeyboard.components.selectionHaloimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBarimport kotlinx.coroutines.flow.first
/** * Main screen layout for the sample app */@Composablefun NavigateMapViewAndIdentifyFeaturesWithKeyboardScreen( mapViewModel: NavigateMapViewAndIdentifyFeaturesWithKeyboardViewModel = viewModel()) { val loadStatus by mapViewModel.arcGISMap.loadStatus.collectAsStateWithLifecycle() val drawStatus by mapViewModel.mapViewDrawStatus.collectAsStateWithLifecycle() val areaOfInterestSize = with(LocalDensity.current) { AREA_OF_INTEREST_SIZE.toDp() } val selectedOrderedFeature = mapViewModel.selectedFeatureIndex ?.let(mapViewModel.orderedFeatures::getOrNull)
val sampleHostView = LocalView.current // Await the ArcGISMap and MapView to be fully loaded and drawn // then request focus on the MapView to enable keyboard navigation. LaunchedEffect(Unit) { snapshotFlow { loadStatus } .first { it is LoadStatus.Loaded }
snapshotFlow { drawStatus } .first { it == DrawStatus.Completed }
sampleHostView.findDescendantMapView()?.requestFocus() }
Scaffold( topBar = { SampleTopAppBar(title = stringResource(R.string.navigate_map_view_and_identify_features_with_keyboard_app_name)) }, content = { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding) ) { // Box containing the MapView and centered area of interest indicator. Box( modifier = Modifier .fillMaxSize() .weight(1f) .animateContentSize() .onPreviewKeyEvent { keyEvent -> if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (keyEvent.key) { Key.C -> { // Dismiss the callout when C is pressed, if displayed. mapViewModel.dismissCallout() true }
else -> { // Show callout for the feature corresponding to number keys 1-9. numberKeyToFeatureIndex(keyEvent.key)?.let { index -> mapViewModel.selectFeatureForCallout(index) } ?: false } } } ) { MapView( modifier = Modifier .fillMaxSize() .onSizeChanged(mapViewModel::updateMapViewSizeAndIdentify), canFocus = true, arcGISMap = mapViewModel.arcGISMap, mapViewProxy = mapViewModel.mapViewProxy, graphicsOverlays = listOf(mapViewModel.labelsOverlay), selectionProperties = SelectionProperties(color = Color.selectionHalo), onDrawStatusChanged = mapViewModel::handleDrawStatusChanged, onNavigationChanged = mapViewModel::refreshFeaturesAfterNavigation, content = { selectedOrderedFeature?.let { orderedFeature -> Callout(location = orderedFeature.point) { Column { Text( text = orderedFeature.name ?: "Restaurant", style = MaterialTheme.typography.titleSmall ) Spacer(modifier = Modifier.size(4.dp)) Text( text = orderedFeature.formatedFeatureDetails(), style = MaterialTheme.typography.bodySmall ) } } } } )
if (selectedOrderedFeature == null) { // Area of interest rounded box indicator for feature selection. Box( modifier = Modifier .align(Alignment.Center) .size(areaOfInterestSize) .border( width = 2.dp, color = MaterialTheme.colorScheme.outline, shape = RoundedCornerShape(4.dp) ) ) } } // Bottom text instructions for the sample. SampleInstructions(isOverflowMessageVisible = mapViewModel.isOverflowMessageVisible) }
mapViewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } } } )}
@Composableprivate fun SampleInstructions( modifier: Modifier = Modifier, isOverflowMessageVisible: Boolean) { Column( modifier = modifier .fillMaxWidth() .padding(12.dp) .animateContentSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = "Pan (Arrow Keys) and zoom (+ and - ) to bring restaurants into the area of interest. Press 1-9 for details, C to close Callout.", style = MaterialTheme.typography.bodyMedium ) if (isOverflowMessageVisible) { Text( text = "Too many features in the area. Zoom in to see fewer.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error ) } }}
/** * Recursively search the view hierarchy for a [MapView] instance. */private fun View.findDescendantMapView(): MapView? = when (this) { is MapView -> this is ViewGroup -> (0 until childCount).firstNotNullOfOrNull { getChildAt(it).findDescendantMapView() } else -> null}