Embedded Map & Orientation

In a previous lab our MyRent app consumed a GoogleMap API. Here we provide an alternative mapping system provided by MapBox. This is based on the open source OpenStreetMap project. This approach has the advantage of providing greater flexibility, not requiring keys and, importantly, resulting in a significantly smaller release build.

Introduction

In a previous lab our MyRent app consumed a GoogleMap API. Here we provide an alternative mapping system provided by MapBox. This is based on the open source OpenStreetMap project. This approach is interesting for a number of reasons. We are not obliged to undergo the somewhat laborious process of obtaining keys. For our purposes a simple token suffices. The API provided by MapBox is quite easy to use. And the method reference count in the signed release build for this iteration of MyRent is significantly less than that generated when using GoogleMaps. For example, presently, the MyRent release apk breaches the 64k method reference limit whereas using MapBox results in a method reference count in the order of 42k.

OpenStreetMap (OSM) is an open source project to create a free editable world map. The project was founded by Stev Coast in 2004 and has experienced growing usage. According to the referenced Wikipedia article, presently there are over two million registered users.

The OSM licence was originally published under the Creative Commons Attribution-ShareAlike licence but has since changed to the Open Database Licence (ODbL).

The approach taken in this lab is to continue from the end of the previous lab and replace GoogleMap with the MapBox version. It is suggested that you provide suitable tags or branches to facilite recovery of the Google Maps state should you require.

Before proceeding, create an account (free - no credit card required) with MapBox.

  • Once registered, switch to the studio page and copy your default access token. This will be required in a later step.

Figure 1: Obtain MapBox access token

Setup

Gradle

Delete reference to Google Play Services (something similar to the following):

compile 'com.google.android.gms:play-services:9.2.1'

and introduce in its place the MapBox dependency:

compile ('com.mapbox.mapboxsdk:mapbox-android-sdk:4.1.1@aar'){
  transitive=true
}

Remove GoogleMaps Code

Delete these files:

  • MapActivity in activities package.
  • activity_map.xml in res/layout package.
  • replace MapHelper content in android.helpers package with the following. Study the code and observe the differences between it and the original material.
package org.wit.android.helpers;
import android.util.Log;


import com.mapbox.mapboxsdk.geometry.LatLng;

import java.lang.NumberFormatException;


public class MapHelper
{

  /**
   * Parses a string containing latitude and longitude.
   * @param geolocation The string obtained by concatenating comma separated latitude and longitude
   * @return The latitude component
   */
  public static double latitude(String geolocation) {
    String[] g = geolocation.split(",");
    try {
      if (g.length == 2) {
        return Double.parseDouble(g[0]);
      }
    }
    catch (NumberFormatException e) {
      Log.d("MapHelper", "Number format exception: invalid latitude: " + e.getMessage());
    }
    return 0.0;

  }

  /**
   * Parses a string containing latitude and longitude.
   * @param geolocation The string obtained by concatenating comma separated latitude and longitude
   * @return The longitude component
   */
  public static double longitude(String geolocation) {
    String[] g = geolocation.split(",");
    try {
      if (g.length == 2) {
        return Double.parseDouble(g[1]);
      }
    }
    catch (NumberFormatException e) {
      Log.d("MapHelper", "Number format exception: invalid longitude: " + e.getMessage());
    }
    return 0.0;

  }

  /**
   *
   * @param geo A Mapbox LatLng object representing geolocation
   * @return String Returns concatenated latitude and longitude.
   */
  public static String latLng(LatLng geo) {

    return String.format("%.6f", geo.getLatitude()) + ", " + String.format("%.6f", geo.getLongitude());
  }

}

Layout

Add a default MapBox layout file named activity_mapbox.xml in res/layout folder:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                xmlns:tools="http://schemas.android.com/tools"
                xmlns:mapbox="http://schemas.android.com/apk/res-auto"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                tools:context=".activities.MapBoxActivity">

  <!-- Set the starting camera position and map style using xml-->
  <com.mapbox.mapboxsdk.maps.MapView
      android:id="@+id/mapView"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      mapbox:style_url="mapbox://styles/mapbox/streets-v9"
      mapbox:center_latitude="40.73581"
      mapbox:center_longitude="-73.99155"
      mapbox:zoom="11"/>

</RelativeLayout>

MapBox activity

Add an activity MapBoxActivity to the activities package, locating your token in the placeholder your token.

