Android — working with Google Maps and Directions API

TRIEN TRAN
16 min readMay 31, 2019

--

Android — working with Google Maps and Directions API

Imagine that we are required to build an app that includes an area on the map with highlighted routes and annotations and markers like below:

As the app design shows, there are 5 bus routes and many bus stops along the way. Also, we are required to add an annotation for every bus stop and appropriate directional arrows for each route. It can be achieved easily by taking advantage of Google Maps SDK to build customized, agile experiences that bring the real world to our users with static maps. The Directions API is utilized in this app to give users the best routes to get from A to Z with high-quality directions.

The steps involved to build this simple app are as follows:

In this tutorial, we will be using Android Studio 3.4 to create this app using Java language. The sample code package can be downloaded here.

1. Create a new project using Android Studio

Just follow the New Project wizard to create a new Android project with Google Maps Activity included. By default, min SDK Version is set to 19 and targeted SDK Version is 28.

2. Create Google Maps API key

Once we are done with creating a new project, Android Studio should have automatically opened google_maps_api.xml (in values folder) that displays details on how to generate a Google Maps API key.

The easiest way is to copy and paste the provided link to a web browser:

Follow the instructions and you will get the key at the last step.

Finally, once you have your key (it starts with “AIza”), replace the “google_maps_key”
string in google_maps_api.xml:

Alternatively, you can go to https://console.developers.google.com and create a new key for your existing or new project. Note: there seems to be a bug with Android Studio 3.4 for PC as of May 31, 2019, since the provided package name in this file is exactly the same as SHA-1 certificate fingerprint. If you want to restrict your key to only Android apps, you should use the correct package name specified in AndroidManifest.xml. Don’t forget to enable Maps SDK for Android for your project, otherwise, the app won’t work.

Now run your project and you should see the basic map:

3. Determine and load routes’ coordinates (latitude and longitude of specific points that form the routes)

To realize the desired routes, we first need to have a look at Google Maps on a web browser and point out locations where we want to generate directions. It may look very simple like this:

In the screenshot above, the route starts from Waterside Restaurant Whalers Inn and traverses around before ends up at the same place. In details, it goes through these locations:

Next, you need to enable Direction API for your project via https://console.developers.google.com.

Now we can run a Directions API request that looks like the form below:

https://maps.googleapis.com/maps/api/directions/json?origin=ADDRESS_1&destination=ADDRESS_2&waypoints=ADDRESS_X|ADDRESS_Y&key=API_KEY

There are 4 parameters associated with the URL above. From Google Developer documentation (learn more about Direction API here):

· Origin — the address, textual latitude/longitude value, or place ID from which you wish to calculate directions.

· Destination — the address, textual latitude/longitude value, or place ID to which you wish to calculate directions. The options for the destination parameter are the same as for the origin parameter, described above.

· Waypoints — specifies an array of waypoints. Waypoints alter a route by routing it through the specified location(s). A waypoint is specified as a latitude/longitude coordinate, an encoded polyline, a place ID, or an address which will be geocoded.

· Key — your application’s API key. This key identifies your application for purposes of quota management.

In our case, as they are all shuttle buses, the Origin and Destination should be the same address: Waterside Restaurant Whalers Inn, 121 Franklin Parade, Victor Harbor SA 5211. Waypoints are the other addresses that the routes are drawn through. Waypoints are separated by “|”.The final URL should look like this:

https://maps.googleapis.com/maps/api/directions/json?origin=Waterside Restaurant Whalers Inn, 121 Franklin Parade, Victor Harbor SA 5211&destination=Waterside Restaurant Whalers Inn, 121 Franklin Parade, Victor Harbor SA 5211&waypoints=8 Franklin Parade, Victor Harbor SA 5211|127 Victoria St, Victor Harbor SA 5211|136 Bay Rd, Encounter Bay SA 5211|45 Whalers Rd, Encounter Bay SA 5211&key=AIzaSyAo5wZVf6wj5ZHwuqp7HB4

Note: If you have chosen to automatically generate the API key via the provided link in google_maps_api.xml or you have restricted your key to only Android apps, the generated key is by default restricted to only Android apps, which means that you cannot run the URL above in a web browser on your computer. If you want to run it on a web browser, you need to go to https://console.developers.google.com and edit restriction to your API key. See screenshot below:

Once run, the above URL will return a JSON response which comprises all necessary latitude-longitude value pairs for drawing polylines on map:

You may use http://jsonviewer.stack.hu/ for more readable JSON format.

The only value that we will take into account from the JSON response is the points attribute’s value within the overview_polyline node. This is basically an encoded polyline which can be easily decoded into a list of latitude and longitude value pairs later.

You may want to run this HTTPS request within your Android app and then parse the JSON response to get the overview_polyline.

However, the Direction API is currently only free for use in 1 year, which means that you will be unable to run the HTTPS request above after 1 year from now with free membership.

Therefore, if you aren’t ready for a paid subscription yet, you should opt to run this request on your web browser once, then manually copy the points attribute’s value within the overview_polyline node and store them locally in a CSV file.

Saving them in a CSV file may make it easy for app developers from other platforms such as iOS or web to retrieve the substantive data. In the screenshot below, “lineBlue” is the keyword for the first polyline, “encodedPoints” is the keyword for the encoded polyline. Those keywords are used to look up encoded polyline strings in our activity classes.

Similarly, construct 4 more https requests for the other 4 routes, or you may skip this step and quickly find all the encoded polylines strings in the CSV file from the sample code.

4. Render coordinates into polylines

Before rendering coordinates to form polylines, we create a helper class named OnMapAndViewReadyListener which implements OnGlobalLayoutListener and OnMapReadyCallback. This will delay triggering the OnMapReady callback until both the GoogleMap and the view has completed initialization.

