Creating a Map-based App with Nativescript Vue
As mobile devices and communication networks have evolved, one of their most popular uses has been for location-based applications. Nativescript offers a few plugins that provide both the maps and the location discovery tools needed to create a modern map-based application. For this post, I'll discuss how to use maps, location services and API calls to create a Nativescript Vue application that will display a map of the current user's location and allow the user to search for places nearby.
Getting Started
To begin, we'll create a new app named ns6maps using the CLI. When asked, choose a Vue.JS Blank app template to start coding from. Once that's complete, we'll add the Google Maps plugin and run it in the simulator to ensure everything is working.
tns create ns6maps
cd ns6maps
tns doctor
tns plugin add nativescript-google-maps-sdk
tns run ios
We'll need some API keys to use Google Maps on iOS and Android, so sign into the Google Cloud Platform site and create a new Project for your app.
Select that project at the top of the page and you'll see a screen with all the APIs available for use with your project
Enable the Maps SDK for iOS first by tapping on that box, it will bring you to an overview of that API. Tap on Credentials, then the link at the top to go to the main Google APIs & Services Credentials area and then click on the button to create credentials and select an API key. You'll get a popup showing the newly created key, and a link to restrict that key.
Follow the Restrict Key
link and select iOS apps under Application Restrictions.
This will require your app bundle identifier, so let's ensure it matches the one in your package.json
.
Save the settings, and make note of the iOS key for later. Now, create another api key, follow the restrict link and restrict this one to Android apps. For Android, you'll need to add SHA1 fingerprints for both your debug key and production keys used to develop the app. If you don't have a production keystore yet, create one by following the Google documentation. The debug fingerprint can be obtained by using keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
for Linux/OSX systems, and instructions are shown on the right side of the key restriction screen showing how to obtain the fingerprints for both debug and production versions of the app.
Now that we have api keys, let's add them to our app. For Android, we'll start by copying the template config file using:
cp -r node_modules/nativescript-google-maps-sdk/platforms/android/res/values app/App_Resources/Android/src/main/res
Then edit app/App_Resources/Android/src/main/res/values/nativescript_google_maps_api.xml
and uncomment the string after replacing the placeholder with your Android Google Maps API key. Finally, edit app/App_Resources/Android/src/main/AndroidManifest.xml
and add the following to the application
tag:
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="@string/nativescript_google_maps_api_key" />
For iOS, we'll edit /app/app.js and add the following code to the top of the page after the other imports, using the iOS Google Maps API key generated earlier. We'll also add code to register the plugin for use with Nativescript Vue, and load the plugin as MapView for use in this app.
import { isAndroid, isIOS } from "tns-core-modules/platform";
if (isIOS) {
GMSServices.provideAPIKey('REPLACE_WITH_IOS_KEY');
}
Vue.registerElement('MapView', () => require('nativescript-google-maps-sdk').MapView);
Edit Home.vue
and replace the contents with:
<template>
<Page actionBarHidden="true" backgroundSpanUnderStatusBar="false">
<StackLayout height="100%" width="100%" >
<MapView iosOverflowSafeArea="true" :latitude="latitude" :longitude="longitude" :zoom="zoom" :bearing="bearing" :tilt="tilt" height="100%" @mapReady="onMapReady" @markerSelect="onMarkerSelect" @markerInfoWindowTapped="onMarkerInfoWindowTapped"></MapView>
</StackLayout>
</Page>
</template>
<script>
export default {};
</script>
<style scoped>
</style>
Note that we've enclosed the Map component inside a StackLayout with both width and height set to 100%. This is done to avoid a potential problem when the map is part of a more complicated page layout, and to ensure the map component is shown full screen. We've also set the component's iosOverflowSafeArea
property to true, so that the map will completely fill the screen on larger notched devices instead of being restricted to the safe rectangular screen area. Run the app with tns run ios
and you should see a full page map if everything worked properly:
Geolocation with Nativescript
If you look at the MapView component, you'll see that we have parameters defined that we'll use to configure the map's location and display. We'll now add another plugin that will allow Nativescript to request the current device location, then set those as the coordinates to center the map on. We'll also display the current user's location on the map for reference. Start by installing the geolocation plugin:
tns plugin add nativescript-geolocation
We'll need extra permissions in order to access the device location, so let's take care of those first. Since we'll be working with a map, we'll want to request precise location permissions for this application. Edit app/App_Resources/Android/src/main/AndroidManifest.xml
and add the following permissions;
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
Edit app/App_Resources/iOS/Info.plist
and add the following strings to be shown to the user when requesting location permission:
<key>NSLocationWhenInUseUsageDescription</key>
<string>Find your current location to see places nearby.</string>
<key>NSLocationUsageDescription</key>
<string>Find your current location to see places nearby.</string>
Next we will fill in the exports default data section with variables used by the map component. We'll also add the mounted()
section where we will request location permissions, and then request the current location if we have them. If a location is returned by the device, we'll update the data variables with the user's current coordinates, which will also re-center the map on that location. Modify the script section to be:
const geolocation = require("nativescript-geolocation");
export default {
data() {
return {
latitude: '',
longitude: '',
zoom: '',
bearing: '',
tilt: '',
mapView:null,
}
},
mounted() {
let that = this
geolocation.enableLocationRequest(true, true).then(() => {
geolocation.isEnabled().then(value => {
if (!value) {
console.log("NO permissions!");
return false;
} else {
console.log("Have location permissions");
geolocation
.getCurrentLocation({
timeout: 20000
})
.then(location => {
if (!location) {
console.log("Failed to get location!");
} else {
that.latitude = location.latitude
that.longitude = location.longitude
that.zoom = 14
that.bearing = 0
that.altitude = 0
}
});
return true;
}
});
})
},
methods: {},
}
Run the app now on the iOS simulator, allow location permissions, and you should now see something like the following if your simulator has the default San Francisco location set.
Let's improve our map by showing the current user location. We'll configure out MapView by adding a new function that is called once the map component has been loaded and ready to use. Add the following functions to the methods section:
onMapReady(args) {
this.mapView = args.object;
this.mapView.myLocationEnabled = true;
this.mapView.zoomGesturesEnabled = true;
var gMap = this.mapView.gMap;
if (isAndroid) {
uiSettings = gMap.getUiSettings();
uiSettings.setMyLocationButtonEnabled(true);
gMap.setMyLocationEnabled(true);
}
if (isIOS) {
gMap.myLocationEnabled = true;
gMap.settings.myLocationButton = true;
this.mapView.on("myLocationTapped", event => {
geolocation.isEnabled().then(enabled => {
if (enabled) {
geolocation.getCurrentLocation({
maximumAge: 5000,
timeout: 20000
}).then(location => {
gMap.animateToLocation(location);
});
}
});
});
}
},
onMarkerSelect() {},
onMarkerInfoWindowTapped() {},
The onMapReady
function is run once the map component has initialized. First we store a reference to the map component for later use by other functions . We then configure the map to show the current user location, and enable a floating location button on each platform that is used to center the map on the current user's location. Finally, we enable the display of a compass button to orient the map north (only shown if the user has rotated the map), and allow zoom gestures. You'll note that for iOS, we've added a small workaround to fix a bug with this plugin that affects newer iOS versions, and prevents the map from centering when the button is tapped. Run the app on the simulator and you should now see a blue pulsing dot showing your device's current location, as well as the floating button used to center the map on that location.
Run the app on an Android simulator now. You will probably run into a few problems that will hang or crash the map plugin. In order to avoid one common problem with this plugin on Android, you should set the version of Google Play Services in app/App_Resources/Android/before-plugins.gradle
file (if the file does not exist, just create it):
android {
project.ext { googlePlayServicesVersion = "16.+" }
}
Once that's ready, you'll run into another problem on Android when trying to enable display of the current user's location without having permission to location yet in onMapReady()
. To avoid this, we'll use a flag isMounted
that is set after permissions status has been resolved in mounted()
, and only enable the current location setting on Android after location permission has been granted by the user and the MapView has been initialized. This approach may lead to a race condition since we expect the map plugin to finish loading before the permissions check in mounted()
, otherwise the button may not be enabled and shown. To avoid this, we'll also check if the mapView
variable has been assigned in mounted()
and permissions check have been completed before enabling this setting here as well. In a more realistic application, you'd probably hide the map screen with a loader or v-if
until everything else is ready and the map can start initializing. Update the default
section so it contains these changes:
data() {
return {
latitude: '',
longitude: '',
zoom: '',
bearing: '',
tilt: '',
mapView: null,
isMounted: false
}
},
mounted() {
let that = this
geolocation.isEnabled().then(function(isEnabled) {
if (!isEnabled) {
geolocation.enableLocationRequest(true, true).then(() => {
that.isMounted = true
if (isAndroid && that.mapView) {
let uiSettings = that.mapView.gMap.getUiSettings();
uiSettings.setMyLocationButtonEnabled(true);
that.mapView.gMap.setMyLocationEnabled(true);
}
geolocation
.getCurrentLocation({
timeout: 20000
})
.then(location => {
if (!location) {
console.log("Failed to get location!");
} else {
that.latitude = location.latitude
that.longitude = location.longitude
that.zoom = 14
that.bearing = 0
that.altitude = 0
}
});
}, (e) => {
console.log("Error: " + (e.message || e));
}).catch(ex => {
console.log("Unable to Enable Location", ex);
});
} else {
that.isMounted = true
if (isAndroid && that.mapView) {
let uiSettings = that.mapView.gMap.getUiSettings();
uiSettings.setMyLocationButtonEnabled(true);
that.mapView.gMap.setMyLocationEnabled(true);
}
geolocation
.getCurrentLocation({
timeout: 20000
})
.then(location => {
if (!location) {
console.log("Failed to get location!");
} else {
that.latitude = location.latitude
that.longitude = location.longitude
that.zoom = 14
that.bearing = 0
that.altitude = 0
}
});
}
}, function(e) {
console.log("Error: " + (e.message || e));
});
},
methods: {
onMapReady(args) {
this.mapView = args.object;
var gMap = this.mapView.gMap;
this.mapView.settings.myLocationEnabled = true;
this.mapView.settings.myLocationButtonEnabled = true
this.mapView.settings.compassEnabled = true
this.mapView.settings.zoomGesturesEnabled = true;
if (isAndroid && this.isMounted && geolocation.isEnabled()) {
let uiSettings = gMap.getUiSettings();
uiSettings.setMyLocationButtonEnabled(true);
gMap.setMyLocationEnabled(true);
}
if (isIOS) {
gMap.myLocationEnabled = true;
gMap.settings.myLocationButton = true;
this.mapView.on("myLocationTapped", event => {
geolocation.isEnabled().then(enabled => {
if (enabled) {
geolocation.getCurrentLocation({
maximumAge: 5000,
timeout: 20000
}).then(location => {
gMap.animateToLocation(location);
});
}
});
});
}
},
onMarkerSelect() {},
onMarkerInfoWindowTapped() {},
},
Run the app on an Android simulator and you should now see something similar to:
Adding Map Markers
We've got a map that shows our current location, but now let's make it useful by adding markers for locations of interest. Let's set our current location in the simulator to the coordinates of 645 San Antonio Rd, Mountain View, CA 94040, USA. We'll use example map markers for a few places near that location found by searching Google Maps for grocery stores, and make note of the name and location information. Add the following import to the top of your script section:
const mapsModule = require("nativescript-google-maps-sdk");
Then add a new data variable to store the array of markers to be rendered by our app:
markers: [{
name: 'Whole Foods',
address: '4800 El Camino Real',
city: 'Los Altos',
state: 'CA',
zip: '94022',
type: 'Grocery Store',
latitude: '37.398933',
longitude: '-122.110570',
},
{
name: 'Trader Joe\'s',
address: '590 Showers Dr',
city: 'Mountain View',
state: 'CA',
zip: '94040',
type: 'Grocery Store',
latitude: '37.402180',
longitude: '-122.110888',
},
{
name: 'Safeway',
address: '645 San Antonio Rd',
city: 'Mountain View',
state: 'CA',
zip: '94040',
type: 'Grocery Store',
latitude: '37.402079',
longitude: '-122.111946',
},
{
name: 'Walmart',
address: '600 Showers Dr',
city: 'Mountain View',
state: 'CA',
zip: '94040',
type: 'Grocery Store',
latitude: '37.400774',
longitude: '-122.109642',
},
]
Now we'll add markers using the following code added to the end of onMapReady
:
this.markers.forEach(element => {
var marker = new mapsModule.Marker();
marker.position = mapsModule.Position.positionFromLatLng(
element.latitude,
element.longitude
);
marker.title = element.name;
this.mapView.addMarker(marker);
})
Now if you run the app you should see something similar to:
When a marker is tapped, the map will center on that marker and display the marker's title. We can make this better by popping up a window with more information, like those you get when using Google Maps via their app or website. Replace the block of code just added with:
this.mapView.infoWindowTemplate = `<StackLayout orientation="vertical" width="240" height="140" >
<Label text="{{title}}" marginTop="26" marginLeft="20" textWrap="true" color="black" fontSize="18" />
<Label text="{{type}}" marginLeft="20" textWrap="true" color="gray" fontSize="12" />
<Label text="{{address}}" marginLeft="20" textWrap="true" color="gray" fontSize="14" />
</StackLayout>`;
this.markers.forEach(element => {
var marker = new mapsModule.Marker();
marker.position = mapsModule.Position.positionFromLatLng(
element.latitude,
element.longitude
);
marker.title = element.name;
marker.type = element.type
marker.address = element.address + ' ' + element.city + ',' + element.state + ' ' + element.zip
this.mapView.addMarker(marker);
})
We've added an XML template to use for the popup, which references properties we've also added to each marker object. You can experiment with styling and using local images in these popups, as well as implement the marker tap handler if you want to perform an action such as navigating to a details page for that location.
Search Box with Google Places
Let's make this map more interactive and add a search box connected to Google Places to find results and render markers based on those results. We'll need to get another api key for Google Places, which will require the same steps as the Google Maps key, except this time we'll just generate a single key restricted by API to only Google Places and use that for both platforms. Once you've got that key, add the following to the XML section inside the <StackLayout>
and above the MapView
component:
<SearchBar hint="What are you looking for?" v-model="searchPhrase" @submit="onSubmit" textFieldHintColor="gray" marginTop="20" />
This will render a search bar component at the top of the page, and will bind the search query to a new local variable searchPhrase
which we'll add to our data export section as searchPhrase: '',
. Add a new import to the top of your script section so we can make http calls: import * as http from "http";
. We'll need to setup a URL request to the API server that contains the current user location, the maximum distance for results of 1000 meters, the search query submitted by the user and the restricted Google Places API key. Any results returned will be parsed and added as new markers on the map, after first removing all old markers. This will happen inside the new onSubmit
function added to the methods section:
onSubmit() {
let that = this
this.mapView.removeAllMarkers()
let searchurl = "https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=" + this.latitude + "," + this.longitude + "&radius=1000&keyword=" + encodeURI(this.searchPhrase) + "&key=YOUR_API_KEY"
http
.request({
url: searchurl,
method: "GET",
timeout: 10000,
headers: { "Content-Type": "application/json" }
})
.then(function(data) {
if (data.statusCode == 200) {
let result = JSON.parse(data.content);
let results = result.results
results.forEach(element => {
var marker = new mapsModule.Marker();
marker.position = mapsModule.Position.positionFromLatLng(
element.geometry.location.lat,
element.geometry.location.lng
);
marker.title = element.name;
marker.type = element.types[0]
marker.address = element.vicinity
that.mapView.addMarker(marker);
})
} else {
console.log("Error requesting youtube metadata! Returned: ");
console.dir(data);
}
})
.catch(e => {
console.log("Error retreiving youtube metadata");
console.error(e);
});
},
This function first builds the query string for the Google Places API call by using the current user location, a search radius of 1 km, the search query and the API key. You can read more about how to use and interpret results from this call here. Once the call completes, we will check for a valid status code and then process any results returned by creating new markers and adding them to the map. Remove the example markers
variable array from the data section, and remove the code that added these example markers in onMapReady
. Run the code now and search for "grocery" and you should see something like:
This works so far, but if the user is moving around then the initial location we obtained when loading the app will no longer be accurate. To ensure we're sending our current location for search results, we can request the current location from the device before issuing the query, ensuring we'll get results for our current location instead of a past one. Modify the onSubmit
function to now be:
onSubmit() {
let that = this
geolocation
.getCurrentLocation({
timeout: 20000
})
.then(location => {
if (!location) {
console.log("Failed to get location!");
} else {
that.latitude = location.latitude
that.longitude = location.longitude
that.mapView.removeAllMarkers()
let searchurl = "https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=" + that.latitude + "," + that.longitude + "&radius=1000&keyword=" + encodeURI(that.searchPhrase) + "&key=AIzaSyCnnXo6rqBp4ItB__nC80OyoLDYjxWcxBQ"
http
.request({
url: searchurl,
method: "GET",
timeout: 10000,
headers: { "Content-Type": "application/json" }
})
.then(function(data) {
if (data.statusCode == 200) {
let result = JSON.parse(data.content);
let results = result.results
results.forEach(element => {
var marker = new mapsModule.Marker();
marker.position = mapsModule.Position.positionFromLatLng(
element.geometry.location.lat,
element.geometry.location.lng
);
marker.title = element.name;
marker.type = element.types[0]
marker.address = element.vicinity
that.mapView.addMarker(marker);
})
} else {
console.log("Error getting google places data");
console.dir(data);
}
})
.catch(e => {
console.log("Error getting google places data");
console.error(e);
});
}
});
},
While this does work, a better alternative would be to setup a location watcher using the geolocation plugin function watchLocation
and have that update the app's current user location in the background when the device location changes.
Bounding the Map
Another feature you will probably want to implement is to have the map zoomed and centered so it fits all the markers returned from the search results. This will require native calls specific to each platform to create a new bounding box from all the markers and then applying that to the MapView
to center it on that box. For Android we can calculate the bounding box once all the markers have been added to the map. For iOS, we'll need to perform operations before, during, and after all markers have been added. Update onSubmit
to be:
onSubmit() {
let that = this
geolocation
.getCurrentLocation({
timeout: 20000
})
.then(location => {
if (!location) {
console.log("Failed to get location!");
} else {
that.latitude = location.latitude
that.longitude = location.longitude
that.mapView.removeAllMarkers()
let searchurl = "https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=" + that.latitude + "," + that.longitude + "&radius=1000&keyword=" + encodeURI(that.searchPhrase) + "&key=AIzaSyCnnXo6rqBp4ItB__nC80OyoLDYjxWcxBQ"
http
.request({
url: searchurl,
method: "GET",
timeout: 10000,
headers: { "Content-Type": "application/json" }
})
.then(function(data) {
if (data.statusCode == 200) {
let result = JSON.parse(data.content);
let results = result.results
var bounds
let padding = 100
if (isIOS) {
bounds = GMSCoordinateBounds.alloc().init();
}
results.forEach(element => {
var marker = new mapsModule.Marker();
marker.position = mapsModule.Position.positionFromLatLng(
element.geometry.location.lat,
element.geometry.location.lng
);
if (isIOS) bounds = bounds.includingCoordinate(marker.position);
marker.title = element.name;
marker.type = element.types[0]
marker.address = element.vicinity
that.mapView.addMarker(marker);
})
if (isAndroid) {
var builder = new com.google.android.gms.maps.model.LatLngBounds.Builder();
that.mapView.findMarker(function(marker) { builder.include(marker.android.getPosition()); });
bounds = builder.build();
var cu = com.google.android.gms.maps.CameraUpdateFactory.newLatLngBounds(bounds, padding);
that.mapView.gMap.animateCamera(cu);
}
if (isIOS) {
var update = GMSCameraUpdate.fitBoundsWithPadding(bounds, padding);
that.mapView.gMap.animateWithCameraUpdate(update);
}
} else {
console.log("Error getting google places data");
console.dir(data);
}
})
.catch(e => {
console.log("Error getting google places data");
console.error(e);
});
}
});
},
Custom Marker Icons
Besides customizing the popup for each marker, we can take it one step further and also customize the appearance of the markers themselves. The simplest change we can make is to the modify the color of the marker icon. To do this we can simply assign each marker a color using marker.color="COLOR"
. Let's change all the markers to be green instead of red by adding marker.color="green"
to onSubmit
in the loop where we add the markers to the map. Now your map and markers should look like:
Using images is almost as easy, although you'll have to be careful with the image size used and the way they are rendered on iOS and Android platforms. Let's comment out the marker color assignment and add the following lines instead:
// marker.color = "green"
let imageSource = ImageSource.fromFileSync("~/images/foodicon.png");
const icon = new Image();
icon.imageSource = imageSource;
marker.icon = icon;
that.mapView.addMarker(marker);
We've downloaded a 48x48 icon image and placed it in the /app/images/
directory. We also need a few Nativescript image libraries, so add these imports to the script section:
import { ImageSource } from "tns-core-modules/image-source";
import { Image } from "tns-core-modules/ui/image";
Run the app, perform a search and you should now have markers with this icon instead of the default pin marker:
Done!
That's it for this post. If you'd like to download the final source files, you can find them on Github.