2017/03/12

MapView for the busy

I has been a while since I don't do iOS. These are my notes, hopefully helpful for others.

Just show me code

Here you go. Full working example :)

Show a map

Very simple, create a view controller with a MapKit MapView object and you are done. A MapView without delegate is still usable however I set it (in interface builder) for future use.
import UIKit
import MapKit

class MapViewController: UIViewController, MKMapViewDelegate {
    @IBOutlet fileprivate weak var mapView: MKMapView!
}

Get and show current location

Get current location is a task of CoreLocation. We need to receive updates from CoreLocationManagerDelegate so render current location in our mapView. Location itself will be stored in fromLocation and fromLocationAnnotation will be used to represent a pin at such location.
import UIKit
import MapKit
import CoreLocation

class MapInfoDetailViewController: UIViewController, MKMapViewDelegate, CLLocationManagerDelegate {
    @IBOutlet fileprivate weak var mapView: MKMapView!
    fileprivate var locationManager: CLLocationManager?
    fileprivate var fromLocation: CLLocation?
    fileprivate var fromLocationAnnotation: MKPointAnnotation?
}
We start the location manager
override func viewDidLoad() {
    super.viewDidLoad()

    // Start location manager
    if CLLocationManager.locationServicesEnabled() {
        locationManager = CLLocationManager()
        locationManager?.delegate = self
        locationManager?.desiredAccuracy = kCLLocationAccuracyBest
        locationManager?.requestAlwaysAuthorization()
        locationManager?.startUpdatingLocation()
    }
}
Note that in recent versions of iOS we need to add the following to the Info.plist otherwise CoreLocation will not work
<key>NSLocationAlwaysUsageDescription</key>
<string>🍌Short explanation of why you will require location services here🍌</string>
Here is the method that receives locations updates. This method is called several times in very short intervals
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let location = locations.last else { return }

    fromLocation = location

    if fromLocationAnnotation == nil {
        // First time: Create and add annotation
        fromLocationAnnotation = MKPointAnnotation()
        fromLocationAnnotation!.coordinate = fromLocation!.coordinate
        fromLocationAnnotation!.title = "Current Location"
        mapView.addAnnotation(fromLocationAnnotation!)
    } else {
        // Not first time: Update annotation
        mapView.removeAnnotation(fromLocationAnnotation!)
        fromLocationAnnotation!.coordinate = fromLocation!.coordinate
        mapView.addAnnotation(fromLocationAnnotation!)
    }
    mapView.showAnnotations(mapView.annotations, animated: true)

    if location.horizontalAccuracy < 30 {
        // IMO 30m is accurate enough to turn off location services to save battery :)
        locationManager?.stopUpdatingLocation()
    }
}
Previous method adds an MKPointAnnotation to the mapView. MKMapView will render the default annotation view for the given annotation. So far we our current location :)

Customize the pin

To customize the view we need to implement MKMapViewDelegate method. When we add MKAnnotation (usually a MKPointAnnotation) the map view will consult its delegate to see if there is a view for the given annotation. If we return nil or do not implement the delegate method is will draw the default view (a red pin). Usually you want to create a view with some extra information special to the location (For example number of likes for that place, an explanation of the place, etc) and a way to pass data from your model to the view is via a subclass of MKAnnotation. Each MKAnnotationView object will have a reference to an MKAnnotation so we have put data here.
import MapKit

class PlaceAnnotation: MKPointAnnotation {
    var label: String?
}
To use it instead of creating MKPointAnnotation we use our own:
...
// Instead of:
// fromLocationAnnotation = MKPointAnnotation()
// We can create our MKAnnotation and pass any data we might need later
let annotation = PlaceAnnotation()
annotation.label = "出発"
fromLocationAnnotation = annotation
...
The delegate method:
public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    guard let annotation = annotation as? PlaceAnnotation else {
        // Other annotations will show the default pin
        return nil
    }
    // Place annotation
    var annotationView: MKAnnotationView?
    let reuseId = String(describing: PlaceAnnotation.self)
    annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseId)
    if annotationView == nil {
        // Create annotation view
        annotationView = MKAnnotationView(
            annotation: annotation,
            reuseIdentifier: reuseId)
        annotationView?.canShowCallout = true
        annotationView?.image = image(text: annotation.label)
    } else {
        // Update annotation view (update the least possible)
        annotationView?.annotation = annotation
        annotationView?.image = image(text: annotation.label)
    }
    return annotationView
}
I am using a regular MKAnnotationView and simply customizing the image according the given data (I am creating an UIImage on the fly). In my small app I have just a few annotations so this does not cost me anything. If performance becomes a problem then we should create subclass MKAnnotationView and render things there rather than setting a different UIImage every time, to really reuse it. If you are curious about image(text: annotation.label) check code here.


Get and show location from an address

The act of find coordinates from a address text is called Geo Coding. Happily there is a geocoder class in CoreLocation so lets use it:

fileprivate var toLocation: CLLocation?

...

func getLocationAndShowRoute(address: String) {
    let geocoder = CLGeocoder()
    geocoder.geocodeAddressString(address) { (placemarks, error) in
        if let error = error {
            print("Geocoder error. " + error.localizedDescription)
            return
        }
        guard let placemark = placemarks?.last else {
            print("Geocoder error. No placemarks found")
            return
        }
        guard let location = placemark.location else {
            print("Geocoder error. No location in placemark")
            return
        }
        // Add location
        let annotation = MKPointAnnotation()
        annotation.coordinate = location.coordinate
        annotation.title = address
        self.mapView.addAnnotation(annotation)
        self.toLocation = location
    }
}

Draw route between two locations

By now we should have two locations:
  • fromLocation: current location found with help of CLLocationManager
  • toLocation: an arbitrary location found by geocoding an address

func showRoute(transportType: MKDirectionsTransportType) {
    guard let from = fromLocation else {
        print("showRoute: no fromLocation")
        return
    }
    guard let to = toLocation else {
        print("showRoute: no toLocation")
        return
    }
    
    // Search routes in MapKit (Japanese article)
    // http://qiita.com/oggata/items/18ce281d5818269c7281
    let fromPlacemark = MKPlacemark(coordinate: from.coordinate, addressDictionary: nil)
    let toPlacemark = MKPlacemark(coordinate: to.coordinate, addressDictionary: nil)
    
    let fromItem = MKMapItem(placemark:fromPlacemark)
    let toItem = MKMapItem(placemark:toPlacemark)
    
    let request = MKDirectionsRequest()
    request.source = fromItem
    request.destination = toItem
    request.requestsAlternateRoutes = false // only one route
    request.transportType = transportType
    
    let directions = MKDirections(request:request)
    directions.calculate { response, error in
        if let error = error {
            print("Route search error. " + error.localizedDescription)
            return
        }
        guard let route = response?.routes.last else {
            print("Route search error. No routes found")
            return
        }
        self.mapView.removeOverlays(self.mapView.overlays)
        self.mapView.add(route.polyline)
    }
}
Tadaa!
Full code available here.

0 comments :