From the blog

Introducing BentoMap

BentoMap Clustered iOS Map PinsBentoMap was designed to make it easy to store and retrieve a large amount  of map annotation data. It includes the concept of clustered annotations, and uses generics to allow flexible data storage. In order to handle large data sets efficiently, the annotations are stored using quadtrees. BentoMap is freely available under the MIT license at https://github.com/Raizlabs/BentoMap.

Getting Annotations On Screen

Displaying annotations on the screen requires the view controller to contain an MKMapView, a BentoMap QuadTree to use as a data source, an MKMapViewDelegate, and some binding code to transform the results of quadtree lookups into MKAnnotation objects.

To demonstrate the process, I’m going to walk through most of the code in the view controller in a sample application. Starting out is the body of the class:

final class ViewController: UIViewController {
    let mapData = QuadTree<Int>.sampleData

    override func loadView() {
        let mapView = MKMapView()
        mapView.delegate = self
        mapView.setVisibleMapRect(mapData.boundingBox.mapRect,
                                  edgePadding: self.dynamicType.mapInsets,
                                  animated: false)
        view = mapView
    }
}

The loadView function adds the map view to the view hierarchy, then it sets the delegate that will display the annotation data. Finally, it calls setVisibleMapRect on the map view. This makes sure that the map view is zoomed to match the current data set when the view first appears, with margins to make sure the pins are entirely onscreen rather than clipped or placed under the navigation bar.

The next section of code I will cover is the map view delegate implementation:

extension ViewController: MKMapViewDelegate {

    func mapView(mapView: MKMapView,
                 viewForAnnotation annotation: MKAnnotation) -> MKAnnotationView? {
        let pin = mapView.dequeueAnnotationView(forAnnotation: annotation)
            as MKPinAnnotationView
        pin.configureWithAnnotation(annotation)
        return pin
    }

    func mapView(mapView: MKMapView,
                 didSelectAnnotationView view: MKAnnotationView) {
        // react to annotation selection here
    }

    func mapView(mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
        updateAnnotations(inMapView: mapView, forMapRect: mapView.visibleMapRect)
    }
}

The first function is handling dequeuing the map annotations, then configuring them as needed. This is what connects your abstract MKAnnotation objects into views that will be displayed on screen. The second function handles annotation selection and is where you can add the code to handle zooming to fit a cluster of results,  show details, or otherwise reacting to tapping a single pin.

The final function handles region changes and subsequently ends up being a fairly complex function; the body of which is handled in a private function. Let’s take a look at this function:

func updateAnnotations(inMapView mapView: MKMapView,
                       forMapRect mapRect: MKMapRect) {
     guard !mapView.frame.isEmpty && !MKMapRectIsEmpty(mapRect) else {
         mapView.removeAnnotations(mapView.annotations)
         return
     }
     let zoomScale = Double(mapView.frame.width) / mapRect.size.width
     let clusterResults = mapData.clusteredDataWithinMapRect(mapRect,
                                                             zoomScale: zoomScale,
                                                             cellSize: 64)
     let newAnnotations = clusterResults.map(BaseAnnotation.makeAnnotation)
     let oldAnnotations = mapView.annotations.flatMap({ $0 as? BaseAnnotation })
     let toRemove = oldAnnotations.filter { annotation in
         return !newAnnotations.contains { newAnnotation in
             return newAnnotation == annotation
         }
     }
     mapView.removeAnnotations(toRemove)
     let toAdd = newAnnotations.filter { annotation in
         return !oldAnnotations.contains { oldAnnotation in
             return oldAnnotation == annotation
         }
     }
     mapView.addAnnotations(toAdd)
}

This function is checking if it can calculate a map scale. If either the mapView frame or the mapRect size is zero then it removes all annotations from the map and returns as there’s no useful calculations that can be made.

Next a zoom scale is calculated based on the ratio of the mapView frame to the mapRect, and the cluster data for that zoom scale inside the map rect is calculated. This function uses a static cell size of 64 points, so the map clusters are calculated based on 64 point square boxes.

