Implement Mapbox Navigation for Android

Standard

Introduction

This blog post documents step-by-step procedure to implement Mapbox navigation.  You will need to get a developer account on Mapbox to obtain a required access token.

Create the map

Start a new “Empty Activity” project in Android Studio.

Add Compile Option in build.gradlein (Module:app) within dependencies:

implementation 'com.mapbox.mapboxsdk:mapbox-android-navigation-ui:0.42.6'

Hover mouse over each implementation to let Android update the packages to the latest. Update by pressing alt-shift-return.  Now, it would be good to change minSdkVersion to 16 to cover more applicable users.

Now update build.gradle (Project) file, adding below lines to allprojects> block just below jcenter():

maven { url 'https://mapbox.bintray.com/mapbox' }

A pop-up should appear asking if you want to sync. Click ‘Sync now’ to sync the project.

In activity_main.xml add the below code to bring in the Mapbox map widget:

<com.mapbox.mapboxsdk.maps.MapView
  android:id="@+id/mapbox"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"/>

If root view is a ConstraintLayout, then you may need to add the constraints.

Now we will add permissions to AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> 
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> 
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

Add the ‘coarse’ and ‘fine’ location permissions and so that Mapbox SDK can detect your location and show it on the map. As for the ‘foreground service’ permission, this is needed when you want to start the turn by turn navigation.

Now we edit the MainActivity.kt to access the Mapbox Map by adding the below line just above the setContentView call:

Mapbox.getInstance(this, "Your Mapbox access token")

Then we can add this code just below the setContentView line:

mapView = findViewById<MapView>(R.id.mapbox)
mapView.getMapAsync(this)

The first line attached the MapView defined in activity_main.xml to the variable mapView.  The second line connects the variable to the actual map.  At this point, the top part of MainActivity should look like this:

class MainActivity : AppCompatActivity(), OnMapReadyCallback, PermissionsListener {

    private lateinit var mapView: MapView
    private lateinit var map: MapboxMap
    private var permissionsManager: PermissionsManager = PermissionsManager(this)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Mapbox.getInstance(this, getString(R.string.access_token))
        setContentView(R.layout.activity_main)
        mapView = findViewById(R.id.mapbox)
        mapView.getMapAsync(this)
    }

The lateinit keyword means the variable will be initialized later.  Also, we added a couple of interfaces that will need to be implemented.  The OnMapReadyCallback requires the onMapReady() callback; and PermissionsListener requires onExplainationNeeded() and onPermissionResult().

The onMapReady() callback can do something like this:

override fun onMapReady(mapboxMap: MapboxMap) {
        Log.i("Map", "is ready")
        this.map = mapboxMap
        mapboxMap.setStyle(
            Style.Builder().fromUri("mapbox://styles/mapbox/streets-v11")
        ) {
            // Map is set up and the style has loaded. Now you can add data or make other map adjustments
            enableLocationComponent(it)
        }
    }

The callback receives a map handle that is saved in the map variable. Then the map style is set to one of several pre-defined styles Mapbox offers.  When style is loaded, the enableLocationComponent(it) is called, with the style passed In through (it).  The Log.i() is just a debug print statement.

