Wayfinding Instructions for Android

Last updated:

This tutorial will show how to work with the route model returned from a directions service call. It will also show how you can utilize interactions between the route rendering on the map, and text-based instructions showed in another view.

This tutorial will be based off the Getting Started example found here: Java or Kotlin.

You will work with changing the implementation of the NavigationFragment and the RouteLegFragment.

An example of the view XML file for the NavigationFragment this guide will use can be found here: Navigation view.

First, add the variable mLocation that keeps a reference to the MPLocation that was used as the destination for the route. Assign this variable when creating an instance of the NavigationFragment. There should already be a reference to the Route that should also be assigned.

NavigationFragment.java
private MPLocation mLocation;
private Route mRoute;

public static NavigationFragment newInstance(Route route, MapsActivity mapsActivity, MPLocation location) {
final NavigationFragment fragment = new NavigationFragment();
fragment.mRoute = route;
fragment.mMapsActivity = mapsActivity;
fragment.mLocation = location;
return fragment;
}
NavigationFragment.kt
private var mRoute: Route? = null
private var mLocation: MPLocation? = null

companion object {
fun newInstance(route: Route?, mapsActivity: MapsActivity?, location: MPLocation?): NavigationFragment {
val fragment = NavigationFragment()
fragment.mRoute = route
fragment.mLocation = location
fragment.mMapsActivity = mapsActivity
return fragment
}
}

Next step is replacing the old view in the RouteLegFragment.

An example of the view XML file for the RouteLegFragment, that this guide will use, can be found here: RouteLeg view.

You must start tying in the logic to create these views and show the route on the map together with the necessary UI for the user to navigate.

Start by changing the code inside NavigationFragment. Since the UI was changed, some of the old code needs to be changed. First, start by changing the code inside onViewCreated.

NavigationFragment.java
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
TextView locationNameTxtView = view.findViewById(R.id.location_name);
locationNameTxtView.setText("To " + mLocation.getName());

RouteCollectionAdapter routeCollectionAdapter = new RouteCollectionAdapter(this);
ViewPager2 mViewPager = view.findViewById(R.id.stepViewPager);
mViewPager.setAdapter(routeCollectionAdapter);
mViewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
//When a page is selected call the renderer with the index
mMapsActivity.getMpDirectionsRenderer().setRouteLegIndex(position);
//Update the floor on mapcontrol if the floor might have changed for the routing
mMapsActivity.getMapControl().selectFloor(mMapsActivity.getMpDirectionsRenderer().getCurrentFloor());
}
});

ImageView closeBtn = view.findViewById(R.id.close_btn);
//Button for closing the bottom sheet. Clears the route through directionsRenderer as well, and changes map padding.
closeBtn.setOnClickListener(v -> {
mMapsActivity.removeFragmentFromBottomSheet(this);
mMapsActivity.getMpDirectionsRenderer().clear();
});
}
NavigationFragment.kt
override fun onViewCreated(view: View, @Nullable savedInstanceState: Bundle?) {
//Assigning views
val locationNameTxtView = view.findViewById<TextView>(R.id.location_name)
locationNameTxtView.text = "To " + mLocation?.name

val routeCollectionAdapter =
RouteCollectionAdapter(this)
val mViewPager: ViewPager2 = view.findViewById(R.id.stepViewPager)
mViewPager.adapter = routeCollectionAdapter
mViewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
//When a page is selected call the renderer with the index
mMapsActivity?.getMpDirectionsRenderer()?.setRouteLegIndex(position)
//Update the floor on mapcontrol if the floor might have changed for the routing
mMapsActivity?.getMpDirectionsRenderer()?.currentFloor?.let {floorIndex ->
mMapsActivity?.getMapControl()?.selectFloor(floorIndex)
}
}
})

val closeBtn = view.findViewById<ImageView>(R.id.close_btn)

//Button for closing the bottom sheet. Clears the route through directionsRenderer as well, and changes map padding.
closeBtn.setOnClickListener {
mMapsActivity!!.removeFragmentFromBottomSheet(this)
mMapsActivity!!.getMpDirectionsRenderer()?.clear()
}
}

Next step is to change the ViewPager implementation to contain the revised RouteLegFragment for each leg of the route.

