Use a route displayed in the real world to navigate.

Use case
It can be hard to navigate using 2D maps in unfamiliar environments. You can use full-scale AR to show a route overlaid on the real-world for easier navigation.
How to use the sample
The app opens with a map centered on your current location. Tap the map or use your current location to set a start point and a destination. The route will be calculated and displayed as a line. Tap the “Navigate in augmented reality” button. Follow the route displayed in the AR view. Directions will be provided as you progress.
How it works
- The map page is used to plan the route before starting navigation in augmented reality. See the Find route sample for a more focused demonstration of that workflow.
- Pass the resulting
Routeto the augmented reality view model used to augment reality with navigation information. - Add a
WorldScaleSceneViewcomposable to the augmented reality screen, available in the ArcGIS Maps SDK for Kotlin toolkit.- The component is available both in
World trackingandGeospatial trackingmodes. Geospatial tracking uses street view data to calibrate augmented reality positioning and is available with an ARCORE API key.
- The component is available both in
- Get a list of
DirectionManeuvers from theRoute(solved in 2D) and add z values to the route’s geometry using Esri’sElevationSource.fromTerrain3dService(). - Using heading and pitch information calculated from one point to the next, create a
ModelSceneSymbolGraphicoriented towards then next point.- Arrows that represent a turn are also given a roll of 90 to stand upright and animated for greater visibility.
- Create a
RouteTrackerto track the user’s location and provide navigation instructions. - On location updates from the
RouteTracker, determine the closest arrowGraphicto the user’s location and change the graphic’s behind the user to be partly opaque. - A UI slider adjusts the number of the arrow
Graphics drawn ahead of the user. Too many graphics can clutter the UI and be confusing when shown behind real world objects. - A calibration view is provided by the
WorldScaleSceneViewto adjust the heading of the camera when inWorld trackingmode.
Relevant API
- LocationDataSource
- ModelSceneSymbol
- RouteResult
- RouteTask
- RouteTracker
- Surface
- WorldScaleSceneView
About the data
This sample uses Esri’s world elevation service to ensure that route lines are placed appropriately in 3D space. It uses Esri’s world routing service to calculate routes. The world routing service requires an API key and does consume ArcGIS Online credits.
Additional information
This sample requires a device that is compatible with ARCore.
Unlike other scene samples, there’s no need for a basemap while navigating, because context is provided by the camera feed showing the real environment. The base surface’s opacity is set to zero to prevent it from interfering with the AR experience.
This sample uses the WorldScaleSceneView toolkit component. For information about setting up the toolkit, as well as code for the underlying component, visit the toolkit docs.
Note that apps using ARCore must comply with ARCore’s user privacy requirements. See this page for more information.
Tags
augmented reality, directions, full-scale, guidance, mixed reality, navigate, navigation, real-scale, route, routing, world-scale
Sample Code
/* Copyright 2025 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.augmentrealitytonavigateroute
import android.content.Intentimport android.os.Bundleimport com.esri.arcgismaps.sample.sampleslib.DownloaderActivity
class DownloadActivity : DownloaderActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) downloadAndStartSample( Intent(this, MainActivity::class.java), // get the app name of the sample getString(R.string.augment_reality_to_navigate_route_app_name), // ArcGIS Portal item containing the arrow graphic listOf( "https://arcgisruntime.maps.arcgis.com/home/item.html?id=248ea5112c8a46ee97fe3b8603d1e1dd" ) ) }}/* Copyright 2025 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.augmentrealitytonavigateroute
import android.Manifestimport android.os.Bundleimport android.widget.Toastimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.activity.result.contract.ActivityResultContractsimport androidx.compose.runtime.Composableimport androidx.navigation.compose.rememberNavControllerimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.components.SharedRepositoryimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.navigation.AugmentRealityToNavigateRouteNavGraphimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme
class MainActivity : ComponentActivity() {
private var isLocationPermissionGranted = false
private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { isLocationPermissionGranted = true } else { Toast.makeText(this, "Location permission is required to run this sample!", Toast.LENGTH_SHORT).show() }
enableEdgeToEdge() setContent { SampleAppTheme { AugmentRealityToNavigateRoute(isLocationPermissionGranted) } } }
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) ArcGISEnvironment.applicationContext = applicationContext
val hasNonDefaultAPIKey = BuildConfig.GOOGLE_API_KEY != "DEFAULT_GOOGLE_API_KEY"
SharedRepository.updateHasNonDefaultAPIKey(hasNonDefaultAPIKey) SharedRepository.updateRoute(null)
requestLocationPermission() }
private fun requestLocationPermission() { requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
}
@Composablefun AugmentRealityToNavigateRoute(isLocationPermissionGranted: Boolean) { val navController = rememberNavController() AugmentRealityToNavigateRouteNavGraph( navController = navController, isLocationPermissionGranted = isLocationPermissionGranted, )}/* Copyright 2025 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.augmentrealitytonavigateroute.components
import android.app.Applicationimport android.speech.tts.TextToSpeechimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableIntStateOfimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.geometry.AngularUnitimport com.arcgismaps.geometry.GeodeticCurveTypeimport com.arcgismaps.geometry.GeodeticDistanceResultimport com.arcgismaps.geometry.Geometryimport com.arcgismaps.geometry.GeometryEngineimport com.arcgismaps.geometry.LinearUnitimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.Polylineimport com.arcgismaps.geometry.PolylineBuilderimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.location.RouteTrackerLocationDataSourceimport com.arcgismaps.location.SystemLocationDataSourceimport com.arcgismaps.mapping.ArcGISSceneimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.ElevationSourceimport com.arcgismaps.mapping.symbology.ModelSceneSymbolimport com.arcgismaps.mapping.symbology.SceneSymbolAnchorPositionimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.SurfacePlacementimport com.arcgismaps.navigation.RouteTrackerimport com.arcgismaps.tasks.networkanalysis.DirectionManeuverTypeimport com.arcgismaps.tasks.networkanalysis.Routeimport com.arcgismaps.tasks.networkanalysis.RouteResultimport com.arcgismaps.toolkit.ar.WorldScaleSceneViewProxyimport com.arcgismaps.toolkit.ar.WorldScaleVpsAvailabilityimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.Rimport kotlinx.coroutines.delayimport kotlinx.coroutines.launchimport java.io.Fileimport java.lang.Math.toDegreesimport java.util.concurrent.atomic.AtomicBooleanimport kotlin.math.absimport kotlin.math.atan2
class AugmentedRealityViewModel(app: Application) : AndroidViewModel(app) {
val worldScaleSceneViewProxy = WorldScaleSceneViewProxy()
var isVpsAvailable by mutableStateOf(false)
// Path to the model file val provisionPath: String by lazy { app.getExternalFilesDir(null)?.path.toString() + File.separator + app.getString( R.string.augment_reality_to_navigate_route_app_name ) + File.separator }
// Create a symbol of a taxi using the model file val arrowSymbol = ModelSceneSymbol( uri = provisionPath + "arrow.FBX", scale = 10F, ).apply { anchorPosition = SceneSymbolAnchorPosition.Bottom }
// Boolean to check if Android text-speech is initialized private var isTextToSpeechInitialized = AtomicBoolean(false)
// Instance of Android text-speech private var textToSpeech: TextToSpeech? = null
// Mutable variables used in the UI var currentGraphicsShown by mutableIntStateOf(5) var nextDirectionText: String by mutableStateOf("")
// Create a scene with an elevation source and grid and surface hidden val arcGISScene = ArcGISScene(BasemapStyle.ArcGISHumanGeography).apply { baseSurface.elevationSources.add(ElevationSource.fromTerrain3dService()) baseSurface.backgroundGrid.isVisible = false baseSurface.opacity = 0.0f // hide the background }
// Route result passed from the route view model via the repository val routeResult = SharedRepository.route
// Graphics overlay for the route ahead val routeAheadGraphicsOverlay = GraphicsOverlay().apply { sceneProperties.surfacePlacement = SurfacePlacement.Absolute }
// Graphics overlay for the route behind val routeBehindGraphicsOverlay = GraphicsOverlay().apply { sceneProperties.surfacePlacement = SurfacePlacement.Absolute opacity = 0.5f }
// List of all graphics, used to find the closest graphic on location changes val routeAllGraphics: MutableList<Graphic> = mutableListOf()
// The current closest graphic var currentClosestGraphic: Graphic? = null
init { routeResult?.let { routeResult -> viewModelScope.launch { drawRoute(routeResult.routes.first()) } setupRouteTracker(routeResult, app) } }
/** * Draws route graphics in augmented reality with turn arrows stood up like a billboard and other arrows lying flat. */ suspend fun drawRoute(route: Route) { // Loop through all the direction maneuvers and draw the route route.directionManeuvers.forEachIndexed { i, maneuver -> // If the maneuver is a stop if (maneuver.geometry is Point && maneuver.maneuverType == DirectionManeuverType.Stop) { val thisPoint = addZValuesGeometry(maneuver.geometry as Point) as Point // Check there are enough direction maneuvers to get the previous point if (route.directionManeuvers.size > 1) { // Get the second to last point of the previous maneuver. The last point is coincident with the stop. val previousPoint = (route.directionManeuvers[i - 1].geometry as? Polyline)?.parts?.last()?.points?.toList() ?.takeLast(2)?.first() previousPoint?.let { previousPoint -> val distanceInformation = GeometryEngine.distanceGeodeticOrNull( point1 = thisPoint, point2 = previousPoint, distanceUnit = LinearUnit.meters, azimuthUnit = AngularUnit.degrees, curveType = GeodeticCurveType.Geodesic ) val headingToPreviousPoint = calculateHeading(distanceInformation) drawArrow( position = thisPoint, heading = headingToPreviousPoint, pitch = -90f, roll = 90f, animate = true ) } } } else if (maneuver.geometry is Polyline) { val densifiedPolyline = GeometryEngine.densifyGeodeticOrNull( geometry = maneuver.geometry as Polyline, maxSegmentLength = 15.0, lengthUnit = LinearUnit.meters, curveType = GeodeticCurveType.Geodesic ) as Polyline val polylineWithElevation = addZValuesGeometry(densifiedPolyline) as Polyline polylineWithElevation.parts.forEach { part -> var previousPoint = part.points.first() var previousHeading = 0f var isFirstPoint = true part.points.drop(1).forEach { thisPoint -> val distanceInformation = GeometryEngine.distanceGeodeticOrNull( point1 = previousPoint, point2 = thisPoint, distanceUnit = LinearUnit.meters, azimuthUnit = AngularUnit.degrees, curveType = GeodeticCurveType.Geodesic ) val headingToNextPoint = calculateHeading(distanceInformation) val pitchToNextPoint = calculatePitch(previousPoint, thisPoint, distanceInformation) // If the first point of a non-straight maneuver or if the heading change is more than 30 // degrees, set the roll to 90 degrees and thus stand the graphic up like a billboard. if ((maneuver.maneuverType != DirectionManeuverType.Straight && isFirstPoint) || (abs( previousHeading - headingToNextPoint ) > 30f) ) { drawArrow( position = previousPoint, heading = headingToNextPoint, pitch = pitchToNextPoint, roll = 0f, animate = true ) } else { drawArrow( position = previousPoint, heading = headingToNextPoint, pitch = pitchToNextPoint, roll = 90f, animate = false ) }
isFirstPoint = false previousPoint = thisPoint previousHeading = headingToNextPoint } } } setNumberOfArrowsVisible(currentGraphicsShown) } }
/** * Draws an arrow at the given position with the specified heading, pitch, and roll and adds it to the graphics * overlay and list of graphics used for closest graphic calculations. Will animate the graphic if specified. */ fun drawArrow(position: Point, heading: Float, pitch: Float, roll: Float, animate: Boolean) { val arrowGraphic = Graphic( geometry = position, symbol = arrowSymbol.apply { this.heading = heading this.pitch = pitch this.roll = roll }.clone() ) // Animate arrow if specified if (animate) { animateModelSceneSymbolScale(arrowGraphic) } routeAheadGraphicsOverlay.graphics.add(arrowGraphic) routeAllGraphics.add(arrowGraphic) }
/** * Adds Z values to the geometry by getting the elevation from the base surface. */ suspend fun addZValuesGeometry(geometry: Geometry): Geometry { if (geometry is Polyline) { val polylineBuilder = PolylineBuilder(SpatialReference.wgs84()) geometry.parts.forEach { part -> part.points.forEach { point -> arcGISScene.baseSurface.elevationSources.first().load().onSuccess { arcGISScene.baseSurface.getElevation(point).let { elevationResult -> elevationResult.getOrNull()?.let { elevation -> polylineBuilder.addPoint( Point( x = point.x, y = point.y, z = elevation, spatialReference = SpatialReference.wgs84() ) ) } } } }
} return polylineBuilder.toGeometry() } else { var point = geometry as Point arcGISScene.baseSurface.elevationSources.first().load().onSuccess { arcGISScene.baseSurface.getElevation(point).let { elevationResult -> elevationResult.getOrNull()?.let { elevation -> point = Point( x = point.x, y = point.y, z = elevation, spatialReference = SpatialReference.wgs84() ) } } } return point } }
/** * Calculates the heading from the distance information. */ fun calculateHeading(distanceInformation: GeodeticDistanceResult?): Float { return distanceInformation?.azimuth1?.toFloat() ?: 0.0f }
/** * Calculates the pitch between two points using the elevation difference and horizontal distance. */ fun calculatePitch(previousPoint: Point, thisPoint: Point, distanceInformation: GeodeticDistanceResult?): Float { val elevationDifference = previousPoint.z?.let { thisPoint.z?.minus(it) } val horizontalDistance = distanceInformation?.distance ?: 0.0 return if (elevationDifference != null && horizontalDistance != 0.0) { toDegrees(atan2(elevationDifference, horizontalDistance)).toFloat() } else { 0.0f } }
/** * Gets the closest graphic to the given location by calculating the distance to each graphic and returning the * closest one. */ suspend fun getClosestGraphic(location: Point): Graphic? { val locationWithZ = addZValuesGeometry(location) as Point return routeAllGraphics.minByOrNull { graphic -> GeometryEngine.distanceGeodeticOrNull( locationWithZ, graphic.geometry as Point, LinearUnit.meters, AngularUnit.degrees, GeodeticCurveType.Geodesic )?.distance ?: Double.MAX_VALUE } }
/** * Update current graphics shown based on input from the UI. */ fun onCurrentGraphicsShownChanged(numGraphics: Int) { currentGraphicsShown = numGraphics setNumberOfArrowsVisible(currentGraphicsShown) }
/** * Set the number of arrows visible in the route ahead graphics overlay. Updated from both the UI and when the list * of graphics in the route ahead graphics overlay changes. */ fun setNumberOfArrowsVisible(numArrows: Int) { routeAheadGraphicsOverlay.graphics.forEachIndexed { index, graphic -> graphic.isVisible = index < numArrows } }
/** * Setup the route tracker to track the route, update graphic visibility and provide voice guidance. */ fun setupRouteTracker(routeResult: RouteResult, app: Application) {
// Create text-to-speech to replay navigation voice guidance textToSpeech = TextToSpeech(app) { status -> if (status != TextToSpeech.ERROR) { textToSpeech?.language = getApplication<Application>().resources.configuration.locales[0] isTextToSpeechInitialized.set(true) } }
with(viewModelScope) { // Set a route tracker val routeTracker = RouteTracker(routeResult, 0, true).apply { setSpeechEngineReadyCallback { isTextToSpeechInitialized.get() && textToSpeech?.isSpeaking == false } }.apply { launch { // Listen for new voice guidance events 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 = voiceGuidance.text } } } // Setup location data sources launch { // Start a new system location data source val systemLocationDataSource = SystemLocationDataSource().also { it.start() } // Start a route tracker location data source to snap the location display to the route RouteTrackerLocationDataSource( routeTracker = routeTracker, locationDataSource = systemLocationDataSource ).also { it.start() } // Collect location changes from the system location data source to update the route tracker systemLocationDataSource.locationChanged.collect { location -> routeTracker.trackLocation(location) } } // Collect tracking status changes to update the closest graphic launch { routeTracker.trackingStatus.collect { trackingStatus -> // Get the current position of the route tracker val currentPosition = trackingStatus?.locationOnRoute?.position // Get the closest graphic to the current position currentPosition?.let { currentClosestGraphic = getClosestGraphic(it) } // Move the closest graphic from the route ahead graphics overlay to the route behind // graphics overlay if (routeAheadGraphicsOverlay.graphics.contains(currentClosestGraphic)) { val closestGraphicIndex = routeAheadGraphicsOverlay.graphics.indexOf(currentClosestGraphic) if (closestGraphicIndex != -1) { // Select all graphics up to the closest graphic val graphicsToMove = routeAheadGraphicsOverlay.graphics.subList(0, closestGraphicIndex).toSet() if (graphicsToMove.isNotEmpty()) { // Move the graphics to the route behind graphics overlay routeAheadGraphicsOverlay.graphics.removeAll(graphicsToMove) routeBehindGraphicsOverlay.graphics.addAll(graphicsToMove) // Update the visibility of the graphics in the route ahead graphics overlay setNumberOfArrowsVisible(currentGraphicsShown) } } } } } } }
/** * Animate the model scene symbol scale using a sine wave function. */ fun animateModelSceneSymbolScale(arrowGraphic: Graphic) { viewModelScope.launch { val animationDuration = 2000L val frameRate = 20 val frameDelay = 1000L / frameRate val totalFrames = (animationDuration / frameDelay).toInt()
val symbol = arrowGraphic.symbol as ModelSceneSymbol
while (true) { for (frame in 0 until totalFrames) { val progress = frame.toFloat() / totalFrames val scaleFactor = 1 + 0.2 * kotlin.math.sin(2 * kotlin.math.PI * progress) symbol.height = 1 * scaleFactor symbol.depth = 2 * scaleFactor delay(frameDelay) } } } }
/** * Checks if the current viewpoint camera location is within the VPS availability area. */ fun onCurrentViewpointCameraChanged(location: Point) { viewModelScope.launch { worldScaleSceneViewProxy.checkVpsAvailability(location.y, location.x).onSuccess { isVpsAvailable = it == WorldScaleVpsAvailability.Available } } }}/* Copyright 2025 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.augmentrealitytonavigateroute.components
import android.app.Applicationimport androidx.compose.runtime.MutableStateimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.geometry.Pointimport com.arcgismaps.location.LocationDisplayAutoPanModeimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport 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.tasks.networkanalysis.RouteParametersimport com.arcgismaps.tasks.networkanalysis.RouteTaskimport com.arcgismaps.tasks.networkanalysis.Stopimport kotlinx.coroutines.launch
class RouteViewModel(app: Application) : AndroidViewModel(app) {
val arcGISMap = ArcGISMap(BasemapStyle.ArcGISTopographic) private val stopGraphicsOverlay = GraphicsOverlay() private val routeGraphicsOverlay = GraphicsOverlay() val graphicsOverlays = listOf(stopGraphicsOverlay, routeGraphicsOverlay)
var startPoint: Point? = null private var endPoint: Point? = null
private val _statusText = mutableStateOf("Tap on map or use current location to create start point") val statusText: MutableState<String> = _statusText
var isCurrentLocationAsStartButtonEnabled by mutableStateOf(false)
// Generate a route with directions and stops for navigation private val routeTask = RouteTask("https://route-api.arcgis.com/arcgis/rest/services/World/Route/NAServer/Route_World") private var routeParameters = RouteParameters()
init { viewModelScope.launch { routeTask.load().onSuccess { setupRouteParameters() } } }
/** * Initialize the location display. */ fun initialize(locationDisplay: LocationDisplay) { with(viewModelScope) { launch { locationDisplay.setAutoPanMode(LocationDisplayAutoPanMode.Recenter) locationDisplay.dataSource.start().onSuccess { isCurrentLocationAsStartButtonEnabled = true }.onFailure { _statusText.value = "Failed to start location data source: ${it.message}" } } } }
/** * Set up the route parameters with default values. */ private fun setupRouteParameters() { viewModelScope.launch { routeParameters = routeTask.createDefaultParameters().getOrNull()!!.apply { travelMode = routeTask.getRouteTaskInfo().travelModes.firstOrNull { it.name.contains("Walking") } returnDirections = true returnStops = true returnRoutes = true } } }
/** * Add start and stop points to the route. Once both have been added, calculate the route. */ fun addRoutePoint(point: Point) { if (startPoint == null) { startPoint = point addStopGraphic(point) _statusText.value = "Tap to place destination." isCurrentLocationAsStartButtonEnabled = false } else if (endPoint == null) { endPoint = point addStopGraphic(point) calculateRoute(Stop(startPoint!!), Stop(endPoint!!)) } }
/** * Add a graphic to the map at the given point. */ private fun addStopGraphic(point: Point) { val symbol = SimpleMarkerSymbol(SimpleMarkerSymbolStyle.Circle, Color.red, 10f) val graphic = Graphic(point, symbol) stopGraphicsOverlay.graphics.add(graphic) }
/** * Calculate the route between the start and end points. */ private fun calculateRoute(start: Stop, end: Stop) { viewModelScope.launch { // Add the start and end points to the route parameters' stops routeParameters.setStops(listOf(start, end)) // Calculate the route val routeResult = routeTask.solveRoute(routeParameters).getOrNull() routeResult?.let { if (SharedRepository.route == null) { // Add the route to the repository so it can be shared with the augmented reality screen SharedRepository.updateRoute(routeResult) } val routeGraphic = Graphic( routeResult.routes.first().routeGeometry, SimpleLineSymbol(SimpleLineSymbolStyle.Solid, Color.yellow, 5f) ) routeGraphicsOverlay.graphics.add(routeGraphic) _statusText.value = "Route ready. Tap to start navigation." } } }}/* Copyright 2025 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.augmentrealitytonavigateroute.components
import androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport com.arcgismaps.tasks.networkanalysis.RouteResult
/** * Shared repository to hold the route result generated in the route view model and passed to the augmented reality view * model. */object SharedRepository {
private var _route by mutableStateOf<RouteResult?>(null) val route get() = _route
private var _hasNonDefaultAPIKey by mutableStateOf(false) val hasNonDefaultAPIKey: Boolean get() = _hasNonDefaultAPIKey
fun updateRoute(route: RouteResult?) { _route = route }
fun updateHasNonDefaultAPIKey(hasNonDefaultAPIKey: Boolean) { _hasNonDefaultAPIKey = hasNonDefaultAPIKey }}/* Copyright 2025 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.augmentrealitytonavigateroute.navigation
import androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport androidx.compose.ui.res.stringResourceimport androidx.navigation.NavHostControllerimport androidx.navigation.compose.NavHostimport androidx.navigation.compose.composableimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.Rimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.screens.AugmentedRealityScreenimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.screens.RouteScreen
@Composablefun AugmentRealityToNavigateRouteNavGraph( navController: NavHostController, modifier: Modifier = Modifier, isLocationPermissionGranted: Boolean) { NavHost( navController = navController, startDestination = "route_screen", modifier = modifier ) { composable("route_screen") { RouteScreen( sampleName = stringResource(R.string.augment_reality_to_navigate_route_app_name), locationPermissionGranted = isLocationPermissionGranted, onNavigateToARScreen = { navController.navigate("ar_screen") } )
} composable("ar_screen") { AugmentedRealityScreen( sampleName = stringResource(R.string.augment_reality_to_navigate_route_app_name) ) } }}/* Copyright 2025 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.augmentrealitytonavigateroute.screens
import android.content.Contextimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.Spacerimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.heightimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.wrapContentSizeimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.MoreVertimport androidx.compose.material.icons.filled.Settingsimport androidx.compose.material3.Buttonimport androidx.compose.material3.Cardimport androidx.compose.material3.CircularProgressIndicatorimport androidx.compose.material3.DropdownMenuimport androidx.compose.material3.DropdownMenuItemimport androidx.compose.material3.ExperimentalMaterial3Apiimport androidx.compose.material3.FloatingActionButtonimport androidx.compose.material3.Iconimport androidx.compose.material3.IconButtonimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.ModalBottomSheetimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Sliderimport androidx.compose.material3.Textimport androidx.compose.material3.TextButtonimport androidx.compose.material3.rememberModalBottomSheetStateimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.saveable.rememberSaveableimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Alignment.Companion.CenterVerticallyimport androidx.compose.ui.Modifierimport androidx.compose.ui.draw.clipimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.platform.LocalContextimport androidx.compose.ui.res.painterResourceimport androidx.compose.ui.text.LinkAnnotationimport androidx.compose.ui.text.SpanStyleimport androidx.compose.ui.text.TextLinkStylesimport androidx.compose.ui.text.buildAnnotatedStringimport androidx.compose.ui.text.withLinkimport androidx.compose.ui.unit.dpimport androidx.compose.ui.window.Dialogimport androidx.core.content.editimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.LoadStatusimport com.arcgismaps.toolkit.ar.WorldScaleSceneViewimport com.arcgismaps.toolkit.ar.WorldScaleSceneViewStatusimport com.arcgismaps.toolkit.ar.WorldScaleTrackingModeimport com.arcgismaps.toolkit.ar.rememberWorldScaleSceneViewStatusimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.Rimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.components.AugmentedRealityViewModelimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.components.SharedRepositoryimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar
private const val KEY_PREF_ACCEPTED_PRIVACY_INFO = "ACCEPTED_PRIVACY_INFO"
@OptIn(ExperimentalMaterial3Api::class)@Composablefun AugmentedRealityScreen( sampleName: String) { val augmentedRealityViewModel: AugmentedRealityViewModel = viewModel()
// Set up the bottom sheet controls val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var showBottomSheet by remember { mutableStateOf(false) }
var displayCalibrationView by remember { mutableStateOf(false) } var initializationStatus by rememberWorldScaleSceneViewStatus()
// Initialize the world scale tracking mode based on whether a google API key is provided val initialWorldScaleTrackingMode = when { SharedRepository.hasNonDefaultAPIKey -> { WorldScaleTrackingMode.Geospatial() } else -> { WorldScaleTrackingMode.World() } }
var trackingMode by remember { mutableStateOf(initialWorldScaleTrackingMode) }
val sharedPreferences = LocalContext.current.getSharedPreferences("", Context.MODE_PRIVATE) var acceptedPrivacyInfo by rememberSaveable { mutableStateOf( sharedPreferences.getBoolean( KEY_PREF_ACCEPTED_PRIVACY_INFO, false ) ) } var showPrivacyInfo by rememberSaveable { mutableStateOf(!acceptedPrivacyInfo) }
Scaffold(topBar = { SampleTopAppBar(title = sampleName, actions = { var actionsExpanded by remember { mutableStateOf(false) } IconButton(onClick = { actionsExpanded = !actionsExpanded }) { Icon(Icons.Default.MoreVert, "More") } DropdownMenu( expanded = actionsExpanded, onDismissRequest = { actionsExpanded = false }) { DropdownMenuItem(text = { Text("World tracking") }, onClick = { trackingMode = WorldScaleTrackingMode.World() actionsExpanded = false }) DropdownMenuItem(text = { Text("Geospatial tracking") }, onClick = { trackingMode = WorldScaleTrackingMode.Geospatial() actionsExpanded = false }) } }) }, content = { if (showPrivacyInfo) { PrivacyInfoDialog( hasCurrentlyAccepted = acceptedPrivacyInfo, onUserResponse = { accepted -> acceptedPrivacyInfo = accepted sharedPreferences.edit { putBoolean(KEY_PREF_ACCEPTED_PRIVACY_INFO, accepted) } showPrivacyInfo = false } ) } if (!acceptedPrivacyInfo) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = "Privacy Info not accepted") Button(onClick = { showPrivacyInfo = true }) { Text(text = "Show Privacy Info") } } } else { Box( modifier = Modifier .fillMaxSize() .padding(it), ) { WorldScaleSceneView( modifier = Modifier.fillMaxSize(), arcGISScene = augmentedRealityViewModel.arcGISScene, graphicsOverlays = listOf( augmentedRealityViewModel.routeAheadGraphicsOverlay, augmentedRealityViewModel.routeBehindGraphicsOverlay ), onCurrentViewpointCameraChanged = { camera -> if (camera.location.x != 0.0 && camera.location.y != 0.0) { augmentedRealityViewModel.onCurrentViewpointCameraChanged(camera.location) } }, worldScaleSceneViewProxy = augmentedRealityViewModel.worldScaleSceneViewProxy, worldScaleTrackingMode = trackingMode, onInitializationStatusChanged = { status -> initializationStatus = status }) { Box(modifier = Modifier.fillMaxSize()) { if (trackingMode is WorldScaleTrackingMode.World) { if (displayCalibrationView) { CalibrationView( onDismiss = { displayCalibrationView = false }, modifier = Modifier.align(Alignment.BottomCenter), ) } } } } Column { if (trackingMode is WorldScaleTrackingMode.Geospatial) { Box( modifier = Modifier .fillMaxWidth() .background(Color.Gray.copy(alpha = 0.5f)) .padding(8.dp), contentAlignment = Alignment.Center ) { Text( text = if (augmentedRealityViewModel.isVpsAvailable) { "VPS available" } else { "VPS unavailable" }, color = Color.White ) } } if (augmentedRealityViewModel.nextDirectionText != "") { // Add directions text box at the top of the screen Box( modifier = Modifier .align(Alignment.CenterHorizontally) .padding(8.dp) .clip(RoundedCornerShape(10.dp)) .background(Color.Black.copy(alpha = 0.8f)) .padding(16.dp) ) {
Text( text = augmentedRealityViewModel.nextDirectionText, color = Color.White ) } } } // Show bottom sheet with controls to change number of graphics drawn ahead if (showBottomSheet) { ModalBottomSheet( modifier = Modifier.wrapContentSize(), onDismissRequest = { showBottomSheet = false }, sheetState = sheetState ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = CenterVertically ) { Text( text = "Number of graphics drawn:", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 12.dp) ) Text( text = augmentedRealityViewModel.currentGraphicsShown.toString(), modifier = Modifier.padding(end = 12.dp) ) } Slider( value = augmentedRealityViewModel.currentGraphicsShown.toFloat(), onValueChange = { valueChanged -> augmentedRealityViewModel.onCurrentGraphicsShownChanged(valueChanged.toInt()) }, valueRange = 1f..augmentedRealityViewModel.routeAllGraphics.size.toFloat(), modifier = Modifier.padding(start = 12.dp, end = 12.dp), steps = augmentedRealityViewModel.routeAllGraphics.size - 2 ) } }
when (val status = initializationStatus) { is WorldScaleSceneViewStatus.Initializing -> { TextWithScrim( if (trackingMode is WorldScaleTrackingMode.Geospatial) { "Initializing AR in geospatial mode..." } else { "Initializing AR in world mode..." } ) }
is WorldScaleSceneViewStatus.Initialized -> { val sceneLoadStatus = augmentedRealityViewModel.arcGISScene.loadStatus.collectAsStateWithLifecycle().value when (sceneLoadStatus) { is LoadStatus.Loading, LoadStatus.NotLoaded -> { // The scene may take a while to load, so show a progress indicator CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
is LoadStatus.FailedToLoad -> { TextWithScrim("Failed to load world scale AR scene: " + sceneLoadStatus.error) }
else -> {} } }
is WorldScaleSceneViewStatus.FailedToInitialize -> { TextWithScrim( text = "World scale AR failed to initialize: " + (status.error.message ?: status.error) ) } } } } }, floatingActionButton = { if (!displayCalibrationView) { Column { if (trackingMode is WorldScaleTrackingMode.World) {
FloatingActionButton( modifier = Modifier .align(Alignment.End) .padding(bottom = 16.dp), onClick = { displayCalibrationView = true }) { Icon( painter = painterResource(R.drawable.baseline_straighten_24), "Show calibration view" ) } } FloatingActionButton( modifier = Modifier .align(Alignment.End) .padding(bottom = 32.dp), onClick = { showBottomSheet = !showBottomSheet }) { Icon( imageVector = Icons.Filled.Settings, "Change settings" ) } } } })}
/** * An alert dialog that asks the user to accept or deny * [ARCore's privacy requirements](https://developers.google.com/ar/develop/privacy-requirements). */@Composableprivate fun PrivacyInfoDialog( hasCurrentlyAccepted: Boolean, onUserResponse: (accepted: Boolean) -> Unit) { Dialog(onDismissRequest = { onUserResponse(hasCurrentlyAccepted) }) { Card { Column( modifier = Modifier.padding(16.dp) ) { LegalTextArCore() Spacer(Modifier.height(16.dp)) LegalTextGeospatial() Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { TextButton(onClick = { onUserResponse(false) }) { Text(text = "Decline") }
TextButton(onClick = { onUserResponse(true) }) { Text(text = "Accept") } } } } }}
/** * Displays the provided [text] on top of a half-transparent gray background. */@Composableprivate fun TextWithScrim(text: String) { Column( modifier = Modifier .background(Color.Gray.copy(alpha = 0.5f)) .fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = text) }}
/** * Displays the required privacy information for use of ARCore */@Composableprivate fun LegalTextArCore() { val textLinkStyle = TextLinkStyles(style = SpanStyle(color = Color.Blue)) Text(text = buildAnnotatedString { append("This application runs on ") withLink( LinkAnnotation.Url( "https://play.google.com/store/apps/details?id=com.google.ar.core", textLinkStyle ) ) { append("Google Play Services for AR") } append(" (ARCore), which is provided by Google and governed by the ") withLink( LinkAnnotation.Url( "https://policies.google.com/privacy", textLinkStyle ) ) { append("Google Privacy Policy.") } })}
/** * Displays the required privacy information for use of the Geospatial API */@Composableprivate fun LegalTextGeospatial() { Text(text = buildAnnotatedString { append("To power this session, Google will process sensor data (e.g., camera and location).") appendLine() withLink( LinkAnnotation.Url( "https://support.google.com/ar?p=how-google-play-services-for-ar-handles-your-data", TextLinkStyles(style = SpanStyle(color = Color.Blue)) ) ) { append("Learn more") } })}/* Copyright 2025 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.augmentrealitytonavigateroute.screens
import androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.Buttonimport androidx.compose.material3.FabPositionimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.LaunchedEffectimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.draw.clipimport androidx.compose.ui.unit.dpimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplayimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.components.RouteViewModelimport com.esri.arcgismaps.sample.augmentrealitytonavigateroute.components.SharedRepositoryimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar
/** * Main screen layout for the sample app */@Composablefun RouteScreen( sampleName: String, locationPermissionGranted: Boolean, onNavigateToARScreen: () -> Unit) { val locationDisplay = rememberLocationDisplay() val routeViewModel: RouteViewModel = viewModel() var isViewmodelInitialized by remember { mutableStateOf(false) } LaunchedEffect(isViewmodelInitialized) { if (!isViewmodelInitialized && locationPermissionGranted) { routeViewModel.initialize(locationDisplay) isViewmodelInitialized = true } } Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, content = { Box( modifier = Modifier .fillMaxSize() .padding(it), ) { MapView( modifier = Modifier.fillMaxSize(), arcGISMap = routeViewModel.arcGISMap, locationDisplay = locationDisplay, graphicsOverlays = routeViewModel.graphicsOverlays, onSingleTapConfirmed = { tap -> tap.mapPoint?.let { it -> routeViewModel.addRoutePoint(it) } }) if (routeViewModel.statusText.value != "") { // Add directions text box at the top of the screen Box( modifier = Modifier .align(Alignment.TopCenter) .padding(8.dp) .clip(RoundedCornerShape(10.dp)) .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.8f)) .padding(16.dp) ) { Text( text = routeViewModel.statusText.value, color = androidx.compose.ui.graphics.Color.White ) } } } }, floatingActionButton = { if (routeViewModel.startPoint == null && SharedRepository.route == null) { if (routeViewModel.isCurrentLocationAsStartButtonEnabled) { Button( onClick = { locationDisplay.mapLocation?.let { routeViewModel.addRoutePoint(it) } }, modifier = Modifier.padding(bottom = 32.dp), ) { Text("Use current location as start point") } } } if (SharedRepository.route != null) { Button( onClick = onNavigateToARScreen, modifier = Modifier.padding(bottom = 32.dp) ) { Text("Navigate in augmented reality") } } }, floatingActionButtonPosition = FabPosition.Center )}