Draw custom route callouts on a map
Note
This example is a part of the Navigation SDK Examples. You can find the values for all referenced resources in the res
directory. For example, see res/values/strings.xml
for R.string.*
references used in this example. The dependencies can be found here.The examples use View binding.See setup documention if necessary.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.mapbox.maps.MapView
android:id="@+id/mapView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/switchTheme"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorPrimary"
android:text="Switch theme"
android:textColor="@android:color/white"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:ignore="HardcodedText" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.mapbox.navigation.examples.standalone.callout
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.mapbox.api.directions.v5.models.RouteOptions
import com.mapbox.geojson.Point
import com.mapbox.maps.CameraOptions
import com.mapbox.maps.EdgeInsets
import com.mapbox.maps.MapboxDelicateApi
import com.mapbox.maps.plugin.animation.MapAnimationOptions
import com.mapbox.maps.plugin.animation.camera
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
import com.mapbox.navigation.base.options.NavigationOptions
import com.mapbox.navigation.base.route.NavigationRoute
import com.mapbox.navigation.base.route.NavigationRouterCallback
import com.mapbox.navigation.base.route.RouteAlternativesOptions
import com.mapbox.navigation.base.route.RouterFailure
import com.mapbox.navigation.base.route.RouterOrigin
import com.mapbox.navigation.core.MapboxNavigation
import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp
import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver
import com.mapbox.navigation.core.lifecycle.requireMapboxNavigation
import com.mapbox.navigation.core.preview.RoutesPreviewObserver
import com.mapbox.navigation.core.routealternatives.AlternativeRouteMetadata
import com.mapbox.navigation.examples.databinding.ActivityRouteCalloutBinding
import com.mapbox.navigation.examples.standalone.camera.ShowCameraTransitionsActivity
import com.mapbox.navigation.examples.standalone.routeline.RenderRouteLineActivity
import com.mapbox.navigation.ui.maps.NavigationStyles
import com.mapbox.navigation.ui.maps.route.arrow.api.MapboxRouteArrowApi
import com.mapbox.navigation.ui.maps.route.arrow.api.MapboxRouteArrowView
import com.mapbox.navigation.ui.maps.route.line.MapboxRouteLineApiExtensions.setNavigationRoutes
import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineApi
import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineView
import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineApiOptions
import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineViewOptions
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
/**
* This example demonstrates customization of the route callouts UI elements.
*
* Before running the example make sure you have put your access_token in the correct place
* inside [app/src/main/res/values/mapbox_access_token.xml]. If not present then add this file
* at the location mentioned above and add the following content to it
*
* <?xml version="1.0" encoding="utf-8"?>
* <resources xmlns:tools="http://schemas.android.com/tools">
* <string name="mapbox_access_token"><PUT_YOUR_ACCESS_TOKEN_HERE></string>
* </resources>
*
* The example assumes that you have granted location permissions and does not enforce it. However,
* the permission is essential for proper functioning of this example. The example also uses replay
* location engine to facilitate navigation without actually physically moving.
*
* The example uses camera API's exposed by the Maps SDK rather than using the API's exposed by the
* Navigation SDK. This is done to make the example concise and keep the focus on actual feature at
* hand. To learn more about how to use the camera API's provided by the Navigation SDK look at
* [ShowCameraTransitionsActivity]
*
* How to use this example:
* - The example uses a list of predefined coordinates that will be used to fetch a route.
* - When the example starts, the camera transitions to fit route origin and destination, the route between them fetches
* - Once routes are rendered you can see callouts attached to each route line
* - Click on any callout to make that route primary and all others alternative
* - Click on Switch Theme button to trigger adapter to redraw callouts with new data
*
* Note:
* The example does not demonstrates the use of [MapboxRouteArrowApi] and [MapboxRouteArrowView].
* Take a look at [RenderRouteLineActivity] example to learn more about route line and route arrow.
*/
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
class CustomRouteCalloutActivity : AppCompatActivity() {
private lateinit var binding: ActivityRouteCalloutBinding
private val routeLineApiOptions: MapboxRouteLineApiOptions by lazy {
MapboxRouteLineApiOptions.Builder()
.isRouteCalloutsEnabled(true)
.build()
}
/**
* Click on any callout of the alternative route on the map to make it primary.
*/
private val routeCalloutClickListener: ((NavigationRoute) -> Unit) = { route ->
reorderRoutes(route)
}
/**
* Callout adapter allows to provide custom UI for the route callouts.
*/
private val calloutAdapter by lazy { CustomRouteCalloutAdapter(this, routeCalloutClickListener) }
private val routeLineView by lazy {
MapboxRouteLineView(MapboxRouteLineViewOptions.Builder(this).build()).also {
it.enableCallouts(
binding.mapView.viewAnnotationManager,
calloutAdapter,
)
}
}
private val routeLineApi: MapboxRouteLineApi by lazy {
MapboxRouteLineApi(routeLineApiOptions)
}
/**
* Hardcoded origin point of the route.
*/
private val originPoint = Point.fromLngLat(12.453822818321797, 41.90756056705955)
/**
* Hardcoded destination point of the route.
*/
private val destinationPoint = Point.fromLngLat(12.497853961893584, 41.89050307407414)
private val routesPreviewObserver: RoutesPreviewObserver = RoutesPreviewObserver { update ->
val preview = update.routesPreview ?: return@RoutesPreviewObserver
updateRoutes(preview.routesList, preview.alternativesMetadata)
}
private fun updateRoutes(routes: List<NavigationRoute>, metadata: List<AlternativeRouteMetadata>) {
lifecycleScope.launch {
routeLineApi.setNavigationRoutes(
newRoutes = routes,
alternativeRoutesMetadata = metadata,
).apply {
routeLineView.renderRouteDrawData(
binding.mapView.mapboxMap.style!!,
this
)
}
}
}
private fun reorderRoutes(clickedRoute: NavigationRoute) {
// if we clicked on some route callout that is not primary,
// we make this route primary and all the others - alternative
if (clickedRoute != routeLineApi.getPrimaryNavigationRoute()) {
if (mapboxNavigation.getRoutesPreview() == null) {
val reOrderedRoutes = mapboxNavigation.getNavigationRoutes()
.filter { clickedRoute.id != it.id }
.toMutableList()
.also { list ->
list.add(0, clickedRoute)
}
mapboxNavigation.setNavigationRoutes(reOrderedRoutes)
} else {
mapboxNavigation.changeRoutesPreviewPrimaryRoute(clickedRoute)
}
}
}
private val mapboxNavigation: MapboxNavigation by requireMapboxNavigation(
onResumedObserver = object : MapboxNavigationObserver {
@SuppressLint("MissingPermission")
override fun onAttached(mapboxNavigation: MapboxNavigation) {
mapboxNavigation.registerRoutesPreviewObserver(routesPreviewObserver)
findRoute(originPoint, destinationPoint)
}
override fun onDetached(mapboxNavigation: MapboxNavigation) {
mapboxNavigation.unregisterRoutesPreviewObserver(routesPreviewObserver)
}
},
onInitialize = this::initNavigation
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityRouteCalloutBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.mapView.mapboxMap.loadStyle(NavigationStyles.NAVIGATION_DAY_STYLE) {
updateCamera()
}
binding.switchTheme.setOnClickListener {
calloutAdapter.theme = when (calloutAdapter.theme) {
CustomRouteCalloutAdapter.Theme.Day -> CustomRouteCalloutAdapter.Theme.Night
CustomRouteCalloutAdapter.Theme.Night -> CustomRouteCalloutAdapter.Theme.Day
}
calloutAdapter.notifyDataSetChanged()
}
}
override fun onDestroy() {
super.onDestroy()
routeLineApi.cancel()
routeLineView.cancel()
}
private fun initNavigation() {
MapboxNavigationApp.setup(
NavigationOptions.Builder(this)
.routeAlternativesOptions(
RouteAlternativesOptions.Builder()
.intervalMillis(30.seconds.inWholeMilliseconds)
.build()
)
.build()
)
}
/**
* Request routes between the two points.
*/
private fun findRoute(origin: Point?, destination: Point?) {
val routeOptions = RouteOptions.builder()
.applyDefaultNavigationOptions()
.coordinatesList(listOf(origin, destination))
.layersList(listOf(mapboxNavigation.getZLevel(), null))
.alternatives(true) // make sure you set the `alternatives` flag to true in route options
.build()
mapboxNavigation.requestRoutes(
routeOptions,
object : NavigationRouterCallback {
override fun onRoutesReady(
routes: List<NavigationRoute>,
@RouterOrigin routerOrigin: String
) {
updateCamera()
if (routes.isNotEmpty()) {
binding.switchTheme.isVisible = true
mapboxNavigation.setRoutesPreview(routes)
}
}
override fun onFailure(
reasons: List<RouterFailure>,
routeOptions: RouteOptions
) {
// no impl
}
override fun onCanceled(
routeOptions: RouteOptions,
@RouterOrigin routerOrigin: String
) {
// no impl
}
}
)
}
@OptIn(MapboxDelicateApi::class)
private fun updateCamera() {
val mapAnimationOptions = MapAnimationOptions.Builder().duration(1500L).build()
val overviewOption = binding.mapView.mapboxMap.cameraForCoordinates(
listOf(
originPoint,
destinationPoint
),
CameraOptions.Builder()
.padding(EdgeInsets(100.0, 100.0, 100.0, 100.0))
.build(),
null,
null,
null,
)
binding.mapView.camera.easeTo(
overviewOption,
mapAnimationOptions
)
}
}
package com.mapbox.navigation.examples.standalone.callout
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.PorterDuff
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat.getColor
import androidx.core.content.ContextCompat.getDrawable
import com.mapbox.maps.ViewAnnotationAnchor
import com.mapbox.maps.ViewAnnotationAnchorConfig
import com.mapbox.maps.viewannotation.annotationAnchors
import com.mapbox.maps.viewannotation.viewAnnotationOptions
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
import com.mapbox.navigation.base.route.NavigationRoute
import com.mapbox.navigation.examples.R
import com.mapbox.navigation.ui.maps.route.callout.api.MapboxRouteCalloutAdapter
import com.mapbox.navigation.ui.maps.route.callout.model.CalloutViewHolder
import com.mapbox.navigation.ui.maps.route.callout.model.RouteCallout
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
class CustomRouteCalloutAdapter(
private val context: Context,
private val routeCalloutClickListener: (NavigationRoute) -> Unit,
) : MapboxRouteCalloutAdapter() {
var theme = Theme.Day
private val inflater = LayoutInflater.from(context)
override fun onCreateViewHolder(callout: RouteCallout): CalloutViewHolder {
val view = if (callout.isPrimary) {
createPrimaryView(callout)
} else {
createAlternativeView(callout)
}
view.setOnClickListener { routeCalloutClickListener(callout.route) }
return CalloutViewHolder.Builder(view)
.options(
viewAnnotationOptions {
ignoreCameraPadding(true)
priority(if (callout.isPrimary) 0 else 1)
minZoom(1.0f)
maxZoom(16.0f)
annotationAnchors(
{
anchor(ViewAnnotationAnchor.TOP_RIGHT)
},
{
anchor(ViewAnnotationAnchor.TOP_LEFT)
},
{
anchor(ViewAnnotationAnchor.BOTTOM_RIGHT)
},
{
anchor(ViewAnnotationAnchor.BOTTOM_LEFT)
},
)
},
)
.build()
}
@SuppressLint("SetTextI18n")
private fun createPrimaryView(callout: RouteCallout): View {
val view = inflater.inflate(R.layout.item_dva_eta, FrameLayout(context), false)
view.tag = PRIMARY_TAG
val durationInMinutes = callout.route.directionsRoute.duration() / 60
view.findViewById<TextView>(R.id.textNativeView).text = "${durationInMinutes.toInt()} mins"
val backgroundColorResId = when (theme) {
Theme.Day -> R.color.primaryCalloutColor
Theme.Night -> R.color.primaryCalloutColorDarkDark
}
val backgroundColor = getColor(context, backgroundColorResId)
view.backgroundTintList = ColorStateList.valueOf(backgroundColor)
return view
}
@SuppressLint("SetTextI18n")
private fun createAlternativeView(callout: RouteCallout): View {
val view = inflater.inflate(R.layout.item_dva_alt_eta, FrameLayout(context), false)
view.tag = ALTERNATIVE_TAG
val durationInMinutes = callout.durationDifferenceWithPrimary.absoluteValue.inWholeMinutes
val isFaster = callout.durationDifferenceWithPrimary.isNegative()
val textColor = when (theme) {
Theme.Day -> android.R.color.black
Theme.Night -> android.R.color.white
}
with(view.findViewById<TextView>(R.id.eta)) {
text =
"$durationInMinutes mins ${if (isFaster) "faster" else "slower"}"
setTextColor(getColor(context, textColor))
}
val color = if (isFaster) R.color.fasterArrow else R.color.slowerArrow
view.findViewById<ImageView>(R.id.arrow).setColorFilter(
getColor(context, color), PorterDuff.Mode.SRC_IN
)
val backgroundColorResId = when (theme) {
Theme.Day -> R.color.alternativeCalloutColor
Theme.Night -> R.color.alternativeCalloutColorDark
}
val backgroundColor = getColor(context, backgroundColorResId)
view.backgroundTintList = ColorStateList.valueOf(backgroundColor)
return view
}
override fun onUpdateAnchor(view: View, anchor: ViewAnnotationAnchorConfig) {
when (view.tag) {
PRIMARY_TAG -> {
val color = when (theme) {
Theme.Day -> R.color.primaryCalloutColor
Theme.Night -> R.color.primaryCalloutColorDarkDark
}
view.background = getBackground(anchor, getColor(context, color))
}
ALTERNATIVE_TAG -> {
val color = when (theme) {
Theme.Day -> R.color.alternativeCalloutColor
Theme.Night -> R.color.alternativeCalloutColorDark
}
view.background = getBackground(anchor, getColor(context, color))
}
else -> {
// no-op
}
}
}
private fun getBackground(
anchorConfig: ViewAnnotationAnchorConfig,
@ColorInt tint: Int,
): Drawable {
var flipX = false
var flipY = false
when (anchorConfig.anchor) {
ViewAnnotationAnchor.BOTTOM_RIGHT -> {
flipX = true
flipY = true
}
ViewAnnotationAnchor.TOP_RIGHT -> {
flipX = true
}
ViewAnnotationAnchor.BOTTOM_LEFT -> {
flipY = true
}
else -> {
// no-op
}
}
return BitmapDrawable(
context.resources,
BitmapUtils.drawableToBitmap(
getDrawable(context, R.drawable.bg_dva_eta),
flipX = flipX,
flipY = flipY,
tint = tint,
)!!
)
}
companion object {
private const val PRIMARY_TAG = 0
private const val ALTERNATIVE_TAG = 1
}
enum class Theme {
Day, Night
}
}
Was this example helpful?