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!