Skip to main content

Route Instructions

Last updated: June 19, 2026 | 6 minutes read

This example computes a route and presents it as a scrollable timeline - without rendering a map. The timeline starts with a route summary (departure → destination, total distance and time), followed by any route-wide warnings (toll roads, ferry connections, restricted areas), the turn-by-turn instructions with their turn icons, and any traffic events along the way. Every entry is sorted by its position along the route so the list reads from departure to destination.

The route used here travels from Berlin to Copenhagen, passing through Poznan.

Route instruction timeline

Routing

The route is calculated exactly as in the Routing On Map example: a RoutingService is retained in MainActivity, and its onStarted / onCompleted callbacks drive the UI. On success the first (main) route is forwarded to displayRouteInstructions; any error is surfaced through a dialog.

MainActivity.ktView on Github
private val routingService = RoutingService(
onStarted = {
binding.progressBar.visibility = View.VISIBLE
},

onCompleted = onCompleted@{ routes, errorCode, _ ->
binding.progressBar.visibility = View.GONE

when (errorCode) {
GemError.NoError -> {
if (routes.isEmpty()) return@onCompleted

// Get the main route from the ones that were found.
displayRouteInstructions(routes[0])
}
else -> {
// There was a problem at computing the routing operation.
showDialog(
getString(R.string.routing_error, SdkCall.runSynced { GemError.getMessage(errorCode, this) }),
)
EspressoIdlingResource.decrement()
}
}
},
)

Once the worldwide road map is available, startCalculateRoute defines the waypoints and starts the calculation. A route needs at least two waypoints, but it may contain more - here three are used, so the route passes through Poznan on its way from Berlin to Copenhagen. calculateRoute returns synchronously an error code indicating whether the calculation could be started; on success the result arrives later through onCompleted, but when it fails to start that callback never fires, so the error is reported here directly.

MainActivity.ktView on Github
private fun startCalculateRoute() = SdkCall.execute {
val wayPoints = arrayListOf(
Landmark("Berlin", 52.521944, 13.413056),
Landmark("Poznan", 52.406374, 16.925168),
Landmark("Copenhagen", 55.676097, 12.568337),
)

// calculateRoute returns synchronously whether the calculation could be started. On
// failure onCompleted never fires, so report the error here.
val errorCode = routingService.calculateRoute(wayPoints)
if (errorCode != GemError.NoError) {
val errorMessage = GemError.getMessage(errorCode, this)
runOnUiThread {
showDialog(getString(R.string.routing_failed_to_start, errorMessage))
}
EspressoIdlingResource.decrement()
}
}

Displaying the timeline

When the route is ready, displayRouteInstructions builds the turn-image size and hands the route to a RouteTimelineAdapter, which is set on the RecyclerView. All the work of turning the route into displayable rows happens inside the adapter.

MainActivity.ktView on Github
private fun displayRouteInstructions(route: Route) {
// Get the instructions from the route.
val imageSize = resources.getDimension(R.dimen.turn_image_size).toInt()
binding.listView.adapter = RouteTimelineAdapter(this, route, imageSize, isDarkThemeOn())
EspressoIdlingResource.decrement()
}

Building the timeline rows

The adapter pre-computes every row once, in its init block, on the SDK thread. Each row is a small RouteTimelineItem (icon bitmap, primary text, description, distance value/unit and a sortingKey used to order the list). The rows are assembled in this order:

  1. Route summary - read from route.timeDistance; the title is the "from → to" waypoint names and the description is the total distance and time. The total restricted distance/time is also captured here for the warning below.
  2. Warnings - route.hasTollRoads() and route.hasFerryConnections() add a toll / ferry warning row, and a non-zero restricted distance or time adds a "crosses restricted areas" row.
  3. Turn-by-turn instructions - one row per RouteInstruction (covered below).
  4. Traffic events - read from route.trafficEvents, each formatted with its delay/length and a from→to description.

Finally the items are sorted by sortingKey, which places the summary and warnings first and then interleaves instructions and traffic events by their distance along the route.

