Use a routing service to navigate between two points.

Use case
Navigation is often used by field workers while traveling between two points to get live directions based on their location.
How to use the sample
Tap ‘Navigate Route’ to simulate travelling and to receive directions from a preset starting point to a preset destination. Tap ‘Recenter’ to focus on the simulated location, or press ‘Navigate Route’ again to restart navigation.
How it works
- Create a
RouteTaskusing a URL to an online route service. - Generate default
RouteParametersusingrouteTask.createDefaultParameters(). - Set
returnStopsandreturnDirectionson the parameters to true. - Add
Stops to the parametersstopscollection for each destination. - Solve the route using
routeTask.solveRoute(routeParameters)to get aRouteResult. - Create a
RouteTrackerusing the route result, and the index of the desired route to take. - Create a
RouteTrackerLocationDataSourcewith the route tracker and simulated location data source to snap the location display to the route. - Collect location updates using
MapView.LocationDisplay.Location - Get the
TrackingStatususing theRouteTracker.trackingStatus.valueand use it to display updated route information. Tracking status includes a variety of information on the route progress, such as the remaining distance, remaining geometry or traversed geometry (represented by aPolyline), or the remaining time (Double), amongst others. - Collect new voice guidance updates using
RouteTracker.newVoiceGuidanceto get theVoiceGuidancewhenever new instructions are available. From the voice guidance, get theStringrepresenting the directions and use a text-to-speech engine to output the maneuver directions. - To establish whether the destination has been reached, get the
DestinationStatusfrom the tracking status. If the destination status isDestinationStatus.Reached, and theremainingDestinationCountis 1, we have arrived at the destination and can stop routing. If there are several destinations in your route, and the remaining destination count is greater than 1, switch the route tracker to the next destination.
Relevant API
- DestinationStatus
- Location
- LocationDataSource
- Route
- RouteParameters
- RouteTask
- RouteTracker
- Stop
- VoiceGuidance
About the data
The route taken in this sample goes from the San Diego Convention Center, site of the annual Esri User Conference, to the Fleet Science Center, San Diego.
Additional information
This sample uses the GeoView-Compose Toolkit module to be able to implement a composable MapView.
Tags
directions, geoview-compose, maneuver, navigation, route, toolkit, turn-by-turn, voice
Sample Code
/* Copyright 2024 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.navigateroute
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.runtime.Composableimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.esri.arcgismaps.sample.navigateroute.screens.NavigateRouteScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // authentication with an API key or named user is // required to access basemaps and other location services ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ACCESS_TOKEN)
enableEdgeToEdge() setContent { SampleAppTheme { NavigateRouteApp() } } }
@Composable private fun NavigateRouteApp() { Surface(color = MaterialTheme.colorScheme.background) { NavigateRouteScreen( sampleName = getString(R.string.navigate_route_app_name) ) } }}/* Copyright 2024 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.navigateroute.components
import android.app.Applicationimport android.speech.tts.TextToSpeechimport android.text.format.DateUtilsimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.core.content.ContextCompatimport androidx.core.content.ContextCompat.getStringimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.Polylineimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.location.LocationDisplayAutoPanModeimport com.arcgismaps.location.RouteTrackerLocationDataSourceimport com.arcgismaps.location.SimulatedLocationDataSourceimport com.arcgismaps.location.SimulationParametersimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.symbology.SimpleLineSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolStyleimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolimport com.arcgismaps.mapping.symbology.SimpleMarkerSymbolStyleimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.LocationDisplayimport com.arcgismaps.navigation.DestinationStatusimport com.arcgismaps.navigation.RouteTrackerimport com.arcgismaps.navigation.TrackingStatusimport com.arcgismaps.tasks.networkanalysis.RouteResultimport com.arcgismaps.tasks.networkanalysis.RouteTaskimport com.arcgismaps.tasks.networkanalysis.Stopimport com.arcgismaps.toolkit.geoviewcompose.MapViewProxyimport com.esri.arcgismaps.sample.navigateroute.Rimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport kotlinx.coroutines.Jobimport kotlinx.coroutines.cancelAndJoinimport kotlinx.coroutines.flow.filterimport kotlinx.coroutines.launchimport java.time.Instantimport java.util.concurrent.atomic.AtomicBoolean
class NavigateRouteViewModel(application: Application) : AndroidViewModel(application) {
val map = ArcGISMap(BasemapStyle.ArcGISStreets)
// graphics overlay to display the route ahead and traveled graphics val graphicsOverlay = GraphicsOverlay()
// generate a route with directions and stops for navigation private val routeTask = RouteTask(getString(application, R.string.routing_service_url))
// destination list of stops for the RouteParameters private val routeStops = listOf( // San Diego Convention Center Stop(Point(-117.160386, 32.706608, SpatialReference.wgs84())), // USS San Diego Memorial Stop(Point(-117.173034, 32.712327, SpatialReference.wgs84())), // RH Fleet Aerospace Museum Stop(Point(-117.147230, 32.730467, SpatialReference.wgs84())) )
// passed to the composable MapView to set the mapViewProxy val mapViewProxy = MapViewProxy()
// keep track of the the location display job when navigation is enabled private var locationDisplayJob: Job? = null
// default location display object, which is updated by rememberLocationDisplay private var locationDisplay: LocationDisplay = LocationDisplay()
private var routeResult: RouteResult? = null
// instance of the route ahead polyline private var routeAheadGraphic: Graphic = Graphic()
// instance of the route traveled polyline private var routeTraveledGraphic: Graphic = Graphic()
var distanceRemainingText by mutableStateOf("") private set
var timeRemainingText by mutableStateOf("") private set
var nextDirectionText by mutableStateOf("") private set
var nextStopText by mutableStateOf("") private set
var isNavigateButtonEnabled by mutableStateOf(true) private set
var isRecenterButtonEnabled by mutableStateOf(false) private set
// boolean to check if Android text-speech is initialized private var isTextToSpeechInitialized = AtomicBoolean(false)
// instance of Android text-speech private var textToSpeech: TextToSpeech? = null
// create a ViewModel to handle dialog interactions val messageDialogVM: MessageDialogViewModel = MessageDialogViewModel()
init { // create text-to-speech to replay navigation voice guidance textToSpeech = TextToSpeech(application) { status -> if (status != TextToSpeech.ERROR) { textToSpeech?.language = getApplication<Application>().resources.configuration.locales[0] isTextToSpeechInitialized.set(true) } }
viewModelScope.launch { // load and set the route parameters val routeParameters = routeTask.createDefaultParameters().getOrElse { return@launch messageDialogVM.showMessageDialog( title = "Error creating default parameters", description = it.message.toString() ) }.apply { setStops(routeStops) returnDirections = true returnStops = true returnRoutes = true }
// get the solved route result routeResult = routeTask.solveRoute(routeParameters).getOrElse { return@launch messageDialogVM.showMessageDialog( title = "Error solving route", description = it.message.toString() ) }
// reset navigation to initial state resetNavigation() }
}
/** * Start the navigation along the provided route using the [routeResult] and * collects updates in the location using the MapView's location display. * */ fun startNavigation() { val routeResult = routeResult ?: return messageDialogVM.showMessageDialog("Error retrieving route result") // get the route's geometry from the route result val routeGeometry: Polyline = routeResult.routes[0].routeGeometry ?: return messageDialogVM.showMessageDialog("Route is missing geometry")
// set up a simulated location data source which simulates movement along the route val simulationParameters = SimulationParameters( Instant.now(), velocity = 35.0, horizontalAccuracy = 5.0, verticalAccuracy = 5.0 )
// create the simulated data source using the geometry and parameters val simulatedLocationDataSource = SimulatedLocationDataSource( polyline = routeGeometry, parameters = simulationParameters )
// set up a RouteTracker for navigation along the calculated route val routeTracker = RouteTracker( routeResult = routeResult, routeIndex = 0, skipCoincidentStops = true ).apply { setSpeechEngineReadyCallback { isTextToSpeechInitialized.get() && textToSpeech?.isSpeaking == false } }
// create a route tracker location data source to snap the location display to the route val routeTrackerLocationDataSource = RouteTrackerLocationDataSource( routeTracker = routeTracker, locationDataSource = simulatedLocationDataSource )
locationDisplayJob = with(viewModelScope) { launch { // automatically enable recenter button when navigation pan is disabled locationDisplay.autoPanMode.filter { it == LocationDisplayAutoPanMode.Off } .collect { isRecenterButtonEnabled = true } }
launch { // set the simulated location data source as the location data source for this app locationDisplay.dataSource = routeTrackerLocationDataSource
// start the location data source locationDisplay.dataSource.start().getOrElse { messageDialogVM.showMessageDialog( title = "Error starting location data source", description = it.message.toString() ) }
// set the auto pan to navigation mode locationDisplay.setAutoPanMode(LocationDisplayAutoPanMode.Navigation)
// data source has started, display stop message nextStopText = getStringArray(R.array.stop_message)[0]
// plays the direction voice guidance updateVoiceGuidance(routeTracker)
launch { // zoom in the scale to focus on the navigation route mapViewProxy.setViewpointScale(10000.0) } }
launch { // listen for changes in location locationDisplay.location.collect { // get the route's tracking status val trackingStatus = routeTracker.trackingStatus.value ?: return@collect // displays the remaining and traversed route updateRouteGraphics(trackingStatus) // display route status and directions info displayRouteInfo(routeTracker, trackingStatus) // disable navigation button isNavigateButtonEnabled = false } } } }
/** * Displays the route distance and time information using [trackingStatus], and * switches destinations using [routeTracker]. When final destination is reached, * the location data source is stopped. */ private suspend fun displayRouteInfo( routeTracker: RouteTracker, trackingStatus: TrackingStatus ) { // get remaining distance information val remainingDistance = trackingStatus.destinationProgress.remainingDistance // convert remaining minutes to hours:minutes:seconds val remainingTimeString = DateUtils.formatElapsedTime((trackingStatus.destinationProgress.remainingTime * 60).toLong())
// update text views timeRemainingText = getString(R.string.time_remaining) + " " + remainingTimeString distanceRemainingText = getString(R.string.distance_remaining) + " " + remainingDistance.displayText + " " + remainingDistance.displayTextUnits.abbreviation
// if a destination has been reached if (trackingStatus.destinationStatus == DestinationStatus.Reached) { // if there are more destinations to visit. Greater than 1 because the start point is considered a "stop" if (trackingStatus.remainingDestinationCount > 1) { // switch to the next destination routeTracker.switchToNextDestination().getOrElse { return messageDialogVM.showMessageDialog( title = "Error retrieving next destination", description = it.message.toString() ) } // set second stop message nextStopText = getStringArray(R.array.stop_message)[1] } else { // the final destination has been reached, // stop the location data source locationDisplay.dataSource.stop() // set last stop message nextStopText = getStringArray(R.array.stop_message)[2] } } }
/** * Update the remaining and traveled route graphics using [trackingStatus] */ private fun updateRouteGraphics(trackingStatus: TrackingStatus) { trackingStatus.routeProgress.let { // set geometries for the route ahead and the remaining route routeAheadGraphic.geometry = it.remainingGeometry routeTraveledGraphic.geometry = it.traversedGeometry } }
/** * Initialize and add route travel graphics to the map using [routeResult]'s [Polyline] geometry. */ private fun createRouteGraphics() { // clear any graphics from the current graphics overlay graphicsOverlay.graphics.clear()
// set the map view view point to show the whole route val routeGeometry = routeResult?.routes?.get(0)?.routeGeometry
// create a graphic (with a dashed line symbol) to represent the route routeAheadGraphic = Graphic( routeGeometry, SimpleLineSymbol( SimpleLineSymbolStyle.Dash, Color(getColorArgb(com.esri.arcgismaps.sample.sampleslib.R.color.colorPrimary)), 3f ) )
// create a graphic (solid) to represent the route that's been traveled (initially empty) routeTraveledGraphic = Graphic( routeGeometry, SimpleLineSymbol( SimpleLineSymbolStyle.Solid, Color.cyan, 3f ) )
val stopGraphics = routeStops.map { Graphic( geometry = it.geometry, symbol = SimpleMarkerSymbol( style = SimpleMarkerSymbolStyle.Circle, color = Color.red, size = 10f ) ) }
// add the graphics to the mapView's graphics overlays graphicsOverlay.graphics.addAll( listOf(routeAheadGraphic, routeTraveledGraphic) + stopGraphics ) }
/** * Resets the navigation back to the initial state by stopping the * [locationDisplay]'s datasource and cancels related coroutine tasks. */ fun resetNavigation() { // reset the navigation if button is clicked viewModelScope.launch { if (locationDisplayJob?.isActive == true) { // stop location data sources locationDisplay.dataSource.stop() // reset location display auto-pan-mode locationDisplay.setAutoPanMode(LocationDisplayAutoPanMode.Off) // cancel the coroutine job locationDisplayJob?.cancelAndJoin() } // set the map view view point to show the whole route routeResult?.routes?.get(0)?.routeGeometry?.extent?.let { mapViewProxy.setViewpoint(Viewpoint(it.extent)) } mapViewProxy.setViewpointRotation(0.0) createRouteGraphics()
isNavigateButtonEnabled = true } }
/** * Uses Android's [textToSpeech] to speak to say the latest * voice guidance from the [routeTracker] out loud. */ private suspend fun updateVoiceGuidance(routeTracker: RouteTracker) { // listen for new voice guidance events routeTracker.newVoiceGuidance.collect { voiceGuidance -> // use Android's text to speech to speak the voice guidance textToSpeech?.speak(voiceGuidance.text, TextToSpeech.QUEUE_FLUSH, null, null) // set next direction text nextDirectionText = getString(R.string.next_direction) + " " + voiceGuidance.text } }
fun recenterNavigation() { locationDisplay.setAutoPanMode(LocationDisplayAutoPanMode.Navigation) isRecenterButtonEnabled = false }
private fun getString(id: Int): String { return getApplication<Application>().resources.getString(id) }
private fun getStringArray(id: Int): Array<out String> { return getApplication<Application>().resources.getStringArray(id) }
private fun getColorArgb(id: Int): Int { return ContextCompat.getColor(getApplication(), id) }
fun setLocationDisplay(locationDisplay: LocationDisplay) { this.locationDisplay = locationDisplay }}/* Copyright 2024 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.navigateroute.screens
import androidx.compose.animation.AnimatedVisibilityimport androidx.compose.animation.animateContentSizeimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.OutlinedButtonimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplayimport com.esri.arcgismaps.sample.navigateroute.components.NavigateRouteViewModelimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar
/** * Main screen layout for the sample app */@Composablefun NavigateRouteScreen(sampleName: String) { val locationDisplay = rememberLocationDisplay() val mapViewModel: NavigateRouteViewModel = viewModel<NavigateRouteViewModel>().apply { setLocationDisplay(locationDisplay) }
Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, content = { Column( modifier = Modifier .fillMaxSize() .padding(it) ) { MapView( modifier = Modifier .fillMaxSize() .weight(1f) .animateContentSize(), arcGISMap = mapViewModel.map, mapViewProxy = mapViewModel.mapViewProxy, graphicsOverlays = listOf(mapViewModel.graphicsOverlay), locationDisplay = locationDisplay )
mapViewModel.apply { NavigateRouteOptions( isNavigateEnabled = isNavigateButtonEnabled, isRecenterEnabled = isRecenterButtonEnabled, onNavigateClicked = ::startNavigation, onRecenterClicked = ::recenterNavigation, onResetClicked = ::resetNavigation )
AnimatedVisibility(!isNavigateButtonEnabled) { NavigationRouteInfo( nextStopText, distanceRemainingText, timeRemainingText, nextDirectionText ) }
// display a MessageDialog if the sample encounters an error messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } } } } } )}
@Composablefun NavigationRouteInfo( nextStopText: String, distanceRemainingText: String, timeRemainingText: String, nextDirectionText: String) { Column( modifier = Modifier .fillMaxWidth() .padding(12.dp) .animateContentSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = nextStopText, style = MaterialTheme.typography.bodyMedium )
Text( text = distanceRemainingText, style = MaterialTheme.typography.bodyMedium )
Text( text = timeRemainingText, style = MaterialTheme.typography.bodyMedium )
Text( text = nextDirectionText, style = MaterialTheme.typography.bodyMedium ) }}
@Composablefun NavigateRouteOptions( isNavigateEnabled: Boolean, onNavigateClicked: () -> Unit, isRecenterEnabled: Boolean, onRecenterClicked: () -> Unit, onResetClicked: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { OutlinedButton( enabled = isNavigateEnabled, onClick = onNavigateClicked ) { Text("Navigate") }
OutlinedButton( enabled = isRecenterEnabled, onClick = onRecenterClicked ) { Text("Recenter") }
OutlinedButton( onClick = onResetClicked ) { Text("Reset") } }}