package org.wit.myrent.activities;

import android.app.Activity;
import android.os.Bundle;

import com.mapbox.mapboxsdk.maps.MapView;
import com.mapbox.mapboxsdk.maps.MapboxMap;
import com.mapbox.mapboxsdk.maps.OnMapReadyCallback;
import com.mapbox.mapboxsdk.MapboxAccountManager;

import org.wit.myrent.R;

public class MapBoxActivity extends Activity {

  private MapView mapView;


  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Mapbox access token only needs to be configured once in your app
    MapboxAccountManager.start(this, "your token");

    // This contains the MapView in XML and needs to be called after the account manager
    setContentView(R.layout.activity_mapbox);


    mapView = (MapView) findViewById(R.id.mapView);
    mapView.onCreate(savedInstanceState);
    mapView.getMapAsync(new OnMapReadyCallback() {
      @Override
      public void onMapReady(MapboxMap mapboxMap) {

        // Customize map with markers, polylines, etc.

      }
    });
  }

  // Add the mapView lifecycle to the activity's lifecycle methods
  @Override
  public void onResume() {
    super.onResume();
    mapView.onResume();
  }

  @Override
  public void onPause() {
    super.onPause();
    mapView.onPause();
  }

  @Override
  public void onLowMemory() {
    super.onLowMemory();
    mapView.onLowMemory();
  }

  @Override
  protected void onDestroy() {
    super.onDestroy();
    mapView.onDestroy();
  }

  @Override
  protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    mapView.onSaveInstanceState(outState);
  }
}

Manifest

Delete the MapActivity node.

These permissions are required.

  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.READ_CONTACTS" />
  <uses-permission android:name="android.permission.INTERNET" />

Add a MapBoxActivity node:

    <activity
        android:name=".activities.MapBoxActivity"
        android:label="@string/app_name">
    <meta-data android:name="android.support.PARENT_ACTIVITY"
               android:value=".activities.ResidencePagerActivity"/>
    </activity>

Add telemetry service:

        <service android:name="com.mapbox.mapboxsdk.telemetry.TelemetryService" />

ResidenceFragment

In ResidenceFragment.onClick replace the code relating to the now deleted Google map activity with the following:


      case R.id.fab:
        //startActivityWithData(getActivity(), MapActivity.class, EXTRA_RESIDENCE_ID, residence.id); // <--- delete this line
        startActivityWithData(getActivity(), MapBoxActivity.class, EXTRA_RESIDENCE_ID, residence.id);
        break;

Build, install apk on a device or emulator. Open a residence detail view and click on the floating action button. You should be presented with the default MapBox map as shown in Figure 1.

Figure 1: Default MapBox map

Customize map

First remove the default location (latitude:longitude) from the layout. This now becomes:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                xmlns:tools="http://schemas.android.com/tools"
                xmlns:mapbox="http://schemas.android.com/apk/res-auto"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                tools:context=".activities.MapBoxActivity">

  <!-- The starting camera position will be set programmatically in java -->
  <com.mapbox.mapboxsdk.maps.MapView
      android:id="@+id/mapView"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      mapbox:style_url="mapbox://styles/mapbox/streets-v9"/>

</RelativeLayout>

An anonymous class is used to set the map listener. We shall replace this with the delegate or interface pattern with which we are by now no doubt well familiar.

Replace:


    mapView.getMapAsync(new OnMapReadyCallback() {
      @Override
      public void onMapReady(MapboxMap mapboxMap) {

        // Customize map with markers, polylines, etc.

      }
    });

with:

mapView.getMapAsync(this);

Implement OnMapReadyCallback interface. The class header then becomes:

public class MapBoxActivity extends Activity implements OnMapReadyCallback

Implement OnMapReadyCallback abstract method:

  // OnMapReadyCallback interface method impl
  @Override
  public void onMapReady(MapboxMap mapboxMap) {
    // TODO: map customization
  }

The code should now be error free.

In the next step we shall centre the map on the current residence geolocation.

Residence geolocation

To centre the map on the residence geolocation requires the following steps:

  • Obtain the current residence id from the fragment bundle.
  • Use the id to obtain a reference to the residence and thence its geolocation.
  • Centre the map on this geolocation.

We are about to make several additions in this step. To avoid a proliferation of errors it is best to add all the necessary imports now:

import android.support.annotation.NonNull;