RouteTimelineAdapter.ktView on Github
route.timeDistance?.let { timeDistance ->
val distanceInMeters = timeDistance.totalDistance
val timeInSeconds = timeDistance.totalTime
val description = getFormattedDistanceTime(distanceInMeters, timeInSeconds)

routeLength = timeDistance.totalDistance

restrictedDistance = timeDistance.restrictedDistance
restrictedTime = timeDistance.restrictedTime

items.add(
RouteTimelineItem(
bmp = getDrawableBitmap(
if (isDarkThemeOn) R.drawable.ic_baseline_route_24_night else R.drawable.ic_baseline_route_24,
),
text = getRouteName(),
description = description,
distanceValue = emptyValue,
distanceUnit = emptyValue,
crossesRestrictedAreas = false,
sortingKey = sortingKey++,
),
)
}

if (route.hasTollRoads()) {
addWarningItem(
iconRes = if (isDarkThemeOn) R.drawable.ic_toll_night else R.drawable.ic_toll_day,
text = context.getString(R.string.route_warning_tolls),
sortingKey = sortingKey++,
)
}

if (route.hasFerryConnections()) {
addWarningItem(
iconRes = if (isDarkThemeOn) R.drawable.ic_ferry_night else R.drawable.ic_ferry_day,
text = context.getString(R.string.route_warning_ferry),
sortingKey = sortingKey++,
)
}

Turn instructions and turn images

For each RouteInstruction the adapter builds a turn image and the instruction text. The turn icon is rendered from the instruction's turnDetails?.abstractGeometryImage with GemUtilImages.asBitmap, using theme-aware colors so it is legible on both light and dark backgrounds. The primary text is the turnInstruction, the description is the followRoadInstruction, and the distance traveled up to that turn is formatted into a value/unit pair with GemUtil.getDistText. When the next leg crosses a restricted area the row is flagged so it can be highlighted later.

RouteTimelineAdapter.ktView on Github
for (routeInstruction in routeInstructions) {
val aInner = if (isDarkThemeOn) Rgba(255, 255, 255, 255) else Rgba(0, 0, 0, 255)
val aOuter = if (isDarkThemeOn) Rgba(0, 0, 0, 255) else Rgba(255, 255, 255, 255)
val iInner = Rgba(128, 128, 128, 255)
val iOuter = Rgba(128, 128, 128, 255)

val bmp = if (routeInstruction.hasTurnInfo()) {
GemUtilImages.asBitmap(
routeInstruction.turnDetails?.abstractGeometryImage,
imageSize,
imageSize,
aInner,
aOuter,
iInner,
iOuter,
)
} else {
null
}

var text = emptyValue
var description = emptyValue
var crossesRestrictedAreas = false

if (routeInstruction.hasTurnInfo()) {
text = trimTrailingDot(routeInstruction.turnInstruction)
}

if (routeInstruction.hasFollowRoadInfo()) {
description = trimTrailingDot(routeInstruction.followRoadInstruction)
}

val distance = routeInstruction.traveledTimeDistance?.totalDistance ?: 0
val distText = GemUtil.getDistText(distance, SdkSettings.unitSystem, true)
val distValue = normalizeDistanceValue(distText.first)

routeInstruction.timeDistanceToNextTurn?.let { timeDistToNextTurn ->
if ((timeDistToNextTurn.restrictedTime > 0) || (timeDistToNextTurn.restrictedDistance > 0)) {
crossesRestrictedAreas = true
}
}

items.add(
RouteTimelineItem(
bmp = bmp,
text = text,
description = description,
distanceValue = distValue,
distanceUnit = distText.second,
crossesRestrictedAreas = crossesRestrictedAreas,
sortingKey = distance,
),
)
}

Binding the rows

Because all rows are prepared up front, onBindViewHolder is a thin mapping from a RouteTimelineItem onto the row views: it sets the turn image, the texts and the distance value/unit, hides the description when it is empty, and colors the description red when that leg crosses a restricted area.

RouteTimelineAdapter.ktView on Github
override fun onBindViewHolder(holder: RouteInstructionViewHolder, position: Int) {
val item = items[position]
val descriptionColor = if (item.crossesRestrictedAreas) {
ContextCompat.getColor(context, android.R.color.holo_red_dark)
} else {
ContextCompat.getColor(context, R.color.on_surface)
}

with(holder.binding) {
turnImage.setImageBitmap(item.bmp)
text.text = item.text
description.text = item.description
description.visibility = if (item.description.isBlank()) View.GONE else View.VISIBLE
description.setTextColor(descriptionColor)
statusText.text = item.distanceValue
statusDescription.text = item.distanceUnit
}
}