/*** Helper class that will delay triggering the OnMapReady callback until both the GoogleMap and the* View having completed initialization. This is only necessary if a developer wishes to immediately* invoke any method on the GoogleMap that also requires the View to have finished layout* (ie. anything that needs to know the View's true size like snapshotting).*/public class OnMapAndViewReadyListener implements OnGlobalLayoutListener, OnMapReadyCallback {/** A listener that needs to wait for both the GoogleMap and the View to be initialized. */public interface OnGlobalLayoutAndMapReadyListener {void onMapReady(GoogleMap googleMap);}private final SupportMapFragment mapFragment;private final View mapView;private final OnGlobalLayoutAndMapReadyListener devCallback;private boolean isViewReady;private boolean isMapReady;private GoogleMap googleMap;/** Constructor. */public OnMapAndViewReadyListener(SupportMapFragment mapFragment, OnGlobalLayoutAndMapReadyListener devCallback) {this.mapFragment = mapFragment;mapView = mapFragment.getView();this.devCallback = devCallback;isViewReady = false;isMapReady = false;googleMap = null;// Register listener when creating an OnMapAndViewReadyListener object.registerListeners();}/*** Method to help register a Global Layout Listener for mapView.* This is necessary to determining if map view has completed layout.**/private void registerListeners() {// View layout.if ((mapView.getWidth() != 0) && (mapView.getHeight() != 0)) {// View has already completed layout.isViewReady = true;} else {// Map has not undergone layout, register a View observer.mapView.getViewTreeObserver().addOnGlobalLayoutListener(this);}// GoogleMap. Note if the GoogleMap is already ready it will still fire the callback later.mapFragment.getMapAsync(this);}/*** Use the onMapReady(GoogleMap) callback method to get a handle to the GoogleMap object.**/@Overridepublic void onMapReady(GoogleMap googleMap) {// NOTE: The GoogleMap API specifies the listener is removed just prior to invocation.this.googleMap = googleMap;isMapReady = true;fireCallbackIfReady();}/*** Callback method to be invoked when the global layout state or the visibility of views within* the view tree changes.**/@SuppressWarnings("deprecation")  // We use the new method when supported@SuppressLint("NewApi")  // We check which build version we are using.@Overridepublic void onGlobalLayout() {// Remove our listener.if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {       mapView.getViewTreeObserver().removeGlobalOnLayoutListener(this);} else {mapView.getViewTreeObserver().removeOnGlobalLayoutListener(this);}isViewReady = true;fireCallbackIfReady();}/*** If map view is ready, trigger the callback.**/private void fireCallbackIfReady() {if (isViewReady && isMapReady) {devCallback.onMapReady(googleMap);}}}

Then implement OnGlobalLayoutAndMapReadyListener from OnMapAndViewReadyListener class in our MapsActivity class.

public class MapsActivity extends AppCompatActivity implements        OnMapReadyCallback,        OnMapAndViewReadyListener.OnGlobalLayoutAndMapReadyListener

Next, once GoogleMap has been initialized on app start, we will want to change the user’s viewpoint of the map to the wanted area by modifying the map’s camera like below:

/** * Bound values for camera focus on app start. * BOUND1 is the relative coordinates of the bottom-left corner of the bounded area on the map. * BOUND2 is the relative coordinates of the top-right corner of the bounded area on the map. */private static final LatLng BOUND1 = new LatLng(-35.595209, 138.585857);private static final LatLng BOUND2 = new LatLng(-35.494644, 138.805927);/** * Method to move camera to wanted bus area. */private void moveCameraToWantedArea() {    mMap.setOnMapLoadedCallback(new GoogleMap.OnMapLoadedCallback() {        @Override        public void onMapLoaded() {            // Set up the bounds coordinates for the area we want the user's viewpoint to be.            LatLngBounds bounds = new LatLngBounds.Builder()                    .include(BOUND1)                    .include(BOUND2)                    .build();            // Move the camera now.            mMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, BOUNDS_PADDING));        }    });}

In order to get the relative coordinates of BOUND1 and BOUND2 in the snippet above, just simply use the “What’s here?” feature of Google Maps on a web browser. First, adjust the map to the area or region where you want the users’ viewpoint to be on app start; then right click on the bottom-left and top-right corners of the map view respectively. Choose “What’s here?” and copy the latitude and longitude values that you see in the small popup info windows. See pictures below:

And call this method within onMapReady():

// Move camera to wanted area.moveCameraToWantedArea();

Next, add this line to your app module’s build.gradle file to add the Maps SDK for Android Utility Library to your project:

implementation 'com.google.maps.android:android-maps-utils:0.5+'

By making use of adding the Maps SDK for Android Utility Library, we will be converting encoded polylines to latitude/longitude coordinates with just a single line of code.

Now, create a helper class called Utils.java with a method named readEncodedPolyLinePointsFromCSV(…). This method helps get polyline points by decoding an encoded coordinates string read from the CSV file created earlier:

public class Utils {/** * Default log tag name for log message. */private static final String LOG_TAG = MapsActivity.class.getName();/** * Keyword constants for reading values from polylines.csv. * Important: these keywords values must be exactly the same as ones in polylines.csv file in raw folder. */public static final String ENCODED_POINTS = "encodedPoints";public static final String LAT_LNG_POINT = "latLngPoint";public static final String MARKER = "marker";/** * Helper method to get polyline points by decoding an encoded coordinates string read from CSV file. */static List<LatLng> readEncodedPolyLinePointsFromCSV(Context context, String lineKeyword) {    // Create an InputStream object.    InputStream is = context.getResources().openRawResource(R.raw.polylines);    // Create a BufferedReader object to read values from CSV file.    BufferedReader reader = new BufferedReader(new InputStreamReader(is, Charset.forName("UTF-8")));    String line = "";    // Create a list of LatLng objects.    List<LatLng> latLngList = new ArrayList<>();    try {        while ((line = reader.readLine()) != null) {            // Split the line into different tokens (using the comma as a separator).            String[] tokens = line.split(",");            // Only add the right latlng points to a desired line by color.            if (tokens[0].trim().equals(lineKeyword) && tokens[1].trim().equals(ENCODED_POINTS)) {                // Use PolyUtil to decode the polylines path into list of LatLng objects.                latLngList.addAll(PolyUtil.decode(tokens[2].trim().replace("\\\\", "\\")));                Log.d(LOG_TAG + lineKeyword, tokens[2].trim());                for (LatLng lat : latLngList) {                    Log.d(LOG_TAG + lineKeyword, lat.latitude + ", " + lat.longitude);                }            } else {                Log.d(LOG_TAG, "null");            }        }    } catch (IOException e1) {        Log.e(LOG_TAG, "Error" + line, e1);        e1.printStackTrace();    }    return latLngList;}}

Then in your MapsActivity class, create a method called drawAllPolyLines() for drawing all required polylines:

/** * Method to draw all poly lines. This will manually draw polylines one by one on the map by calling * addPolyline(PolylineOptions) on a map instance. The parameter passed in is a new PolylineOptions * object which can be configured with details such as line color, line width, clickability, and  * a list of coordinates values.  */private void drawAllPolyLines() {    // Add a blue Polyline.    mMap.addPolyline(new PolylineOptions()            .color(getResources().getColor(R.color.colorPolyLineBlue)) // Line color.            .width(POLYLINE_WIDTH) // Line width.            .clickable(false) // Able to click or not.            .addAll(readEncodedPolyLinePointsFromCSV(this, LINE_BLUE))); // all the whole list of lat lng value pairs which is retrieved by calling helper method readEncodedPolyLinePointsFromCSV.    // Add a violet Polyline.    mMap.addPolyline(new PolylineOptions()            .color(getResources().getColor(R.color.colorPolyLineViolet))            .width(POLYLINE_WIDTH)            .clickable(false)            .addAll(readEncodedPolyLinePointsFromCSV(this, LINE_VIOLET)));    // Add an orange Polyline.    mMap.addPolyline(new PolylineOptions()            .color(getResources().getColor(R.color.colorPolyLineOrange))            .width(POLYLINE_WIDTH)            .clickable(false)            .addAll(readEncodedPolyLinePointsFromCSV(this, LINE_ORANGE)));    // Add a green Polyline.    mMap.addPolyline(new PolylineOptions()            .color(getResources().getColor(R.color.colorPolyLineGreen))            .width(POLYLINE_WIDTH)            .clickable(false)            .addAll(readEncodedPolyLinePointsFromCSV(this, LINE_GREEN)));    // Add a pink Polyline.    mMap.addPolyline(new PolylineOptions()            .color(getResources().getColor(R.color.colorPolyLinePink))            .width(POLYLINE_WIDTH)            .clickable(false)            .addAll(readEncodedPolyLinePointsFromCSV(this, LINE_PINK)));}

And call this method within onMapReady()

// draw all polylines.drawAllPolyLines();

Run the project and the result should look like this:

5. Determine markers’ coordinates (bus stops locations)

Markers indicate single locations for all of our bus stops on the map.

First, we will need to identify all the coordinates for our markers (bus stops) on Google Maps. On Google Maps on the web browser, just right-click on a position where you want to get the coordinates and choose “What’s here?”

A small info window will display:

Now you can simply copy the latitudes and longitudes of the desired locations and write them all to polylines.csv:

6. Add mass bus stop markers on the map

Add a helper method in Utils.java class to help resize the drawables used as markers for our bus stops:

/** * Marker bitmap resize tool. This will create a sized bitmap to apply in markers based on input drawable. */static Bitmap resizeMarker(Context context, int drawable) {    BitmapDrawable bitmapDrawable = (BitmapDrawable) context.getResources().getDrawable(drawable);    Bitmap bitmap = bitmapDrawable.getBitmap();    // Change expectedWidth's value to your desired one.    final int expectedWidth = 60;    return Bitmap.createScaledBitmap(bitmap, expectedWidth, (bitmap.getHeight() * expectedWidth) / (bitmap.getWidth()), false);}

Then in MapsActivity class, we manipulate that helper method like below:

/** * Method to add all bus stop markers based on line colors. */private void addAllBusStopMarkers() {    addBulkMarkers(MAIN_MARKER);    addBulkMarkers(LINE_BLUE);    addBulkMarkers(LINE_ORANGE);    addBulkMarkers(LINE_VIOLET);    addBulkMarkers(LINE_GREEN);    addBulkMarkers(LINE_PINK);}/** * Helper method to add bulk markers to map as per line keyword. */private void addBulkMarkers(String lineKeyword) {    // Create a list of LatLng objects and load all of the markers coordinates from CSV file.    List<LatLng> latLngList = readMarkersFromCSV(this, lineKeyword);    // Create a Bitmap object that holds the right resized marker image based on line color.    Bitmap resizedBitmap = resizeMarker(this, R.drawable.marker_blue_dark);    switch (lineKeyword) {        case LINE_BLUE:            resizedBitmap = resizeMarker(this, R.drawable.marker_blue_light);            break;        case LINE_ORANGE:            resizedBitmap = resizeMarker(this, R.drawable.marker_orange);            break;        case LINE_VIOLET:            resizedBitmap = resizeMarker(this, R.drawable.marker_violet);            break;        case LINE_GREEN:            resizedBitmap = resizeMarker(this, R.drawable.marker_green);            break;        case LINE_PINK:            resizedBitmap = resizeMarker(this, R.drawable.marker_pink);            break;        case MAIN_MARKER:            resizedBitmap = resizeMarker(this, R.drawable.marker_blue_dark);            break;    }    // For each of LatLng object in the latLngList, add a relevant marker with the right latitude// and longitude values and bitmap image. for (LatLng latLng : latLngList) {    mMap.addMarker(new MarkerOptions()            .position(new LatLng(latLng.latitude, latLng.longitude))            .icon(BitmapDescriptorFactory.fromBitmap(resizedBitmap))            .title("Bus Stop") // Title of info window displayed on marker click.            .snippet("This is a bus stop")); // Content of info window displayed on marker click.}}

And call this method within onMapReady():

// Add all markers (bus stops) to the map.addAllBusStopMarkers();

Run the project and the result should look like this:

When you click on a marker, a small info window should display:

7. Determine annotations’ coordinates

Each bus stop has a unique annotation attached alongside. In order for these annotations to be displayed on the map, we have to utilize either Ground Overlays or Markers feature of the Map SDK.

According to Google developer docs, ground overlays are image overlays that are tied to latitude/longitude coordinates, so they move when you drag or zoom the map. Just like markers, they are fixed to a map, but unlike markers, ground overlays are oriented against the Earth’s surface rather than the screen, so rotating, tilting or zooming the map will change the orientation of the image. Ground overlays are useful when you wish to fix a single image at one area on the map.

In our app, we will be laying all of our annotation texts on the map as Markers, not Ground Overlays because we don’t want them to change sizes when zooming the map. See 2 pictures below for comparison:

Now we need to manually select relative positions of these annotations on Google Maps just like identifying bus stops’ coordinates in step 5.

You may get annotations’ coordinates one by one from Google Maps on your web browser and follow through step 8 below.

8. Add all annotations as markers on the map

Annotations in our app are just simply texts. To style them nicely as you can see in the app design, we need to either create them as PNG images by using a photo editor tool like Photoshop or programmatically generate them using Bitmap and Canvas classes. To simplify, we opt to use the first solution. You can find all of these annotation images in the sample code package or you may make up your own images.

The helper method resizeCommonAnnotation(…) helps adjust the size of every annotation image accordingly. Add it to your Utils.java class:

/** * Annotation bitmap resize tool. This will create a sized bitmap to apply in annotations based on input drawable. */static Bitmap resizeCommonAnnotation(Context context, int drawable) {    BitmapDrawable bitmapDrawable = (BitmapDrawable) context.getResources().getDrawable(drawable);    Bitmap bitmap = bitmapDrawable.getBitmap();    // Change scale's value to your desired one.    float scale = 0.15f;    int newWidth = (int) (bitmapDrawable.getBitmap().getWidth() * scale);    int newHeight = (int) (bitmapDrawable.getBitmap().getHeight() * scale);    return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, false);}

In your MapsActivity class, add addAllAnnotationsAsMarkers() method to help add all of the annotations to our map. Add your first annotation like below:

private void addAllAnnotationsAsMarkers() {    mMap.addMarker(new MarkerOptions()            .icon(BitmapDescriptorFactory.fromBitmap(resizeCommonAnnotation(R.drawable.anno_whalers_inn)))            .anchor(0, 0.5f)            .position(new LatLng(-35.5863, 138.59842))    );}

According to Google developer docs, method anchor(…) specifies the anchor at a particular point in the marker image. The anchor specifies the point in the icon image that is anchored to the marker’s position on the Earth’s surface.

The anchor point is specified in the continuous space [0.0, 1.0] x [0.0, 1.0], where (0, 0) is the top-left corner of the image, and (1, 1) is the bottom-right corner. The anchoring point in a W x H image is the nearest discrete grid point in a (W + 1) x (H + 1) grid, obtained by scaling them then rounding. For example, in a 4 x 2 image, the anchor point (0.7, 0.6) resolves to the grid point at (3, 1):

Now step by step manually append the remaining annotations as markers to addAllAnnotationsAsMarkers() method above (find a whole lot of code in the downloaded sample package).

Lastly, call the method addAllAnnotationsAsMarkers() in onMapReady():

// Add all annotations as markers on map.addAllAnnotationsAsMarkers();

Run your project and you should see this:

9. Determine arrows’ coordinates

We are going to add all of the directional arrows along the bus routes as Ground Overlays on the map. By doing this, the arrows’ sizes will change proportionally with the zooming level of the map.

Similar to specifying markers’ coordinates as described in step 5, utilize the google maps “What’s here” feature to get directional arrows’ coordinates one by one. Then follow step 10.

10. Add all directional arrows as Ground Overlays on map

Add your first arrow by adding the codes below to your MapsActivity class:

/** * Default width of directional arrows. */private static final float DIRECTION_ARROW_WIDTH = 400f;/** * List of GroundOverlay objects. */private List<GroundOverlay> mGroundOverlay = new ArrayList<>();/** * Method to add all directional arrows as ground overlay images. * The bearing(…) method specifies the bearing of the ground overlay in degrees clockwise from north. * The rotation is performed about the anchor point. * If not specified, the default is 0 (i.e., up on the image points north). * Values outside the range [0, 360) will be normalized (Google developer docs). * DIRECTION_ARROW_WIDTH specifies the default width of the Ground Overlays */private void addAllDirectionArrowsAsGroundOverlay() {    // blue arrows    mGroundOverlay.add(mMap.addGroundOverlay(new GroundOverlayOptions()            .image(BitmapDescriptorFactory.fromResource(R.drawable.arrow_blue))            .anchor(0, 0.5f)            .position(new LatLng(-35.586970, 138.597452), DIRECTION_ARROW_WIDTH)            .bearing(250)            .clickable(false)));}

Looking at the code snippet above, bearing(…) method specifies the bearing of the ground overlay in degrees clockwise from north. The rotation is performed about the anchor point. If not specified, the default is 0 (i.e., upon the image points north). Values outside the range [0, 360) will be normalized (Google developer docs).

Now step by step manually append the remaining arrows as Ground Overlays to addAllDirectionArrowsAsGroundOverlay() method above (find a whole lot of codes in the downloaded sample package).

Lastly, call this method in onMapReady():

// Add all directional arrows.addAllDirectionArrowsAsGroundOverlay();

Run your project and you should see the final result:

Hope you enjoy the tutorial. We welcome all feedback and questions from you. Contact us now!

--

--