Skip to main content

Lane Instructions

Last updated: June 19, 2026 | 6 minutes read

This example adds a lane guidance panel to a simulated turn-by-turn navigation. Whenever the route approaches a bifurcation - a junction, exit or fork where the road splits into several lanes - a strip of lane icons appears at the bottom of the viewport, with the lane(s) you should take highlighted. It builds on the Route Simulation example: the top panel shows the next-turn icon, distance and street name, the bottom panel shows the estimated time of arrival, remaining travel time and remaining travel distance, and the lane panel is placed above the bottom panel.

Because the position is simulated, there is no need to request location permissions, enable location services, or wait for a valid GPS fix before starting.

Lane guidance during simulated navigation

MainActivity retains a single NavigationService and a NavigationListener. The listener receives the stream of navigation events: when the simulation starts, when the next instruction changes, when the status changes, when the destination is reached, on errors, and when a voice instruction should be played. The navRoute helper returns the route currently being simulated, which the UI uses to read the estimated time of arrival, remaining time and distance.

MainActivity.ktView on Github
private val navigationListener: NavigationListener = NavigationListener.create(
onNavigationStarted = {
SdkCall.execute {
binding.gemSurfaceView.mapView?.let { mapView ->
navRoute?.let { route ->
mapView.presentRoute(route)
}

enableGPSButton()
mapView.followPosition()
}
}
applyCameraFocus()
runOnUiThread { setNavigationPanelsVisible(isVisible = true) }
},
onNavigationInstructionUpdated = { instr ->
SdkCall.execute {
val uiState = instr.toUiState()
runOnUiThread {
renderInstructionUi(uiState)
}
}
},
onDestinationReached = { onNavigationEnded() },
onNotifyStatusChange = { status ->
navigationStatus = status
runOnUiThread { refreshStatusMessage() }
},
onNavigationError = { error -> onNavigationEnded(error) },
onNavigationSound = { sound ->
SdkCall.execute {
SoundPlayingService.play(sound, playingListener, soundPreference)
}
},
canPlayNavigationSound = true,
)

A separate ProgressListener toggles the progress bar while the route is being calculated and reports any routing failure in a dialog.

Starting the simulation

The simulation is started once the worldwide road map is downloaded and up to date. Unlike real navigation, there is no permission or GPS gating - the road-map listener clears itself and calls startSimulation directly.

MainActivity.ktView on Github
SdkSettings.onWorldwideRoadMapSupportStatus = { status ->
if (status == EOffboardListenerStatus.UpToDate) {
SdkSettings.onWorldwideRoadMapSupportStatus = {}
startSimulation()
}
}

startSimulation defines the departure and destination waypoints and calls navigationService.startSimulation with the navigation and routing-progress listeners. The call returns synchronously whether the simulation could be started; on failure the error is reported in a dialog. Here the simulated trip runs along a short route in Brașov, Romania, chosen because it crosses a multi-lane junction that exercises the lane panel.

MainActivity.ktView on Github
private fun startSimulation() {
val waypoints = arrayListOf(
Landmark("Calea Bucuresti", 45.64924625, 25.6180490625),
Landmark("Harmanului", 45.6549909375, 25.6161609375),
)
val error = navigationService.startSimulation(
waypoints,
navigationListener,
routingProgressListener,
)

if (error != GemError.NoError) {
showDialog(
getString(
R.string.failed_to_start_simulation,
SdkCall.runSynced { GemError.getMessage(error, this) },
),
)
}
}

Rendering the lane instructions

Each time the SDK reports a new instruction through onNavigationInstructionUpdated, the values needed by the UI are gathered on the SDK thread by toUiState. The key piece for this example is laneImage: every NavigationInstruction exposes an optional laneImage that describes the lanes of the upcoming bifurcation. It is null when no lane split is near, so the lane panel naturally appears only when there is guidance to show.

laneImage.asBitmap rasterizes the lanes into a bitmap sized to the available screen width. The activeColor argument paints the recommended lane(s) - the ones you should follow for the next turn - in white, while the remaining lanes stay dimmed.

