In this tutorial, we will show you how to use Swift 3 to develop a basic mapping application with the HERE Mobile SDK for iOS. The tutorial is based on the HERE Mobile SDK 3.2.2. You can always get the latest Mobile SDK from our developer portal. Log into your existing account or register for a free trial.
We will also use XCode 8.1 with Swift Version 3. Since XCode and Swift change regularly, other versions will probably work somewhat differently and you may need to adapt accordingly. For XCode 8.1 including Swift 3.0, please head to the Apple Mac App Store to download it for free, or get it directly from the Apple Developer site.
Once you have your tools up to date, we can start with developing our first Swift 3.0 app using the HERE Mobile SDK. Let's fire up XCode and create a New->Project and select a "Single View Application".
On the next page, take a close look at the bundle identifier, since it is important for the license key.
The bundle identifier needs to match the one you specified when registering for the Mobile SDK on the developer portal. This is not Swift-specific and also applies to Objective-C (and even Android, where it's bound to the Java namespace). For this tutorial we've chosen com.here.sdkexample. If the bundle identifier does not match, the SDK will throw an exception on startup telling you that you don't have the right license – so make sure it matches.
Save your project and you're good to go, though it's not a bad idea to just compile and run it once to check if everything works as expected.
Now we will add the HERE Mobile SDK to the project. To do so we extract the Mobile SDK archive for iOS. This includes the required NMAKit.framework. Drag and drop the NMAKit.framework into your XCode project. Check the "copy if needed" option. Do the same for the NMABundle.bundle included in the NMAKit.Framework since it contains all the assets and resource files needed for a HERE SDK project.
The current version of the Mobile SDK can't be directly imported into Swift and requires a bridging header to use it. The easiest way to add a bridging header to your project is to add a new Objective-C file to it. So, right click on your project and select "New->File" and then "Objective-C file".
Give the file a name of your choosing and click on "create". This will trigger a dialog asking you if you want to set it up as a bridging header.
Click on "Create Bridging Header" and the files will be correctly added to your Swift project (XCode automatically appends the name "Bridging-Header" to your filename).
Now edit the header file to import the NMAKit:
#import <NMAKit/NMAKit.h> |
Note: For more details how Swift and Obj-C libraries interoperate, Apple have comprehensive information available about this topic on their developer site.
As with an Objective-C project, the HERE SDK needs some additional libraries to work. So let's add these dependencies in the settings for the TARGETS. Add the NMAKit.framework to the section "General->Embedded Binaries". Then add the following system libraries to the section "General->Linked Frameworks and Libraries".
The general project setup should now be ready for adding our first Swift code.
Let's start with setting up AppID, AppCode and license key for the HERE Mobile SDK. This information is available on the developer portal in your account. Use these credentials and call NMAApplicationContext.setAppId in AppDelegate.swift in the application function.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { |
NMAApplicationContext.setAppId(yourAppID, appCode: yourAppCode, licenseKey: yourLicenseKey) |
For this example, we will now add an NMAMapView within Storyboard to setup the visual map component for our app. Let's open Main.storyboard by clicking on the file and it should look like this.
We will reuse the existing View in our ViewController and transform it into an NMAMapView. Click on the View in "View Controller Scene", and change the class to "NMAMapView" in the Identity Inspector (make sure the right panel for the context options is enabled to see these options).
Now the main view of our storyboard will be used as our map canvas. If you compile and run your project, you should see your first map!
You can interact with the map, but it doesn't have any features beyond that. We're now going to modify the map setup to fit our custom needs and add some more functionality to our app.
To do so, we need to access the NMAMapView instance. This works more or less the same as it does with Objective-C. We create an Outlet in our source code that is linked to our Storyboard MapView object. The easiest way to do this is in the Storyboard itself. Switch to the "Assistant Editor" (the menu button with the two intersecting rings) and you see your storyboard and your controller source code next to each other. Now hold down CTRL, then drag and drop your MapView from storyboard into your controller.
You will be asked to give the outlet a name in your controller. We will simply call it "mapView" in this example. The controller source now has an additional line of code.
@IBOutlet var mapView: NMAMapView! |
This connects our code with the MapView instance in the UI and we can directly make use of it.
Note: If you are interested in more details on how Storyboard connects to your code in Swift, please refer to the Apple developer documentation.
Now let's start with modifying our initial map setup by moving the map to a certain position, setting at a zoom level and activating some more visuals on the map.
To do so, let's modify the ViewController's viewDidLoad() to look like this.
override func viewDidLoad() { |
// set position, zoomlevel and a bit of tilt |
mapView.setGeoCenter(NMAGeoCoordinates(latitude:52.51, longitude:13.39), with: NMAMapAnimation.bow) |
// show 3D Landmarks / 3D models for famous buildings |
mapView.landmarksVisible = false |
// show extruded buildings on closer zoomlevels |
mapView.extrudedBuildingsVisible = true |
// enable all embedded POIs on the map |
mapView.setVisibility(true, for: NMAMapPoiCategory.all ) |
This will set our mapView to the center of Berlin with 3D landmarks activated, extruded buildings enabled. It will also show embedded POI markers on the map.
Next we will enable positioning, as we want the app to detect and display our current position on the map.
First of all, we need to add the necessary permission to our Info.plist as we are used to from Objective-C. Since the HERE SDK uses the System CLLocation class, we have to request the common system permission for this. Right click on Info.plist in your project and "Open As-> Source Code". Then add the following permission request inside the <dict> tag.
<key>NSLocationWhenInUseUsageDescription</key> |
<string>This app needs to access your current location to display it on the map.</string> |
Then head back to our ViewControler.swift class, and add the following code to the viewDidLoad() method.
// activate positioning indicator |
mapView.positionIndicator.isVisible = true |
// activate accuracy halo |
mapView.positionIndicator.isAccuracyIndicatorVisible = true |
NMAPositioningManager.shared().startPositioning() |
If you start the app now, it will ask you for permission to allow it to access your position to display it on the map. It will display a position marker and an accuracy halo at your current position. There's one problem however: the map does not jump automatically to your position since we are not listening for the position update events in our code. Let's change this.
After starting the positioning, let's add few more lines of code.
// listen for position updates |
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.positionDidUpdate), name: NSNotification.Name.NMAPositioningManagerDidUpdatePosition, object: NMAPositioningManager.shared()) |
// listen for position lost events |
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.didLosePosition), name: NSNotification.Name.NMAPositioningManagerDidLosePosition, object: NMAPositioningManager.shared()) |
This listens for the position update and position lost events coming from the position manager and calls positionDidUpdate and didLosePosition inside our ViewController.
Since these methods don't exist yet, let's create them.
func positionDidUpdate(){ |
if let p = NMAPositioningManager.shared().currentPosition { |
mapView.setGeoCenter(p.coordinates, with: NMAMapAnimation.bow) |
As you can see, whenever we retrieve a position update event, we take the position and center our map there – this way our map view center will always follow our position. In practice you maybe want to do this only if the user was not previously panning before or only on the very first update - but for this example we will just center the map with every update.
To keep our code clean, we should also stop positioning when the app is running in the background. To do so we listen for the app lifecycle methods and add the following method that stops the service when our view disappears.
override func viewWillDisappear(_ animated: Bool) { |
NMAPositioningManager.shared().stopPositioning() |
NotificationCenter.default.removeObserver(self, name: |
NSNotification.Name.NMAPositioningManagerDidUpdatePosition, object: NMAPositioningManager.shared()) |
NotificationCenter.default.removeObserver(self, name: |
NSNotification.Name.NMAPositioningManagerDidLosePosition, object: NMAPositioningManager.shared()) |
Now we are displaying a map and current position. Next, we want to add routing to our application. Whenever we long press on the map, we want to calculate a route by car from our detected position to position we've pressed.
To start with this, we first have listen for the map gesture events to be able to handle them. Therefore we add a new class to our project which will do this. Add a new file via right click on the project->New File and select Swift file.
After clicking "next", we name it MyGestureHandler.swift and save it to our project. Now let's open this new swift file in XCode, and we will modify it to be a gesture delegate class which we can use to listen for all map interaction events. In our case we will listen for the long key press event.
The class is very small to start with. We just derive from the NMAGestureDelegate and handle the long key press event by implementing the corresponding protocol method from NMAGestureDelegate.
class MyGestureHandler: NSObject, NMAMapGestureDelegate { |
func mapView(_ mapView: NMAMapView!, didReceiveLongPressAtLocation location: CGPoint) |
Additionally, as we will need the MapView instance later in order to interact with the map, we will also save this into a member variable of the class.
Now let's go back to the ViewController.swift class. Here we use an instance of our new MyGestureHandler and set it as the gesture delegate to the MapView. First keep the MyGestureHandler instance as a member of the class so it doesn't get garbage collected.
class ViewController: UIViewController, NMAMapViewDelegate { |
@IBOutlet var mapView: NMAMapView! |
var gestureHandler:NMAMapGestureDelegate! |
Initialize it at the end of viewDidLoad() via:
gestureHandler = MyGestureHandler() |
mapView.gestureDelegate = gestureHandler |
Build and run to see if everything works fine. Now when you long press anywhere on the map, you should see the message "Long press" in the console output.
Note: The HERE SDK needs a basic order of initialization that you need to ensure. Besides the obvious setting of license key to NMAApplicationContext, the mapengine also needs to be launched before any use of the HERE SDK Methods and classes. In our example, the mapengine internally gets implicitly initialized via NMAMapView in the ViewController, therefore you can start using the SDK only when viewDidLoad() happens and the mapView is available. That's why in this example the SDK classes are always initialized after the app lifecycle has passed this point.
In the MyGestureHandler class, we will now replace the log message with code that will kick off a route calculation from your position to the point where you long pressed on the map. We will add a small method to this MyGestureHandler, called "calculateRoute()" which takes start and end coordinates as parameters.
func calculateRoute( from startPosition:NMAGeoCoordinates?, to endPosition: NMAGeoCoordinates?) -> Void { |
print("Calculating new route...") |
In the same class, we will call this method when we retrieve a long key press event, so the handler looks like this:
func mapView(_ mapView: NMAMapView!, didReceiveLongPressAtLocation location: CGPoint) { |
if NMAPositioningManager.shared().currentPosition != nil && NMAPositioningManager.shared().currentPosition.isValid { |
calculateRoute(from: NMAPositioningManager.shared().currentPosition.coordinates, to: mapView.geoCoordinates(from: location)) |
print("No valid startposition found") |
As you can see, we take the start coordinates directly from the position manager – if they are valid and the position was detected. The destination for our route will be the geo coordinate of the map where we long-pressed. However, since the press event returns screen coordinates, we need to convert them into the geospatial space – to geo coordinates. Fortunately, there's a small helper in the MapView class called "geoCoordinates(from:CGPoint)" which we can use to convert screen to geo coordinates.
No let's fill the calculateRoute method to do actual work (starting route calculation asynchronously, waiting and handling the callback, and showing the result on the map).
The HERE Mobile SDK offers two routing engines, the "urban mobility router" for all types of public transit route requests, and the "core router" which should be used for all other types of transport such as car, truck or walking.
In our case we want to calculate a car route, so we will select the CoreRouter class. Since the routing calculation tasks are asynchronous, the CoreRouter will be used to for the callbacks, meaning we need to keep the CoreRouter instance as a class member so it doesn't get garbage collected:
class MyGestureHandler: NSObject, NMAMapGestureDelegate { |
var cr: NMACoreRouter = NMACoreRouter() |
Note: In this case initialization can happen directly with the declaration, since the MyGestureHandler initialization happens in the ViewController.viewDidLoad() method and context and mapengine are already in place at that time.
Now let's complete the calculateRoute method which does all the heavy lifting.
The anatomy of a route requests looks like this:
- CoreRouter kicks off a route requests for two or more coordinates (first is the startpoint, last is the endpoint, everything in between is a stopover)
- Beside the coordinates, the CoreRouter takes a RoutingMode which defines transport mode and type
- Besides Type and Mode, RoutingMode also takes RoutingOptions which define further avoidance options
- RoutingOptions is a bitmask which allows us to encode the different avoidance modes
We will start implementing this in reverse order, as they are dependent on each other. Let's start with defining the RouteOptions.
// set routing options - avoid some road types explicitly |
let ro = NMARoutingOption.avoidTollRoad.rawValue | NMARoutingOption.avoidCarpool.rawValue | |
NMARoutingOption.avoidBoatFerry.rawValue | NMARoutingOption.avoidDirtRoad.rawValue |
Next we need to create the routing mode.
let rm = NMARoutingMode(routingType: NMARoutingType.balanced, transportMode: NMATransportMode.car, routingOptions: ro) |
In addition to using our previously set routing options, we specify that we want to take the balanced (between fast and shortest) route. Other options are "shortest" and "fastest". Our transport mode is "car" (other options would be "walk" or "truck").
Finally, our route calculation needs an array of waypoints to operate with. Obviously we want our start- and endpoint in this list. In some cases, it could be useful to also add some additional stopovers to your routing list - we won't do this in our example, so for now this will do.
let waypoints = [startPosition, endPosition] |
The startPosition and endPosition are the parameters we passed into the route calculation method from the GPS (start) and the point we pressed on the map (end). If wanted, we could optionally also add traffic optimized routing by setting the dynamicPenalty property of the CoreRoute.
The method should now look like the following.
func calculateRoute( from startPosition:NMAGeoCoordinates, to endPosition: NMAGeoCoordinates) -> Void { |
// set routing options - avoid some road types explicitly |
let ro = NMARoutingOption.avoidTollRoad.rawValue | NMARoutingOption.avoidCarpool.rawValue | NMARoutingOption.avoidBoatFerry.rawValue | NMARoutingOption.avoidDirtRoad.rawValue |
// setup routing mode for transport type and mode |
let rm = NMARoutingMode(routingType: NMARoutingType.balanced, transportMode: NMATransportMode.car, routingOptions: ro) |
// create a list of waypoints (start and end but no stopovers) |
let waypoints = [startPosition, endPosition] |
// routing penalties - we want traffic optimized routing |
let dp = NMADynamicPenalty() |
dp?.trafficPenaltyMode = NMATrafficPenaltyMode.optimal |
// next: kick off route calculation |
The final piece is still missing: kicking off route calculation and listening for the callbacks to show the route result on the map.
The core router offers a method called calculateRoute which takes our waypoint array, routing mode and a completion block for the callback as parameters. Besides some updates regarding the naming of properties, this is one of the behavioral changes from Swift 2.2 to Swift 3 with the HERE SDK.
Add this to our calculateRoute() method created above:
cr.calculateRoute(withStops: waypoints, routingMode: rm, completionBlock: { routeResult,error in |
if error == NMARoutingError.none || error == NMARoutingError.violatesOptions |
if let r = routeResult?.routes[0] as? NMARoute |
print("New route received") |
// when the route options were too restrictive for the route, we can still get a route back, but with violated options |
if error == NMARoutingError.violatesOptions { |
// use NMARoutingViolatedOption in NMARouteResult to get the violated options |
print("Violated \(routeResult?.violatedOptions.count) route options") |
if self.mapRoute != nil { |
self.map?.remove(self.mapRoute) |
self.mapRoute = NMAMapRoute(route: r) |
self.map?.add(self.mapRoute) |
self.map?.setBoundingBox(r.boundingBox, with: NMAMapAnimation.bow) |
print("ERROR: failed calculatig route: \(error.rawValue)") |
Some explanations regarding the error codes: We only assume that the route is valid when we have either no error (NMARoutingError.none) or a route that violated some of our route options (NMARoutingError.violatesOptions). This just means, that there was no possibility to calculate a proper route with respect to all our restrictions (such as unpaved roads in a rural area). It ultimately depends on your use case which results are acceptable to you, but in our example this is fine.
To get a valid route from the route result, we can just take the first one. It's possible to request multiple, alternative routes. All of them would be available in the route result array. Again, for our example, the first result is fine.
As a last step, I want to display the route on the map. Therefore, we need to wrap the NMARoute in an NMAMapRoute object so it can be added to the mapView like every other map object.
We also declare an NMAMapRoute in the class to keep track of the most recently displayed route, so we can remove it later if necessary.
class MyGestureHandler: NSObject, NMAMapGestureDelegate { |
var cr: NMACoreRouter = NMACoreRouter() |
var mapRoute: NMAMapRoute! |
In our example, we only want to show one route. So if another one is already displayed, we want to remove it first. Afterwards we create a new NMAMapRoute out of the route and call add() on mapView with this object.
Finally, for some visual polish, we set the viewport of the mapView to the boundingBox of the map with a nice bow transition, so the map will adapt to our new route object nicely. Check out the final result!