Handling MKMapView Annotation Pins on the Same Coordinate with Swift

1 October 2015
 

This is an updated version of a post I made in early 2013 that proved popular. This update reworks the solution from Objective-C to Swift 2.0.

The code inline below is available in a demo application.

A component of a project I am working on displays shop locations on a map. A problem arises when the shops are located at a shopping centre or mall – the shops are invariably geo-coded to the same latitude-longitude coordinates.

MKMapView Annotation Pins on the Same Coordinate

When the shops have the same coordinates, the annotations (pins) display in the exact same location on the map. This gives the appearance of there only being one pin, and indeed, the user can only tap one pin.

To overcome this, we implemented a routine to re-place the pins at new coordinates surrounding the contested coordinate.

Pins surrounding contested coordinate
First we group the annotations by coordinate.

var coordinateToAnnotations = [CLLocationCoordinate2D: [MKAnnotation]]()
for annotation in annotations {
    let coordinate = annotation.coordinate
    let annotationsAtCoordinate = coordinateToAnnotations[coordinate] ?? [MKAnnotation]()
    coordinateToAnnotations[coordinate] = annotationsAtCoordinate + [annotation]
}

This routine produces a dictionary keyed on coordinate. The value of a entry in the dictionary is a array of annotations at that coordinate.

You can see this only matches on exactly equal coordinates, but it would be relatively straightforward to group on coordinates that were close by calculating the distance between them. However, you’re probably better using a clustering solution.

In order to use CLLocationCoordinate2D as a key to a dictionary, it must comply with the Hashable protocol.

extension CLLocationCoordinate2D: Hashable {
    public var hashValue: Int {
        get {
            return (latitude.hashValue&*397) &+ longitude.hashValue;
        }
    }
}

…and to comply with the Hashable protocol, you also have to comply with Equatable by providing equality operator implementation at a global level.

public func ==(lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool {
    return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
}

Next we enumerate the dictionary, and for each group of annotations at a contested coordinate we create a new set of annotations.

var newAnnotations = [MKAnnotation]()
        
for (_, annotationsAtCoordinate) in coordinateToAnnotations {
    
    let newAnnotationsAtCoordinate = annotationsByDistributingAnnotationsContestingACoordinate(annotationsAtCoordinate, constructNewAnnotationWithClosure: ctor)
    
    newAnnotations.appendContentsOf(newAnnotationsAtCoordinate)
}

private static func annotationsByDistributingAnnotationsContestingACoordinate(annotations: [MKAnnotation], constructNewAnnotationWithClosure ctor: annotationRelocator) -> [MKAnnotation] {
        
    var newAnnotations = [MKAnnotation]()
    
    let contestedCoordinates = annotations.map{ $0.coordinate }
    
    let newCoordinates = coordinatesByDistributingCoordinates(contestedCoordinates)
    
    for (i, annotation) in annotations.enumerate() {
        
        let newCoordinate = newCoordinates[i]
        
        let newAnnotation = ctor(oldAnnotation: annotation, newCoordinate: newCoordinate)
        
        newAnnotations.append(newAnnotation)
    }
    
    return newAnnotations
}

The pins are arranged in a circle around the contested point by dividing the circle by the number of contesting annotations. You can see that the distance from the contested coordinate to the new coordinate is a function of the number of annotations contesting – if there are few pins contesting the coordinate, then we have space to place the pins close to the coordinate.

private static func coordinatesByDistributingCoordinates(coordinates: [CLLocationCoordinate2D]) -> [CLLocationCoordinate2D] {
        
    if coordinates.count == 1 {
        return coordinates
    }
    
    var result = [CLLocationCoordinate2D]()
    
    let distanceFromContestedLocation: Double = 3.0 * Double(coordinates.count) / 2.0
    let radiansBetweenAnnotations = (M_PI * 2) / Double(coordinates.count)
    
    for (i, coordinate) in coordinates.enumerate() {
        
        let bearing = radiansBetweenAnnotations * Double(i)
        let newCoordinate = calculateCoordinateFromCoordinate(coordinate, onBearingInRadians: bearing, atDistanceInMetres: distanceFromContestedLocation)
        
        result.append(newCoordinate)
    }
    
    return result
}

Finally, the new coordinate is calculated using an implementation of the function from this excellent resource: Destination point given distance and bearing from start point

private static func calculateCoordinateFromCoordinate(coordinate: CLLocationCoordinate2D, onBearingInRadians bearing: Double, atDistanceInMetres distance: Double) -> CLLocationCoordinate2D {
        
    let coordinateLatitudeInRadians = coordinate.latitude * M_PI / 180;
    let coordinateLongitudeInRadians = coordinate.longitude * M_PI / 180;
    
    let distanceComparedToEarth = distance / radiusOfEarth;
    
    let resultLatitudeInRadians = asin(sin(coordinateLatitudeInRadians) * cos(distanceComparedToEarth) + cos(coordinateLatitudeInRadians) * sin(distanceComparedToEarth) * cos(bearing));
    let resultLongitudeInRadians = coordinateLongitudeInRadians + atan2(sin(bearing) * sin(distanceComparedToEarth) * cos(coordinateLatitudeInRadians), cos(distanceComparedToEarth) - sin(coordinateLatitudeInRadians) * sin(resultLatitudeInRadians));
    
    let latitude = resultLatitudeInRadians * 180 / M_PI;
    let longitude = resultLongitudeInRadians * 180 / M_PI;
    
    return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}

Search

Categories

Archives

Subscribe to Email Updates

Subscribe
 
  • Joshua

    Per the demo application, this is

    private static let radiusOfEarth = Double(6378100)

We are a digital transformation consultancy. We help our clients succeed.

View Services