MainActivity.ktView on Github
private fun NavigationInstruction.toUiState(): InstructionUiState {
val lanesBitmap = laneImage?.asBitmap(
availableWidth,
lanePanelHeight,
activeColor = Rgba.white(),
)

var sameInstructionIcon = false
val instrIcon = getNextTurnImage(this, INSTRUCTION_ICON_SIZE_PX, INSTRUCTION_ICON_SIZE_PX) { isSame ->
sameInstructionIcon = isSame
}

val currentRoute = navRoute
return InstructionUiState(
instructionText = nextStreetName ?: (nextTurnInstruction ?: ""),
instructionIcon = instrIcon,
sameInstructionIcon = sameInstructionIcon,
instructionDistance = getDistanceInMeters(),
lanesBitmap = lanesBitmap,
etaText = currentRoute?.getEta().orEmpty(),
rttText = currentRoute?.getRtt().orEmpty(),
rtdText = currentRoute?.getRtd().orEmpty(),
)
}

renderInstructionUi then applies the snapshot to the views on the UI thread. The lane panel is shown only when a lane bitmap exists and the turn information (not a status message such as "Calculating…") is on screen. When a bitmap is present the ImageView is resized to match it so the lanes are rendered at their native dimensions.

MainActivity.ktView on Github
private fun renderInstructionUi(uiState: InstructionUiState) {
binding.apply {
navInstruction.text = uiState.instructionText
if (!uiState.sameInstructionIcon) {
navInstructionIcon.setImageBitmap(uiState.instructionIcon)
}

navInstructionDistance.text = uiState.instructionDistance

eta.text = uiState.etaText
rtt.text = uiState.rttText
rtd.text = uiState.rtdText

// Only show the lane panel while the turn information (not a status message) is visible.
// The lane panel does not affect the focus viewport, so no viewport refresh is needed.
laneContainer.isVisible = (uiState.lanesBitmap != null) && topPanel.isVisible && turnContainer.isVisible
uiState.lanesBitmap?.let { bitmap ->
laneImage.setImageBitmap(bitmap)
laneImage.layoutParams.width = bitmap.width
laneImage.layoutParams.height = bitmap.height
}
}
}

The width passed to asBitmap (availableWidth) is recomputed for each orientation: in portrait the lane panel spans on the entire map width, while in landscape it is centered in the free map area to the right of the docked navigation panels.

Presenting the route and following the position

When the simulation starts (the onNavigationStarted callback above), the route is drawn on the map with presentRoute, the camera enters follow mode with followPosition, and the navigation panels are made visible. enableGPSButton wires the follow button and the follow-mode transitions: when the user pans away from the simulated position the button reappears and the panels are hidden; tapping the button calls followPosition to re-center and resume following. The active-state check here uses navigationService.isSimulationActive() rather than the real-navigation equivalent.

MainActivity.ktView on Github
private fun enableGPSButton() {
// Set actions for entering/ exiting following position mode.
binding.apply {
gemSurfaceView.mapView?.apply {
onExitFollowingPosition = {
followGpsButton.isVisible = true
setNavigationPanelsVisible(isVisible = false)
}

onEnterFollowingPosition = {
followGpsButton.isVisible = false

val simulationIsActive = SdkCall.execute { navigationService.isSimulationActive() } ?: false
if (simulationIsActive) {
setNavigationPanelsVisible(isVisible = true)
}
}

// Set on click action for the GPS button.
followGpsButton.setOnClickListener {
SdkCall.execute { followPosition() }
}
}
}
}

Ending the simulation

When the destination is reached (onDestinationReached) or an error occurs (onNavigationError), onNavigationEnded hides the navigation panels, disables the follow button and removes the route, showing a dialog only for genuine errors (not for a normal cancellation).

MainActivity.ktView on Github
private fun onNavigationEnded(errorCode: ErrorCode = GemError.NoError) {
runOnUiThread {
if ((errorCode != GemError.NoError) && (errorCode != GemError.Cancel)) {
val message = SdkCall.runSynced { GemError.getMessage(errorCode, this) } ?: ""
if (message.isNotEmpty()) {
showDialog(message)
}
}
setNavigationPanelsVisible(isVisible = false)
disableGPSButton()
}

SdkCall.execute {
binding.gemSurfaceView.mapView?.hideRoutes()
}
}