Handling MKMapView annotation pins on the same coordinate with Swift
Read how we solved the problem of multiple MKMapView annotation pins being located on the same coordinates.
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.
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.
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) }
The code in this blog is available in a demo application.