View on GitHub Sample viewer app

Perform all map navigation operations using only the keyboard.

Image of navigate map view and identify features with 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 19. 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 19 to show a callout for the matching numbered feature, and press C to clear the callout.

How it works

  1. Create a Map with a basemap and add a FeatureLayer.
  2. Overlay a fixed-size Box on the MapView to mark the area of interest.
  3. Listen for onNavigationChanged to re-run the selection after every pan, zoom, or rotation.
  4. Convert the rectangle’s screen bounds to a map-space Envelope using MapViewProxy.screenToLocationOrNull.
  5. Build QueryParameters with the envelope geometry and SpatialRelationship.Intersects, then call FeatureTable.queryFeatures.
  6. Call FeatureLayer.SelectFeature on each returned feature, and add a numbered TextSymbol graphic to a GraphicsOverlay at each feature’s location.
  7. Handle KeyEvent to 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

NavigateMapViewAndIdentifyFeaturesWithKeyboardViewModel.kt NavigateMapViewAndIdentifyFeaturesWithKeyboardViewModel.kt MainActivity.kt NavigateMapViewAndIdentifyFeaturesWithKeyboardScreen.kt
/* 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.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.arcgismaps.Color
import com.arcgismaps.data.Feature
import com.arcgismaps.data.QueryFeatureFields
import com.arcgismaps.data.QueryParameters
import com.arcgismaps.data.ServiceFeatureTable
import com.arcgismaps.data.SpatialRelationship
import com.arcgismaps.geometry.Envelope
import com.arcgismaps.geometry.GeometryEngine
import com.arcgismaps.geometry.Point
import com.arcgismaps.geometry.SpatialReference
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.BasemapStyle
import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.mapping.layers.FeatureLayer
import com.arcgismaps.mapping.symbology.HorizontalAlignment
import com.arcgismaps.mapping.symbology.SimpleLineSymbol
import com.arcgismaps.mapping.symbology.SimpleLineSymbolStyle
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbol
import com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyle
import com.arcgismaps.mapping.symbology.SimpleRenderer
import com.arcgismaps.mapping.symbology.TextSymbol
import com.arcgismaps.mapping.symbology.VerticalAlignment
import com.arcgismaps.mapping.view.DrawStatus
import com.arcgismaps.mapping.view.Graphic
import com.arcgismaps.mapping.view.GraphicsOverlay
import com.arcgismaps.mapping.view.ScreenCoordinate
import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy
import com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import 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)