NavigationFragment.java
class RouteCollectionAdapter extends FragmentStateAdapter {

public RouteCollectionAdapter(Fragment fragment) {
super(fragment);
}

@NonNull
@Override
public Fragment createFragment(int position) {
if (position == mRoute.getLegs().size() - 1) {
return RouteLegFragment.newInstance("Walk to " + mLocation.getName(), (int) mRoute.getLegs().get(position).getDistance(), (int) mRoute.getLegs().get(position).getDuration());
}else {
RouteLeg leg = mRoute.getLegs().get(position);
RouteStep firstStep = leg.getSteps().get(0);
RouteStep lastFirstStep = mRoute.getLegs().get(position+1).getSteps().get(0);
RouteStep lastStep = mRoute.getLegs().get(position+1).getSteps().get(mRoute.getLegs().get(position+1).getSteps().size()-1);

Building firstBuilding = MapsIndoors.getBuildings().getBuilding(firstStep.getStartPoint().getLatLng());
Building lastBuilding = MapsIndoors.getBuildings().getBuilding(lastStep.getStartPoint().getLatLng());

if (firstBuilding != null && lastBuilding != null) {
return RouteLegFragment.newInstance(getStepName(lastFirstStep, lastStep), (int) leg.getDistance(), (int) leg.getDuration());
}else if (firstBuilding != null) {
return RouteLegFragment.newInstance("Exit: " + firstBuilding.getName(), (int) leg.getDistance(), (int) leg.getDuration());
}else {
return RouteLegFragment.newInstance("Enter: " + lastBuilding.getName(), (int) leg.getDistance(), (int) leg.getDuration());
}
}
}

@Override
public int getItemCount() {
return mRoute.getLegs().size();
}
}
NavigationFragment.kt
inner class RouteCollectionAdapter(fragment: Fragment?) :
FragmentStateAdapter(fragment!!) {

override fun createFragment(position: Int): Fragment {
if (position == mRoute?.legs?.size!! - 1) {
return RouteLegFragment.newInstance("Walk to " + mLocation?.name, mRoute?.legs!![position]?.distance?.toInt(), mRoute?.legs!![position]?.duration?.toInt())
} else {
var leg = mRoute?.legs!![position]
var firstStep = leg.steps.first()
var lastFirstStep = mRoute?.legs!![position + 1].steps.first()
var lastStep = mRoute?.legs!![position + 1].steps.last()

var firstBuilding = MapsIndoors.getBuildings()?.getBuilding(firstStep.startPoint.latLng)
var lastBuilding = MapsIndoors.getBuildings()?.getBuilding(lastStep.startPoint.latLng)
return if (firstBuilding != null && lastBuilding != null) {
RouteLegFragment.newInstance(getStepName(lastFirstStep, lastStep), leg.distance.toInt(), leg.duration.toInt())
}else if (firstBuilding != null) {
RouteLegFragment.newInstance("Exit: " + firstBuilding.name, leg.distance.toInt(), leg.duration.toInt())
}else {
RouteLegFragment.newInstance("Enter: " + lastBuilding?.name, leg.distance.toInt(), leg.duration.toInt())
}
}
}

override fun getItemCount(): Int {
mRoute?.legs?.let { legs->
return legs.size
}
return 0
}
}

The method getStepName is however missing, so you must create this. This method creates a string that takes the first and last step of the next leg to create a description for the user on what to do at the end of the currently shown leg. You will also create a method to get a list of the different highway types the route can give the user. These are found as enums through the Highway class in the MapsIndoors SDK.