The next phase is to convert the cluster results into objects that conform to MKAnnotation. The code being used for annotations in the sample app is:

class BaseAnnotation: NSObject, MKAnnotation {
    let mapPoint: MKMapPoint
    let mapRect: MKMapRect
    var coordinate: CLLocationCoordinate2D {
        return MKCoordinateForMapPoint(mapPoint)
    }

    init(mapPoint: MKMapPoint, mapRect: MKMapRect) {
        self.mapPoint = mapPoint
        self.mapRect = mapRect
    }

    static func makeAnnotation(result: QuadTreeResult<Int>) -> BaseAnnotation {
        let annotation: BaseAnnotation
        switch result {
        case let .Single(node: node):
            annotation = SingleAnnotation(mapPoint: result.mapPoint,
                                          annotationNumber: node.content,
                                          mapRect: result.contentRect)
        case let .Multiple(nodes: nodes):
            annotation = ClusterAnnotation(mapPoint: result.mapPoint,
                                           annotationNumbers: nodes.map({ $0.content }),
                                           mapRect: result.contentRect)
        }
        return annotation
    }
}

final class SingleAnnotation: BaseAnnotation {
    let annotationNumber: Int

    init(mapPoint: MKMapPoint, annotationNumber: Int, mapRect: MKMapRect) {
        self.annotationNumber = annotationNumber
        super.init(mapPoint: mapPoint, mapRect: mapRect)
    }
}

final class ClusterAnnotation: BaseAnnotation {
    let annotationNumbers: [Int]

    init(mapPoint: MKMapPoint, annotationNumbers: [Int], mapRect: MKMapRect) {
        self.annotationNumbers = annotationNumbers.sort()
        super.init(mapPoint: mapPoint, mapRect: mapRect)
    }
}

The makeAnnotation static function from BaseAnnotation handles unpacking the QuadTreeResult objects and converting them to objects that implement the MKAnnotation protocol. In this case, it’s initializing SingleAnnotation or ClusterAnnotation based on the node type.

After initializing the annotations, a change set for the annotations is calculated. This is done by comparing the current annotation list to the new list, and generating a list of annotations to remove and a list of annotations to add. This prevents removing and re-adding annotations that are already on the map view, which would , trigger animations on every map movement.

The demo application uses curried functions to generate the desired filter and contains functions without having to have two layers of nested closures, but the exact method for comparing annotations depends on how many annotation types are included in the data source, and the best way to find uniqueness between nodes.

Customization

There are two points of customization one should consider. First, adjusting the cell size based on map scale. If the map is zoomed out, breaking the map into large sections makes sense. If the map is zoomed in close, smaller clusters make sense. One thing to keep in mind when implementing smaller cluster sizes is that the map data may contain multiple points of interest at exactly the same coordinate.

After customizing bucket size, another great point to customize is adding a scale factor to your MKAnnotationView for clusters based on the number of items in the cluster. Once again, the right curve is going to depend on data density and map scale, but starting at something that scales on a 1 + log(n) curve is a decent starting point to get a scale that increases quickly for a small number of annotations, then drops off to a reasonable rate as the annotations per cluster grows.

Omitted Features

BentoMap handles only the data storage, and does not return MKAnnotation objects directly. The reason for not returning MKAnnotation objects is to allow for flexible handling of special cases, such as the aforementioned case where all of the annotations in a cluster are located at exactly the same coordinate.

This also means that BentoMap doesn’t come with any built in MKAnnotationView subclasses. This is because your map annotation subclass should fit the style of the rest of your app, and so it probably should be written with that style in mind.

4 Comments

    1. I don’t have any experience with MapBox, but because the data is stored relative to MKMapRects and MKMapPoints, if MapBox uses those for coordinate spaces it should be relatively easy to adapt the example code to work with MapBox. If you run into any problems, fix an issue on GitHub and I’ll try to help if I can.

Leave a Reply

Your email address will not be published. Required fields are marked *