How to build a location-based hybrid mobile app with reverse geocoding

Over the weekend I released a new app on the App Store and Google Play and I wanted to share how I handled the geolocation features and GMaps integration.

The app is location-based wine rating. You can track what wine you’ve consumed at restaurants by taking photos of labels and rating them. I hate going back to a restaurant and not knowing what we ordered last time – so I built this app! Check it out:

To build the app I used HTML, CSS, JavaScript and the Trigger.io platform to add native features and package it for the app stores, with these libraries:

I’ll mainly focus on the Google Maps integration part and assume existing knowledge of Backbone. But I’ll also try to highlight some other interesting snippets. We will cover how to:

  • render the map at app startup for a fast user experience when the user clicks to view
  • load Google Maps asynchronously and place markers
  • get the current location and do a reverse geocoding lookup
  • control navigation between backbone views from the native tabbar

There is a lot going on in the app so we’ll only look at snippets. For the full picture, you can see the code on GitHub.

Using the Forge build / test cycle

Throughout the development of this app, I used Trigger.io’s Forge platform to run fast build / test cycles in the device emulators so I could develop the flow with the native device features and the Forge API already incorporated. Each time I change the code it was just a couple of seconds and two commands to see the changes:

forge build

forge run ios

If you want to try building the code as native apps yourself as you follow along this post, just signup for free and download the lightweight command-line tools to get started.

I also used Catalyst – a WebKit like debugger for mobile webviews to see the logging output and debug the presentation and logic.

Basic Structure

The first step was to include all my JavaScript and HTML templates in my index.html here. Then I described the paths through the app in router.js:

// Router
wine.types.Router = Backbone.Router.extend({
  routes: {
    "rateTab": "rateTab",
    "listTab": "listTab",
    "mapTab": "mapTab",
    "mapTab/:idx": "mapTab",
    "picture": "picture",
    "rate": "rate",
    "detail/:idx": "detail"
  },
  rateTab: function() {
    state.get('rateButton').setActive();
    forge.topbar.setTitle("Rate Wine");
    if (!state.get('currentPhoto')) {
      wine.router.navigate('picture', { trigger: true });
    } else {
      wine.router.navigate('rate', { trigger: true });
    }
  },
  listTab: function() {
    state.get('listButton').setActive();
    forge.topbar.setTitle("Wine List");
    state.get('list').show();
  },
  mapTab: function(idx) {
    state.get('mapButton').setActive();
    forge.topbar.setTitle("Wine Map");
    state.get('map').show(idx);
  },
  picture: function () {
    state.set('currentPhoto', null);
    var page = new wine.views.Picture();
    page.render().show();
  },
  rate: function() {
    var page = new wine.views.Rate();
    page.render().show();
  },
  detail: function(idx) {
    forge.logging.log('... Showing detail for index: '+idx);
    var page = new wine.views.Detail();
    page.render(idx).show();
  }
});
wine.router = new wine.types.Router();

You can see that the mapTab is one of the routes and we can optionally pass it an index of a particular item to plot (when we don’t, all of the items will be plotted). Lets focus in on that map view.

Pre-loading Google Maps view

When we navigate to the mapTab in the router, we actually show a pre-existing map rather than initializing it there:

mapTab: function(idx) {
  state.get('mapButton').setActive();
  forge.topbar.setTitle("Wine Map");
  state.get('map').show(idx);
}

This is for a smoother experience since when the user clicks the map tab we’ve actually preloaded the map in the initialization code in wine.js:

state.set('map', new wine.views.Map());
state.get('map').render();
forge.logging.log('Pre-rendered map');

In views.js, the render function of the wine.views.Map prototype looks like this:

render: function() {
  var el = this.el;
  var script = document.createElement("script");
  script.type = "text/javascript";
  script.src = "http://maps.googleapis.com/maps/api/js?key=<YOUR_API_KEY>&sensor=true&callback=wine.util.initMap";
  document.body.appendChild(script);
  $('#map_container').append(el);
  return this;
}

You can signup for a Google Maps API key here. In the render function, we asynchronously load the map by embedding the script tag for it. This can then be loaded in the background and initialized when ready. The div with id ‘map_container’ is set to have width and height 100%, but is hidden until the show() function is called from the router. The callback function wine.util.initMap simply references the initMap function on the map view object.

Placing markers and controlling navigation

Here’s how we initialize the map with markers once the GMaps script has loaded:

initMap: function() {
  forge.logging.log('... Initializing map');
  forge.geolocation.getCurrentPosition(function(position) {
    $(this.el).empty();
    state.set('currentCoords', position.coords);
    forge.logging.log('Set current position:');
    forge.logging.log(position.coords);
    var latLng = new google.maps.LatLng(position.coords.latitude, position.coords.longitude, true);
    var myOptions = {
      zoom: 15,
      center: latLng,
      mapTypeId: google.maps.MapTypeId.ROADMAP
    }
    forge.logging.log('... Create map');
    state.get('map').gmap = new google.maps.Map(document.getElementById('map'), myOptions);
    forge.tools.getURL('img/blue-pin.png', function(src) {
      forge.logging.log('... Add position marker');
      state.set('currentMarker', new google.maps.Marker({
        position: latLng,
        title: "Current Position",
        icon: src,
        map: state.get('map').gmap,
        zIndex: -1
      }));
    });
    wine.photos.each(state.get('map').add);
    forge.logging.log('Created map ...');
  });
}

This snippet shows how we center the map on, and set a custom marker icon for, the current position. We’ll use the default icon to plot the other locations (where we’ve previously taken photos of wine labels). Here’s what happens when we call :