NavigationFragment.java
String getStepName(RouteStep startStep, RouteStep endStep) {
double startStepZindex = startStep.getStartLocation().getZIndex();
String startStepFloorName = startStep.getStartLocation().getFloorName();
String highway = null;

for (String actionName : getActionNames()) {
if (startStep.getHighway().equals(actionName)) {
if (actionName.equals(Highway.STEPS)) {
highway = "stairs";
}else {
highway = actionName;
}
}
}

if (highway != null) {
return String.format("Take %s to %s %s", highway, "level", endStep.getEndLocation().getFloorName().isEmpty() ? endStep.getEndLocation().getZIndex(): endStep.getEndLocation().getFloorName());
}

if (startStepFloorName.equals(endStep.getEndLocation().getFloorName())) {
return "Walk to next step";
}

String endStepFloorName = endStep.getEndLocation().getFloorName();

if (endStepFloorName.isEmpty()) {
return String.format("Level %s to %s", startStepFloorName.isEmpty() ? startStepZindex: startStepFloorName, endStep.getEndPoint().getZIndex());
}else {
return String.format("Level %s to %s", startStepFloorName.isEmpty() ? startStepZindex: startStepFloorName, endStepFloorName);
}
}

ArrayList<String> getActionNames() {
ArrayList<String> actionNames = new ArrayList<>();
actionNames.add(Highway.ELEVATOR);
actionNames.add(Highway.ESCALATOR);
actionNames.add(Highway.STEPS);
actionNames.add(Highway.TRAVELATOR);
actionNames.add(Highway.RAMP);
actionNames.add(Highway.WHEELCHAIRLIFT);
actionNames.add(Highway.WHEELCHAIRRAMP);
actionNames.add(Highway.LADDER);
return actionNames;
}
NavigationFragment.kt
fun getStepName(startStep: RouteStep, endStep: RouteStep): String {
val startStepStartPointZIndex = startStep.startLocation?.zIndex
val startStepStartFloorName = startStep.startLocation?.floorName
var highway: String? = null
getActionNames().forEach {
it?.let {
if (startStep.highway == it) {
highway = if (it == Highway.STEPS) {
"stairs"
}else {
it
}
}
}
}
if (highway != null) {
return String.format("Take %s to %s %s", highway, "Level", if (endStep.endLocation?.floorName.isNullOrEmpty()) endStep.endLocation?.zIndex else endStep.endLocation?.floorName)
}
var result = "Walk to next step"

if (startStepStartFloorName == endStep.endLocation?.floorName) {
return result
}

val endStepEndFloorName = endStep.endLocation?.floorName

result = if (TextUtils.isEmpty(endStepEndFloorName)) {
String.format("Level %s to %s", if (TextUtils.isEmpty(startStepStartFloorName)) startStepStartPointZIndex else startStepStartFloorName, endStep.endPoint.zIndex)
} else {
String.format("Level %s to %s", if (TextUtils.isEmpty(startStepStartFloorName)) startStepStartPointZIndex else startStepStartFloorName, endStepEndFloorName)
}
return result
}

fun getActionNames(): Array<String?> {
if (actionNames == null) {
actionNames = arrayOf(
Highway.ELEVATOR,
Highway.ESCALATOR,
Highway.STEPS,
Highway.TRAVELATOR,
Highway.RAMP,
Highway.WHEELCHAIRRAMP,
Highway.WHEELCHAIRLIFT,
Highway.LADDER
)
}
return actionNames!!
}

Now, expand the RouteLegFragment with some string variables to create the UI to explain to the user what to do on the respective Legs. Start by creating 3 new variables and update the newInstance method to assign these variables received from the createFragment method inside RouteCollectionAdapter from the NavigationFragment.

RouteLegFragment.java
private String mStep = null;
private int mDuration = 0;
private int mDistance = 0;

public static RouteLegFragment newInstance(String step, int distance, int duration) {
RouteLegFragment fragment = new RouteLegFragment();
fragment.mStep = step;
fragment.mDistance = distance;
fragment.mDuration = duration;
return fragment;
}
RouteLegFragment.kt
private String mStep = null;
private int mDuration = 0;
private int mDistance = 0;

public static RouteLegFragment newInstance(String step, int distance, int duration) {
RouteLegFragment fragment = new RouteLegFragment();
fragment.mStep = step;
fragment.mDistance = distance;
fragment.mDuration = duration;
return fragment;
}

You must also update the onViewCreated method to use the new views added earlier in the tutorial.

RouteLegFragment.java
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
TextView stepTextView = view.findViewById(R.id.stepTextView);
TextView distanceTextView = view.findViewById(R.id.distanceTextView);
TextView durationTextView = view.findViewById(R.id.durationTextView);

stepTextView.setText(mStep);