import com.mapbox.mapboxsdk.annotations.MarkerViewOptions;
import com.mapbox.mapboxsdk.camera.CameraPosition;
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory;
import com.mapbox.mapboxsdk.geometry.LatLng;

import org.wit.android.helpers.MapHelper;
import org.wit.myrent.app.MyRentApp;
import org.wit.myrent.models.Residence;

Introduce these fields:

  private MapboxMap mapboxMap;
  Long resId; // The id of the residence associate with this map pane
  Residence residence; // The residence associated with this map pane
  LatLng residenceLatLng;
  MyRentApp app;

In onCreate initializeresId,residenceandresidenceLatLng`:


    resId = (Long) getIntent().getSerializableExtra(ResidenceFragment.EXTRA_RESIDENCE_ID);
    app = (MyRentApp) getApplication();
    residence = app.portfolio.getResidence(resId);
    if (residence != null) {
      residenceLatLng = new LatLng(MapHelper.latitude(residence.geolocation),
          MapHelper.longitude(residence.geolocation));
    }

Create a private method setMarker. This instantiates a marker and adds it to the map.


  private void setMarker() {

    MarkerViewOptions marker = new MarkerViewOptions().position(residenceLatLng);
    mapboxMap.addMarker(marker);
  }

Create a private method to position the map camera. This method uses the residenceLatLng reference together with the incoming residence zoom value in positioning the camera.


  private void positionCamera() {
    CameraPosition position = new CameraPosition.Builder()
        .target(residenceLatLng) // Sets the new camera position
        .zoom(residence.zoom) // Sets the zoom
        .build(); // Creates a CameraPosition from the builder

    mapboxMap.animateCamera(CameraUpdateFactory
        .newCameraPosition(position));
  }

We are now in a position to implement the customization to onMapReady:

  // OnMapReadyCallback interface method impl
  @Override
  public void onMapReady(MapboxMap mapboxMap) {
    this.mapboxMap = mapboxMap;
    positionCamera();
    setMarker();
  }

Test the app as before. The output should now resemble that in Figure 1.

Figure 1: Map centred on current residence location

Marker

Here we shall:

  • implement an interface to facilite adding an infowindow to the marker to that when clicked it displays its geolocation.
  • implement an interface so that a long press on any position on the map causes the marker to move to that position.
  • update the model state.

OnMarkerClickListener

The MapBoxActivity class header becomes:


public class MapBoxActivity extends AppCompatActivity implements
    OnMapReadyCallback,
    MapboxMap.OnMarkerClickListener

Register the listener in onMapReady:

    mapboxMap.setOnMarkerClickListener(this);

Here is the interface method, fully implemented:

  // OnMarkerClickListener
  @Override
  public boolean onMarkerClick(@NonNull Marker marker) {
    String snippet = "GPS : " + residence.geolocation;
    marker.setSnippet(snippet);
    return false;
  }

OnMapLongClickListener

The class header now becomes:

public class MapBoxActivity extends AppCompatActivity implements
    OnMapReadyCallback,
    MapboxMap.OnMarkerClickListener,
    MapboxMap.OnMapLongClickListener

Register the listener:


    mapboxMap.setOnMapLongClickListener(this);

Here is the interface method implementation:

  /**
   * Long click moves marker to clicked position and updates
   * Residence object's geolocation to new marker position.
   * @param point
   */
  // OnMapLonClickListener
  @Override
  public void onMapLongClick(@NonNull LatLng point) {
    residenceMarker.setPosition(point);
  }

Persistence

Save the residence state changes in the onPause method:

  • its zoom level
  • its geolocation

  @Override
  public void onPause() {
    super.onPause();
    mapView.onPause();
    residence.zoom = mapboxMap.getCameraPosition().zoom;
    residence.geolocation = MapHelper.latLng(residenceMarker.getPosition());
    app.portfolio.updateResidence(residence);
  }

MapBoxActivity

For reference, here is the complete class.

Observe that we have:

  • enabled the support action bar and programmed the up button.
  • enabled the zoom controls.

package org.wit.myrent.activities;

import android.app.Activity;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v7.app.AppCompatActivity;
import android.view.MenuItem;

import com.mapbox.mapboxsdk.annotations.Marker;
import com.mapbox.mapboxsdk.annotations.MarkerViewOptions;
import com.mapbox.mapboxsdk.camera.CameraPosition;
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory;
import com.mapbox.mapboxsdk.geometry.LatLng;
import com.mapbox.mapboxsdk.maps.MapView;
import com.mapbox.mapboxsdk.maps.MapboxMap;
import com.mapbox.mapboxsdk.maps.OnMapReadyCallback;
import com.mapbox.mapboxsdk.MapboxAccountManager;

import org.wit.android.helpers.MapHelper;
import org.wit.myrent.R;
import org.wit.myrent.app.MyRentApp;
import org.wit.myrent.models.Residence;

import static org.wit.android.helpers.IntentHelper.navigateUp;

public class MapBoxActivity extends AppCompatActivity implements
    OnMapReadyCallback,
    MapboxMap.OnMarkerClickListener,
    MapboxMap.OnMapLongClickListener
{

  private MapView mapView;
  private MapboxMap mapboxMap;
  private Marker residenceMarker;
  Long resId; // The id of the residence associate with this map pane
  Residence residence; // The residence associated with this map pane
  LatLng residenceLatLng;
  MyRentApp app;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    resId = (Long) getIntent().getSerializableExtra(ResidenceFragment.EXTRA_RESIDENCE_ID);
    getSupportActionBar().setDisplayHomeAsUpEnabled(true);
    app = (MyRentApp) getApplication();
    residence = app.portfolio.getResidence(resId);
    if (residence != null) {
      residenceLatLng = new LatLng(MapHelper.latitude(residence.geolocation),
          MapHelper.longitude(residence.geolocation));
    }
    // Mapbox access token only needs to be configured once in your app
    MapboxAccountManager.start(this, "your token");

    // This contains the MapView in XML and needs to be called after the account manager
    setContentView(R.layout.activity_mapbox);

    mapView = (MapView) findViewById(R.id.mapView);
    mapView.onCreate(savedInstanceState);
    mapView.getMapAsync(this);
  }

  @Override
  public boolean onOptionsItemSelected(MenuItem item)
  {
    switch (item.getItemId())
    {
      case android.R.id.home:
        navigateUp(this, ResidenceFragment.EXTRA_RESIDENCE_ID, resId);
        return true;

      default: return super.onOptionsItemSelected(item);
    }
  }

  // OnMapReadyCallback interface method impl
  @Override
  public void onMapReady(MapboxMap mapboxMap) {
    this.mapboxMap = mapboxMap;
    positionCamera();
    setMarker();
    mapboxMap.getUiSettings().setZoomControlsEnabled(true);
    mapboxMap.getUiSettings().setZoomGesturesEnabled(true);
    mapboxMap.setOnMarkerClickListener(this);
    mapboxMap.setOnMapLongClickListener(this);

  }

  private void setMarker() {

    MarkerViewOptions marker = new MarkerViewOptions().position(residenceLatLng);
    residenceMarker = mapboxMap.addMarker(marker);
  }

  private void positionCamera() {
    CameraPosition position = new CameraPosition.Builder()
        .target(residenceLatLng) // Sets the new camera position
        .zoom(residence.zoom)
        .build(); // Creates a CameraPosition from the builder

    mapboxMap.animateCamera(CameraUpdateFactory
        .newCameraPosition(position));

  }

  // Add the mapView lifecycle to the activity's lifecycle methods
  @Override
  public void onResume() {
    super.onResume();
    mapView.onResume();
  }

  @Override
  public void onPause() {
    super.onPause();
    mapView.onPause();
    residence.zoom = mapboxMap.getCameraPosition().zoom;
    residence.geolocation = MapHelper.latLng(residenceMarker.getPosition());
    app.portfolio.updateResidence(residence);
  }

  @Override
  public void onLowMemory() {
    super.onLowMemory();
    mapView.onLowMemory();
  }

  @Override
  protected void onDestroy() {
    super.onDestroy();
    mapView.onDestroy();
  }

  @Override
  protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    mapView.onSaveInstanceState(outState);
  }

  // OnMarkerClickListener
  @Override
  public boolean onMarkerClick(@NonNull Marker marker) {
    String snippet = "GPS : " + residence.geolocation;
    marker.setSnippet(snippet);
    return false;
  }


  /**
   * Long click moves marker to clicked position and updates
   * Residence object's geolocation to new marker position.
   * @param point
   */
  // OnMapLonClickListener
  @Override
  public void onMapLongClick(@NonNull LatLng point) {
    residenceMarker.setPosition(point);
  }

}

The application at the end of this lab is available for reference here: myrent-11a