The enableLocationComponent() functions looks like this:

 @SuppressWarnings("MissingPermission")
    private fun enableLocationComponent(loadedMapStyle: Style) {
        // Check if permissions are enabled and if not request
        if (PermissionsManager.areLocationPermissionsGranted(this)) {

            // Create and customize the LocationComponent's options
            val customLocationComponentOptions = LocationComponentOptions.builder(this)
                .trackingGesturesManagement(true)
                .accuracyColor(ContextCompat.getColor(this, R.color.colorGreen))
                .build()

            val locationComponentActivationOptions = LocationComponentActivationOptions.builder(this, loadedMapStyle)
                .locationComponentOptions(customLocationComponentOptions)
                .build()

            // Get an instance of the LocationComponent and then adjust its settings
            map.locationComponent.apply {

                // Activate the LocationComponent with options
                activateLocationComponent(locationComponentActivationOptions)

                // Enable to make the LocationComponent visible
                isLocationComponentEnabled = true

                // Set the LocationComponent's camera mode
                cameraMode = CameraMode.TRACKING

                // Set the LocationComponent's render mode
                renderMode = RenderMode.COMPASS
            }

            // Setup camera position
            val location =  map.locationComponent.lastKnownLocation
            if (location != null) {
                // Setup camera position
                val position = CameraPosition.Builder()
                    .target(LatLng(location.latitude, location.longitude))
                    .zoom(15.0) // Sets the zoom
                    .bearing(0.0) // Rotate the camera
                    .tilt(0.0) // Set the camera tilt
                    .build() // Creates a CameraPosition from the builder

                map.animateCamera(
                    CameraUpdateFactory
                        .newCameraPosition(position), 1
                )
            }
        }
        else {
            permissionsManager = PermissionsManager(this)
            permissionsManager.requestLocationPermissions(this)
        }

The outer if-else statement checks if location permission is granted.  If granted, the map’s locationComponent is setup (customization), then the camera is setup for initial animated zoom in of current location (the minimum animation duration is 1).  If location permission is not granted, a PermissionManager is spun up to request for the permission.

Now let’s bring in the activity lifecycle functions:

@SuppressWarnings("MissingPermission")
override fun onStart() {
  super.onStart()
  mapView.onStart()
}

override fun onResume() {
  super.onResume()
  mapView.onResume()
}

override fun onPause() {
  super.onPause()
  mapView.onPause()
}

override fun onStop() {
  super.onStop()
  mapView.onStop()
}

override fun onDestroy() {
  super.onDestroy()
  mapbox.onDestroy()
}

override fun onLowMemory() {
  super.onLowMemory()
  mapView.onLowMemory()
}

override fun onSaveInstanceState(outState: Bundle) {
  super.onSaveInstanceState(outState)
  mapView.onSaveInstanceState(outState)
}

Mapbox has its own lifecycle methods for managing an Android openGL lifecycle. You must call those methods directly from the containing activity.  (Note: You need to add @SuppressWarnings("MissingPermission") above onStart and the other methods, because these methods require you to implement permission handling. However, you don’t need that here because Mapbox handles it for you.)

Now, build and run the app to see the result.  Below example uses mapbox://styles/mapbox/satellite-streets-v11 style in onMapReady().

Add a button

Now we need to add a button to start navigation.  Select activity_main.xml from the project view to bring up the layout view.  Add a Floating Button to the view.  Constrain the button to the right edge and to the bottom edge.  My button creates the following entry in activity_main.xml:

<com.google.android.material.floatingactionbutton.FloatingActionButton
    android:id="@+id/btnStartNavigation"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginEnd="76dp"
    android:layout_marginRight="76dp"
    android:layout_marginBottom="56dp"
    android:clickable="true"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:srcCompat="@drawable/ic_navigation_white_24dp" />

Noticed the id I gave to the FloatingActiveButton is btnStartNavigation.  You can change it to something else if you like.  Now we can bind the button in code by adding the lines below:

// In the MainActivity class variable area:
private lateinit var btn: FloatingActionButton
     ...
// In onCreate(), below setContentView(...), add
btn = findViewById(R.id.btnStartNavigation)
btn.setOnClickListener {
   if (currentRoute != null) {
      val navigationLauncherOptions = NavigationLauncherOptions.builder() //1
          .directionsRoute(currentRoute) //2
          .shouldSimulateRoute(true) //3
          .build()
      NavigationLauncher.startNavigation(this, navigationLauncherOptions) //4
   }
}

The above code not only binds the button, but also sets the click handler, which sets up and starts the navigation using currentRoute, the route between current location, and a destination selected by the user by clicking on a map location.  Next we will implement destination selection.  The variable currentRoute needs to be declared with other variables:

private var currentRoute: DirectionsRoute? = null

Now, build and run the app.  However, at this point nothing will happen when clicking on the btnStartNavigation, because currentRoute is still null until we assign something to it.

Destination selection

Now let’s figure out how to create a valid route and assign it to currentRoute.  We desire to have the user select a destination by clicking on the map.  For this we will need to add the MapboxMap.OnMapClickListener interface to MainActivity, like below:

class MainActivity : AppCompatActivity(), OnMapReadyCallback, 
                     PermissionsListener, MapboxMap.OnMapClickListener {
...

Then implement the below onMapClick function:

override fun onMapClick(point: LatLng): Boolean {
   checkLocation()   
   addPlaceMarker(point)
   originLocation?.run {
      val startPoint = Point.fromLngLat(longitude, latitude)
      val endPoint = Point.fromLngLat(point.longitude, point.latitude)

      getRoute(startPoint, endPoint)
   }
   return false
}

When user clicks anywhere on the map, a destination marker is displayed along with a route from origin to destination.  The checkLocation()function updates the current location; addPlaceMarker(point) adds a destination place marker on the map.

fun checkLocation() {
    if (originLocation == null) {
        map.locationComponent.lastKnownLocation?.run {
            originLocation = this
        }
    }
}

private fun addPlaceMarker(location: LatLng) {
    // Add symbol at specified lat/lon
    val symbol = symbolManager?.create(
        SymbolOptions()
            .withLatLng(location)
            .withIconImage("place-marker")
            .withIconSize(1.0f))
}

The symbolManager used in addPlaceMarker() needs to be initialized in onCreate()after the map camera setup section:

// Setup the symbol manager object
symbolManager = SymbolManager(mapView, map, loadedMapStyle)

// add click listeners if desired
symbolManager?.addClickListener { symbol ->
}
            
symbolManager?.addLongClickListener { symbol ->
}

// set non-data-driven properties, such as:
symbolManager?.iconAllowOverlap = true
symbolManager?.iconTranslate = arrayOf(-4f, 5f)
symbolManager?.iconRotationAlignment = ICON_ROTATION_ALIGNMENT_VIEWPORT

// setup marker
val bm: Bitmap = BitmapFactory.decodeResource(resources, R.drawable.map_marker_light)
map.style?.addImage("place-marker", bm)

Notice that the symbol itself can be clicked and have listener functions.  The last two lines of code prepares the place marker for display later.

Now the last thing to implement is getRoute():

private fun getRoute(originPoint: Point, endPoint: Point) {
   NavigationRoute.builder(this) //1
      .accessToken(Mapbox.getAccessToken()!!) //2
      .origin(originPoint) //3
      .destination(endPoint) //4
      .build() //5
      .getRoute(object : Callback { //6
         override fun onFailure(call: Call, t: Throwable) {
            Log.d("MainActivity", t.localizedMessage)
         }

         override fun onResponse(call: Call,
                                       response: Response) {
             if (navigationMapRoute != null) {
                navigationMapRoute?.updateRouteVisibilityTo(false)
             } else {
                navigationMapRoute = NavigationMapRoute(null, mapView, map)
             }

             currentRoute = response.body()?.routes()?.first()
             if (currentRoute != null) {
                navigationMapRoute?.addRoute(currentRoute)
             }

             btnStartNavigation.isEnabled = true
         }
     })
}

The function basically calls the NavigationRoute.builder() with access token, origin and destination as arguments, and handles both success and failed results. If successful, the first route is added.  Note: it is possible to check for alternative routes.

Now we need to make sure all the new variables introduced are declared in MainActivity; the all the declared variables now look like this:

private lateinit var mapView: MapView
private lateinit var map: MapboxMap
private lateinit var btn: FloatingActionButton
private var permissionsManager: PermissionsManager = PermissionsManager(this)
private var currentRoute: DirectionsRoute? = null
private var originLocation: Location? = null
private var navigationMapRoute: NavigationMapRoute? = null
private var symbolManager: SymbolManager? = null

Now, build and run, you should see something like this:

Click on another part of the map you should see this:

Click on the navigation button you so should see:

 

Get the project on Github

References

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s