if (Locale.getDefault().getCountry().equals("US")) {
distanceTextView.setText((int) Math.round(mDistance * 3.281) + " feet");
}else {
distanceTextView.setText(mDistance + " m");
}

if (mDuration < 60) {
durationTextView.setText(mDuration + " sec");
}else {
durationTextView.setText(TimeUnit.MINUTES.convert(new Long(mDuration), TimeUnit.SECONDS) + " min");
}
}
RouteLegFragment.kt
override fun onViewCreated(
view: View, @Nullable savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val stepTextView = view.findViewById<TextView>(R.id.stepTextView)
val distanceTextView = view.findViewById<TextView>(R.id.distanceTextView)
val durationTextView = view.findViewById<TextView>(R.id.durationTextView)

stepTextView.text = mStep
if (Locale.getDefault().country == "US") {
distanceTextView.text = (mDistance?.times(3.281))?.toInt().toString() + " feet"
}else {
distanceTextView.text = mDistance?.toString() + " m"
}
mDuration?.let {
if (it < 60) {
durationTextView.text = it.toString() + " sec"
}else {
durationTextView.text = TimeUnit.MINUTES.convert(it.toLong(), TimeUnit.SECONDS).toString() + " min"
}
}
}

Now you have the revised UI for providing the user with a more explanatory route description when navigating. Now it needs to be rendered onto the map. Since you started from an existing example, this is already implemented. But run through what is done to achieve this behaviour and add some final touches to get the project to build and run.

The MapsActivity class handles all route rendering and route generation. This is done with MPRoutingProvider and MPDirectionsRenderer. The Activity also implements the OnRouteResultListener. You need to have a reference to your selected location when you get the result from the route query.

MapsActivity.java
//Create a variable of MPLocation.
private MPLocation mSelectedLocation = null;

//Assign the selected location inisde createRoute
void createRoute(MPLocation mpLocation) {
//If MPRoutingProvider has not been instantiated create it here and assign the results call back to the activity.
if (mpRoutingProvider == null) {
mpRoutingProvider = new MPRoutingProvider();
mpRoutingProvider.setOnRouteResultListener(this);
}
mpRoutingProvider.setTravelMode(TravelMode.WALKING);
//Queries the MPRouting provider for a route with the hardcoded user location and the point from a location.
mSelectedLocation = mpLocation;
mpRoutingProvider.query(mUserLocation, mpLocation.getPoint());
}
MapsActivity.kt
//Create a variable of MPLocation. It will be initiated later so we set it as a lateinit
private lateinit var mSelectedLocation: MPLocation


//Assign the selected location inisde createRoute
fun createRoute(mpLocation: MPLocation) {
//If MPRoutingProvider has not been instantiated create it here and assign the results call back to the activity.
if (mpRoutingProvider == null) {
mpRoutingProvider = MPRoutingProvider()
mpRoutingProvider?.setOnRouteResultListener(this)
}
//Queries the MPRouting provider for a route with the hardcoded user location and the point from a location.
mSelectedLocation = mpLocation
mpRoutingProvider?.query(mUserLocation, mpLocation.point)
}

First, you have the overwritten onRouteResult method that recieves the route when you query the MPRoutingProvider. The actions and logic in the method are explained inside the code example. This is where the mSelectedLocation is added to the NavigationFragment.newInstance method like in the example below.

MapsActivity.java
/**
* The result callback from the route query. Starts the rendering of the route and opens up a new instance of the navigation fragment on the bottom sheet.
* @param route the route model used to render a navigation view.
* @param miError an MIError if anything goes wrong when generating a route
*/