wine.photos.each(state.get('map').add);
// ...
add: function(item) {
  var latLng = new google.maps.LatLng(item.get('position').latitude, item.get('position').longitude, true);
  var marker = new google.maps.Marker({
    position: latLng,
    map: state.get('map').gmap,
    zIndex: 1
  });
  var idx = wine.photos.indexOf(item)
  google.maps.event.addListener(marker, 'click', function() {
    wine.router.navigate('detail/'+idx, { trigger: true });
    $('#map_container').hide();
  });
}

Once the map is in place, you can see how simple it is to place markers for all the objects you wish to plot and then to control navigation when those markers are clicked using the call to :

google.maps.event.addListener

Showing the map with all items or a specific item

It only remains for us to display the map when the user navigates there, either to show the locations of all the items relative to the current position, or to focus on one item in particular. To do that, here is the show function:

show: function(idx) {
  $('#map_container').show();
  wine.util.resetCurrentView(this);
  if (state.get('map').gmap) {
    google.maps.event.trigger(state.get('map').gmap, 'resize');
    var currentLatLng = new google.maps.LatLng(state.get('currentCoords').latitude, state.get('currentCoords').longitude, true);
    if (idx) {
      var item = wine.photos.at(idx);
      var latLng = new google.maps.LatLng(item.get('position').latitude, item.get('position').longitude, true);
      state.get('map').gmap.setCenter(latLng);
    } else {
      state.get('map').gmap.setCenter(currentLatLng);
    }
    state.get('currentMarker').setPosition(currentLatLng);
  } else {
    this.initMap();
    $('#map').html('<div class="title">Loading...</div>;');
  }
  if (idx) {
    forge.topbar.addButton({
      text: 'Back',
      position: 'left',
      type: 'back'
    }, function() {
      $('#map_container').hide();
    });
  }
}

The idx parameter is optionally passed in – this refers to the index of the item in our model that we want to focus on. The code branches depending on whether it is passed in. If it is not, the map centers on the current location, otherwise it centers on the location of that item.

There’s no guarantee that the map has loaded already when the view is shown since the Google Maps script is loaded asynchronously. This is why we also need to check for the maps existence and display ‘Loading…’ text as a stop-gap.

Getting the current location and reverse geocoding lookup

That’s pretty much it for the map view, but there’s a couple more pieces to making the app work well with location.

In the map view functions, you’ll have seen references to the current location and it’s worth highlighting how we get that. In the initMap function, we used this call:

forge.geolocation.getCurrentPosition

We could use HTML5 geolocation instead, but the problem with that is it throws up an ugly warning to the user with the full path to the index.html file. Using the forge.geolocation.getCurrentPosition API we access true native geolocation and the warning message is much friendler:

 

The final piece is reverse geocoding: extracting an address from location data, because we want to show a list of the locations where we’ve taken photos of wine labels as well just plotting them on a map. To do that is simple with the v3 Google Maps API:

getLocation: function(coords, timestamp) {
  forge.request.ajax({
    url: "http://maps.googleapis.com/maps/api/geocode/json?latlng="+coords.latitude+","+coords.longitude+"&amp;sensor=true",
    dataType: "json",
    success: function(response) {
      try {
        var photo = wine.photos.filter(function(item) {
          return item.get('timestamp') == timestamp;
        })[0];
        if (photo) {
          photo.set('location', response.results[0].formatted_address);
          $('#_'+timestamp+' .title').html(photo.get('location'));
        } else {
          forge.logging.log('No photo with timestamp: '+timestamp);
        }
      } catch(e) {
        forge.logging.log('--- Exception getting location --- ');
        forge.logging.log(e);
        forge.logging.log('--- Photo:');
        forge.logging.log(photo);
        forge.logging.log('--- Response:');
        forge.logging.log(response);
      }
    },
    error: function(response) {
      forge.logging.log('--- Error getting location, response:');
      forge.logging.log(response);
    }
  });
}

Controlling navigation from the native tabbar

That’s it for the location part of the app, but if you look at the code, you can see a lot more going on, such as use of Mustache templates, the native camera using forge.file.getImage, and a star rating input element (thanks to the Yahoo! User Interface Blog for the snippets).

But the remaining aspect I’d like to highlight is just how easy it is to configure a native tabbar to handle navigation in conjunction with backbone. Check-out this snippet from main.js:

forge.tabbar.addButton({
  text: "Rate Wine",
  icon: "img/star.png",
  index: 0
}, function (button) {
  state.set('rateButton', button);
  button.onPressed.addListener(function () {
    wine.router.navigate('rateTab', { trigger: true });
  });
});

forge.tabbar.addButton({
  text: "Wine List",
  icon: "img/bottle.png",
  index: 1
}, function (button) {
  state.set('listButton', button);
  button.onPressed.addListener(function () {
    wine.router.navigate('listTab', { trigger: true });
  });
});

forge.tabbar.addButton({
  text: "Wine Map",
  icon: "img/map.png",
  index: 2
}, function (button) {
  state.set('mapButton', button);
  button.onPressed.addListener(function () {
    wine.router.navigate('mapTab', { trigger: true });
  });
});

Conclusion

Using the Forge API and build/test cycle, backbone and other great libraries you can develop powerful, native mobile applications with relatively few lines of code. You can use native geolocation and the Google Maps API to add location-based features fast.

We hope this has whetted your appetite to try developing hybrid mobile apps incorporating Google Maps.

Questions, comments? Let us know at support@trigger.io. We’d love to hear from you and help with your app plans. Sign up now to get started.