MapKit Tutorial: Clustering in iOS 11

At my company, Hypit, the focus switches like a kid with ADHD in Willy Wonka's Chocolate Factory. One of the most recent additions to the infinite backlog is a map which displays a user's hypes. The important detail of this feature was that, upon zooming out of the map, the hypes would cluster.

My Android developer co-worker found a beautiful library for clustering and implemented it in less than an hour whilst I found some old frameworks that barely did the job, and in a less than elegant fashion. With plenty else to work on the feature was pushed back.

With the iOS 11 announcement one of the things that most excited me was the MapKit updates, the highlight of course being clustering. Let's look at an implementation now.

We're going to make an app which allows battle rappers to check out other battle rappers in their area.

The following tutorial used Xcode 9.0 and Swift 4.0.

mapkit-1.png

Create a new Xcode project and select Single View App.

Product Name: SpittingDistance
Team: <Your Team>
Organization Name: <Your Organization>
Organization Identifier: <Your Identifier>
Language: Swift
Untick everything.
mapkit-2.png

Open Main.storyboard and add a MKMapView to the only UIViewController in the storyboard. Size it to fill the view controller and pin it to the edges.

mapkit-3.png
mapkit-4.png

Open ViewController.swift and replace contents of file with the following:

import MapKit

//  MARK: View Controller
public final class ViewController: UIViewController {
    //  MARK: Properties
    ///    Displays the rappers in a map.
    @IBOutlet private weak var mapView: MKMapView!
}

We then hook up the outlet in the storyboard. Note that we can now use private again instead of fileprivate! Sensible access controls for the win.

We have the very basics set up, and now we're going to actually get something displaying on the map. We'll need a service which provides the battle rappers.

Create a new Swift file and call it RapperService.

mapkit-6.png
mapkit-7.png

Before moving on I'm going to do some clean up of the project hierarchy. Xcode 9 makes it easy to create Groups, automatically creating the associated Folder in the file directory. Beautiful.

mapkit-8.png

Open RapperService.swift and replace add the interface declaration:

//  MARK: Rapper Service
public protocol RapperService {
    /**
    Fetches all battle rappers.
    */
    func allBattleRappers(completion: ([BattleRapper], Error?) -> ())
}

Our naive service will be capable of fetching all the battle rappers. Xcode will be complaining that our model, BattleRapper, is an undeclared type, so let's create it.

Create a Model group and create a new Swift file and call it BattleRapper. Replace the file contents with the following:

import MapKit

//  MARK: Battle Rapper
public struct BattleRapper {
    //  MARK: Properties
    ///    Name of the rapper.
    public let name: String
    ///    The rapper's favourite artist.
    public let favouriteArtist: String
    ///    The current location of the rapper.
    public let coordinate: CLLocationCoordinate2D
}

This model is an encapsulation of a rapper using our app.

Now let's add our service as a dependency in the ViewController. Below the mapView property add:

///    Used to fetch rappers which will be added to our map.
private var service: RapperService?

Below the declaration of the ViewController class add an extension which allows for the injection of our service.

//  MARK: Injection
public extension ViewController {
    func inject(service: RapperService) {
        self.service = service
    }
}

When the view loads we'll use our service to fetch the rappers:

//  MARK: View Lifecycle
public extension ViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        guard let service = service else { fatalError("Service should have been injected before view loads.") }
        fetchRappers(with: service)
    }
}
//  MARK: Map Management
private extension ViewController {
    func fetchRappers(with service: RapperService) {
        service.allBattleRappers { rappers, error in
            guard error == nil else {
                print("Error occurred fetching battle rappers: \(error!)")
                return
            }
            ///    ADD RAPPERS TO MAP
        }
    }
}

We'll need to add the rappers to the map, so we're going to have to revise our BattleRapper model and make it conform to the MKAnnotation protocol.

First we will have to make it an NSObject because MKAnnotation predates the existence of Swift.

Replace public struct BattleRapper with public final class BattleRapper: NSObject, MKAnnotation. We'll no longer get our conveniently auto-generated initializer, so add this to the bottom of the class:

//  MARK: Initialization
public init(coordinate: CLLocationCoordinate2D, name: String, favouriteArtist: String) {
    self.name = name
    self.coordinate = coordinate
    self.favouriteArtist = favouriteArtist
}

Our coordinate property already conveniently conforms to a requirement in the MKAnnotation protocol. There are two more optional properties we'll want to add:

public var title: String? { return name }
public var subtitle: String? { return "🔥 " + favouriteArtist + " 🔥"}

Now we can go back to the ViewController and add our rappers to our mapView. Replace fetchRappers(with:) with:

func fetchRappers(with service: RapperService) {
    service.allBattleRappers { [weak mapView] rappers, error in
        guard let mapView = mapView else { return }
        guard error == nil else {
            print("Error occurred fetching battle rappers: \(error!)")
            return
        }
        DispatchQueue.main.async {
            mapView.addAnnotations(rappers)
        }
    }
}

We're passing the mapView into the closure weakly to avoid a reference cycle. The view controller has a strong hold on the RapperService, so we do not want the service to have a strong reference to the ViewController.

We add the annotations on the main queue where any UI work should be done. Now let's build a service which will actually provide some rappers so that we can see something.

We're not going to actually build an API client capable of communicating with a backend, but the beauty of the protocol interface is that we can mock it.

Create an empty file and call it rappers.json.

mapkit-12.png

Fill it with some sample rappers:

[{
 "name": "Jayinvee",
 "favouriteArtist": "The Game",
 "coordinate": [ 51.767, 0.087 ]
 },
 {
 "name": "Lift the Chains",
 "favouriteArtist": "N.W.A.",
 "coordinate": [ 51.775, 0.089]
 },
 {
 "name": "James il 5'7\"",
 "favouriteArtist": "YG",
 "coordinate": [ 51.783, 0.091]
 },
 {
 "name": "Jammy Red",
 "favouriteArtist": "Danny Brown",
 "coordinate": [ 51.759, 0.085]
 },
 {
 "name": "TC",
 "favouriteArtist": "Royce da 5'9\"",
 "coordinate": [ 51.771, 0.103]
 },
 {
 "name": "C.W.S.",
 "favouriteArtist": "Run the Jewels",
 "coordinate": [ 51.764, 0.064]
 },
 {
 "name": "A Play",
 "favouriteArtist": "Eminem",
 "coordinate": [ 51.791, 0.075]
 },
 {
 "name": "Grad J",
 "favouriteArtist": "Schoolboy Q",
 "coordinate": [ 51.810, 0.044]
 },
 {
 "name": "Shova G",
 "favouriteArtist": "Kanye West",
 "coordinate": [ 51.761, 0.085]
 }
 ]

Now to build the mock service which will load that file and decode the JSON into rappers, using that beautiful new Codable functionality.

New Swift file, name it MockRappersService, and replace contents with:

import Foundation

//  MARK: Mock Rapper Service
final class MockRapperService: RapperService {
    func allBattleRappers(completion: ([BattleRapper], Error?) -> ()) {
        ///    get the URL to `rappers.json`
        guard let url = Bundle(for: type(of: self)).url(forResource: "rappers", withExtension: "json") else { fatalError("Couldn't find local resource defining rappers JSON.") }
        do {
            ///    convert the file into data
            let rappersData = try Data(contentsOf: url)
            ///    decode the data into an array of rappers
            let rappers = try JSONDecoder().decode([BattleRapper].self, from: rappersData)
            completion(rappers, nil)
        } catch {
            completion([], error)
        }
    }
}

Basically this turns the json file into data and then uses the new JSONDecoder to decode the data into an array of BattleRapper instances.

That means we need to make our BattleRapper type Decodable. Open up BatlleRapper.swift and after public final class BattleRapper: NSObject, MKAnnotation add , Codable. Codable is a combination of Decodable and Encodable, so we may as well get the extra functionality for free.

Xcode is going to compain, and that's because CLLocationCoordinate2D doesn't support Codable. That's easy to fix. At the bottom of the file add the following extension:

//  MARK: CLLocationCoordinate2D + Codable
extension CLLocationCoordinate2D: Codable {
    public func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()
        try container.encode(latitude)
        try container.encode(longitude)
    }
    public init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        latitude = try container.decode(Double.self)
        longitude = try container.decode(Double.self)
    }
}

All that we need to do now is inject this service into the view controller. Open up the AppDelegate and replace everything:

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        guard let viewController = window?.rootViewController as? ViewController else { fatalError("Root view controller is not of the correct class.") }

        viewController.inject(service: MockRapperService())

        return true
    }
}

We'll want the map to open with a focus on our added annotations. We won't worry about doing this dynamically for this example. Just add the following constants.

private let regionRadius: CLLocationDistance = 1500
private let initialLocation = CLLocation(latitude: 51.767, longitude: 0.087)

Add a convenience function:

func centreMap(on location: CLLocation) {
    let coordinateRegion = MKCoordinateRegionMakeWithDistance(location.coordinate, regionRadius * 2.0, regionRadius * 2.0)
    mapView.setRegion(coordinateRegion, animated: true)
}

We'll call it when the view appears.

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    centreMap(on: initialLocation)
}

We can finally take the first look at our app. If we build and run we can see our rappers.

mapkit-14.png

That's cool, but this looks pretty boring at the moment. Let's create our own view to be displayed in the map for each rapper. Create a new Swift file and call it BattleRapperView.

import MapKit

//  MARK: Battle Rapper View
internal final class BattleRapperView: MKMarkerAnnotationView {
    //  MARK: Properties
    internal override var annotation: MKAnnotation? { willSet { newValue.flatMap(configure(with:)) } }
}
//  MARK: Configuration
private extension BattleRapperView {
    func configure(with annotation: MKAnnotation) {
        guard annotation is BattleRapper else { fatalError("Unexpected annotation type: \(annotation)") }
        //    CONFIGURE
    }
}

We override the annotation property and will be using it to configure the view, but first we'll need to add an image to the Assets.xcassets. Save it to your computer:

Create a new Image Set and name it rapper. Open the Utilities inspector and tick Preserve Vector Data and then select Single Scale in the Scales drop down menu. Drag the image onto the image set.

13.png

Now replace the // CONFIGURE comment with:

markerTintColor = .purple
glyphImage = #imageLiteral(resourceName: "rapper")
mapkit-10.png
mapkit-11.png

Before iOS 11 we would have to provide the map view with a MKMapViewDelegate which would provide a view for the annotation, but with iOS 11 we can simply register the view against the default reuse identifiers and we're golden.

In viewDidLoad, after the call to super, add the following:

mapView.register(BattleRapperView.self, forAnnotationViewWithReuseIdentifier: MKMapViewDefaultAnnotationViewReuseIdentifier)

That's literally all that is needed to get our custom views displaying

Build and run and take pride in our silly little map with points on it. Pan around, zoom, and tap on the point to view the subtitle.

mapkit-15.png

While you're adjusting the zoom you may have noticed that the points will merge with each other at a certain point, but with no indication that there are more rappers to be battled! This is where clustering comes in. Stop the app, and add a new file called BattleRapperClusterView.

Our basic view will consist of

import MapKit

//  MARK: Battle Rapper Cluster View
internal final class BattleRapperClusterView: MKAnnotationView {
    //  MARK: Properties
    internal override var annotation: MKAnnotation? { willSet { newValue.flatMap(configure(with:)) } }
    //  MARK: Initialization
    override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
        super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
        displayPriority = .defaultHigh
        collisionMode = .circle
        centerOffset = CGPoint(x: 0.0, y: -10.0)
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("\(#function) not implemented.")
    }
}
//  MARK: Configuration
private extension BattleRapperClusterView {
    func configure(with annotation: MKAnnotation) {
        guard let annotation = annotation as? MKClusterAnnotation else { return }
        //    CONFIGURE
    }
}

We've giving the view a high display priority, a circular collision mask, and we've offset the centre up a little bit. We're going to make the view a circle around a number indicating all of the rappers in the area ready to tear it up, and so we want the collision to make sense.

Replace the CONFIGURE comment:

let renderer = UIGraphicsImageRenderer(size: CGSize(width: 40.0, height: 40.0))
    let count = annotation.memberAnnotations.count
    image = renderer.image { _ in
        UIColor.purple.setFill()
        UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: 40.0, height: 40.0)).fill()
        let attributes = [NSAttributedStringKey.foregroundColor: UIColor.white, NSAttributedStringKey.font: UIFont.boldSystemFont(ofSize: 20.0)]
        let text = "\(count)"
        let size = text.size(withAttributes: attributes)
        let rect = CGRect(x: 20 - size.width / 2, y: 20 - size.height / 2, width: size.width, height: size.height)
        text.draw(in: rect, withAttributes: attributes)
    }

We're using the UIGraphicsImageRenderer introduced in iOS 10 to draw our view. It's overkill for what we want to do, but it's a bit of fun.

Now all that we need to do is register the cluster view with the map view. Go back to ViewController and in viewDidLoad, blow the other registration, add

mapView.register(BattleRapperClusterView.self, forAnnotationViewWithReuseIdentifier: MKMapViewDefaultClusterAnnotationViewReuseIdentifier)

Finally we need to give our MKMarkerAnnotationView a cluster identifier. This is what determines which views can be clustered. If we had multiple types of annotations on our map view, we wouldn't want them all smushing together arbitrarily.

Open BattleRapperView and at the bottom of the configure function add:

clusteringIdentifier = String(describing: BattleRapperView.self)

Build and run the application and enjoy zooming in and out, watching as the annotations merge into each other.

mapkit-16.png