@Override
public void onRouteResult(@Nullable Route route, @Nullable MIError miError) {
//Return if either error is not null or the route is null
if (miError != null || route == null) {
new AlertDialog.Builder(this)
.setTitle("Something went wrong")
.setMessage("Something went wrong when generating the route. Try again or change your destination/origin")
.show();
return;
}
//Create the MPDirectionsRenderer if it has not been instantiated.
if (mpDirectionsRenderer == null) {
mpDirectionsRenderer = new MPDirectionsRenderer(this, mMap, mMapControl, i -> {
//Listener call back for when the user changes route leg. (By default is only called when a user presses the RouteLegs end marker)
mpDirectionsRenderer.setRouteLegIndex(i);
mMapControl.selectFloor(mpDirectionsRenderer.getCurrentFloor());
});
}
//Set the route on the Directions renderer
mpDirectionsRenderer.setRoute(route);
//Create a new instance of the navigation fragment
mNavigationFragment = NavigationFragment.newInstance(route, this, mSelectedLocation);
//Add the fragment to the BottomSheet
addFragmentToBottomSheet(mNavigationFragment);
//As camera movement is involved run this on the UIThread
runOnUiThread(()-> {
//Starts drawing and adjusting the map according to the route
mpDirectionsRenderer.initMap(true);
});
}
MapsActivity.kt
/**
* The result callback from the route query. Starts the rendering of the route and opens up a new instance of the navigation fragment on the bottom sheet.
* @param route the route model used to render a navigation view.
* @param miError an MIError if anything goes wrong when generating a route
*/

override fun onRouteResult(@Nullable route: Route?, @Nullable miError: MIError?) {
//Return if either error is not null or the route is null
if (miError != null || route == null) {
//TODO: Tell the user about the route not being able to be created etc.
return
}
//Create the MPDirectionsRenderer if it has not been instantiated.
if (mpDirectionsRenderer == null) {
mpDirectionsRenderer = MPDirectionsRenderer(this, mMap, mMapControl, OnLegSelectedListener { i: Int ->
//Listener call back for when the user changes route leg. (By default is only called when a user presses the RouteLegs end marker)
mpDirectionsRenderer?.setRouteLegIndex(i)
mMapControl.selectFloor(mpDirectionsRenderer!!.currentFloor)
})
}
//Set the route on the Directions renderer
mpDirectionsRenderer?.setRoute(route)
//Create a new instance of the navigation fragment
mNavigationFragment = NavigationFragment.newInstance(route, this, mSelectedLocation)
//Start a transaction and assign it to the BottomSheet
addFragmentToBottomSheet(mNavigationFragment)
//As camera movement is involved run this on the UIThread
runOnUiThread {
//Starts drawing and adjusting the map according to the route
mpDirectionsRenderer?.initMap(true)
}
}

A route is fetched when a user clicks on a location they have searched for. Then createRoute is called which will be used to create the Fragments mentioned above. When the route is ready, it is received by the listener. The user is now able to navigate the route. Changing legs when a user swipes between the steps is done by having a reference to the MapsActivity and a getter for the MPDirectionRenderer created inside the onRouteResult method.

MapsActivity.java
public MPDirectionsRenderer getMpDirectionsRenderer() {
return mpDirectionsRenderer;
}
MapsActivity.kt
fun getMpDirectionsRenderer(): MPDirectionsRenderer? {
return mpDirectionsRenderer
}

To change the routing when swapping between tabs on the viewpager, use the call back that we added further up inside the onViewCreated of NavigationFragment.

NavigationFragment.java
RouteCollectionAdapter routeCollectionAdapter = new RouteCollectionAdapter(this);
ViewPager2 mViewPager = view.findViewById(R.id.stepViewPager);
mViewPager.setAdapter(routeCollectionAdapter);
mViewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
//When a page is selected call the renderer with the index
mMapsActivity.getMpDirectionsRenderer().setRouteLegIndex(position);
//Update the floor on mapcontrol if the floor might have changed for the routing
mMapsActivity.getMapControl().selectFloor(mMapsActivity.getMpDirectionsRenderer().getCurrentFloor());
}
});
NavigationFragment.kt
val mViewPager: ViewPager2 = view.findViewById(R.id.stepViewPager)
mViewPager.adapter = routeCollectionAdapter
mViewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
//When a page is selected call the renderer with the index
mMapsActivity?.getMpDirectionsRenderer()?.setRouteLegIndex(position)
//Update the floor on mapcontrol if the floor might have changed for the routing
mMapsActivity?.getMpDirectionsRenderer()?.currentFloor?.let {floorIndex ->
mMapsActivity?.getMapControl()?.selectFloor(floorIndex)
}
}
})

The full working example can be found here: Java and Kotlin.