This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We show the routing app we'll build in this series and take the first steps by rendering track polygons on a map.

00:06 A little while back, we started working on an app that shows various predefined running routes in the area where Chris lives. A frequently requested feature is the ability to select waypoints on the map to create a longer, custom route that combines portions of the tracks.

01:36 We're going to build this feature over the course of a few episodes, and in doing so, we'll dive into various interesting topics. We'll have to work with MapKit, render polygons on the map, and detect where the user taps on the map to find the nearest point on a track.

02:09 Also, in order to find the shortest route between selected points, we'll need to build up a graph from the tracks. This will be a challenge because the underlying GPX files have some artifacts that cause the tracks to not exactly line up with each other in some places, so we'll either have to clean up the data by preprocessing the files, or we'll have to figure out some other smart way to connect the various tracks into one graph.

Showing Tracks on a Map

02:50 But let's get started with something simpler: we'll build up the map view and add the tracks to it as polygons.

Starting out with an empty view controller, we create a map view as a property. We then add the map view to the view controller's view using a constraint helper we wrote in a previous episode. This helper comes with four functions that allow us to specify the Auto Layout constraints in a very declarative way. The equal function, for example, takes a key path to a layout anchor and uses it on both the subview and the superview to constrain their corresponding anchors to each other. In this way, we can easily make the map view fill up the entire view:

import UIKit
import MapKit

class ViewController: UIViewController {
    let mapView = MKMapView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(mapView, constraints: [
            equal(\.leadingAnchor), equal(\.trailingAnchor),
            equal(\.topAnchor), equal(\.bottomAnchor)
        ])
    }
}

04:39 Now that we have a map view, we can load the tracks and display them on the map. We've prepared the model code that deals with loading the data from disk, so now we can focus on the presentation of the tracks.

The loading is done synchronously, so we should dispatch this work onto a background queue. After the loading is done, we switch back to the main queue and call an update method with the results:

class ViewController: UIViewController {
    let mapView = MKMapView()
    
    override func viewDidLoad() {
        // ...
        DispatchQueue.global(qos: .userInitiated).async {
            let tracks = Track.load()
            DispatchQueue.main.async {
                self.updateMapView(tracks)
            }
        }
    }

    func updateMapView(_ newTracks: [Track]) {

    }
}

06:22 In the update method, we want to display the passed-in tracks on the map. We create a polygon from each track's coordinates, and we add this polygon as an overlay to the map view.

We need a pointer to β€” or an array of β€” CLCoordinate2D to create an MKPolygon. So we map over the track's coordinates and, using an initializer, we convert them from our own type Coordinate into the correct type:

class ViewController: UIViewController {
    // ...
    func updateMapView(_ newTracks: [Track]) {
        for t in newTracks {
            let coords = t.coordinates.map { CLLocationCoordinate2D($0.coordinate) }
            let polygon = MKPolygon(coordinates: coords, count: coords.count)
            mapView.addOverlay(polygon)
        }
    }
}


extension CLLocationCoordinate2D {
    init(_ coord: Coordinate) {
        self.init(latitude: coord.latitude, longitude: coord.longitude)
    }
}

08:34 If we run this, we won't see anything yet, because we first have to set up the map view's delegate, which can then provide an overlay renderer β€” more specifically, a polygon renderer:

class ViewController: UIViewController {
    let mapView = MKMapView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        mapView.delegate = self
        // ...
    }

    // ...
}

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        let r = MKPolygonRenderer(overlay: overlay)
        r.lineWidth = 1
        r.strokeColor = .black
        return r
    }
}

Coloring the Polygons

10:31 Our tracks now show up on the map as black lines, but we'd rather use the colors provided by the tracks. This means we have to know which track corresponds with the overlay that's passed into the renderer delegate method. We create a dictionary that maps each track to a polygon:

class ViewController: UIViewController {
    let mapView = MKMapView()
    var tracks: [Track:MKPolygon] = [:]
    // ...

    func updateMapView(_ newTracks: [Track]) {
        for t in newTracks {
            // ...
            tracks[t] = polygon
        }
    }
}

12:22 Where we set up the renderer, we first check that the passed-in overlay is indeed a polygon. Otherwise, we return a generic overlay renderer. After this check, we can also use the MKPolygonRenderer's dedicated polygon initializer:

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        guard let p = overlay as? MKPolygon else {
            return MKOverlayRenderer(overlay: overlay)
        }
        let r = MKPolygonRenderer(polygon: p)
        // ...
    }
}

Then we search for the polygon in the dictionary and take its corresponding track key. We know that the polygon is present in the dictionary; if not, it means we've made a programming error, in which case we force-unwrap the return value of first(where:):

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        // ...
        let (track, _) = tracks.first(where: { (track, poly) in poly == p })!
        // ...
    }
}

The track's color property uses our own Color type β€” just like the Coordinate type, we use a custom type in order to conform to Hashable and Codable. But Color has a computed property that returns a UIColor, which we can assign to the polygon's stroke color and fill color:

extension ViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        guard let p = overlay as? MKPolygon else {
            return MKOverlayRenderer(overlay: overlay)
        }
        let (track, _) = tracks.first(where: { (track, poly) in poly == p })!
        let r = MKPolygonRenderer(polygon: p)
        r.lineWidth = 1
        r.strokeColor = track.color.uiColor
        r.fillColor = track.color.uiColor.withAlphaComponent(0.2)
        return r
    }
}

14:50 We run the app, but we encounter a nil value where we force-unwrap the dictionary element. This happens because we added the polygon to the map view β€” which immediately tries to render the polygon β€” before we stored it in the dictionary. When we change this into the correct order, everything works:

class ViewController: UIViewController {
    let mapView = MKMapView()
    var tracks: [Track:MKPolygon] = [:]

    // ...

    func updateMapView(_ newTracks: [Track]) {
        for t in newTracks {
            // ...
            tracks[t] = polygon
            mapView.addOverlay(polygon)
        }
    }
}

Set the Map's Visible Region

15:43 The tracks are rendered correctly, but we have to manually zoom and pan to find them on the map. To make this a bit easier, we want to automatically set the map view's visible region to show exactly the polygons we added. We also want to include a small margin around this region.

16:11 We map over the dictionary's values β€” the polygons β€” and pull out their bounding rects. We then combine these bounding rects into one rect by reducing the array with union.

We have to start this process with the "identity" element, MKMapRect.null, which, when combined with any other rect, results in that other rect. This is also described in the documentation of MKMapRect.union.

Finally, we set the combined bounding rect as the map view's visible rect β€” with a padding of 10 points on all sides β€” to make the map zoom to our tracks:

class ViewController: UIViewController {
    let mapView = MKMapView()
    var tracks: [Track:MKPolygon] = [:]
    
    // ...
    
    func updateMapView(_ newTracks: [Track]) {
        // ...
        let boundingRects = tracks.values.map { $0.boundingMapRect }
        let boundingRect = boundingRects.reduce(MKMapRect.null) { $0.union($1) }
        mapView.setVisibleMapRect(boundingRect, edgePadding: UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10), animated: true)
    }
}

Coming Up

18:38 As a next step, we want to tap somewhere on the map and detect the closest point on a track, which can then be used as a starting point of a route. We'll continue with this next week!

Resources

  • Sample Project

    Written in Swift 4.2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes