MKMapView center on map annotations with inset
Requirement
We need to show some annotations on our map view and zoom in just enough, to be able to see all of the annotations.
The catch is - we also have an overlay view, that user can drag to reveal some information. When that happens - it would be nice if map would re-center and re-zoom on annotations as dragging happens.
One possible solution would be to simply adjust map’s constraints (map.bottom being the top of the info view). But then we cannot have a nice effect of having the pop-up overlay only partly above the map (to see the map on pop up sides).
Solution
Lucky for us, MKMapView provides us with just the thing we are looking for - setVisibleMapRect(_ mapRect: , edgePadding insets: , animated animate: )
We will integrate it in five easy steps:
- Make topAnchor constraint outlet for Info view (so we can adjust it’s constant when user drags it)
- Declare helper variables (to store our custom map annotations width/height)
- When adding annotations to map, calculate those helper variables
- When user drags Info view - adjust it’s topAnchor constraint’s constant
- Recenter map on annotations
1. Make topAnchor constraint outlet for Info view
We set up our UIViewController
: add MKMapView
and our draggable info pop up UIView
. We set up constraints, but the main thing here is - to have the top anchor constraint (y offset) for our info view - stored as an outlet in our class file.
2. Declare helper variables
We need to declare some variables to assist us - to have some extra space from sides, top and bottom. Because by default - when annotations region is calculated, it considers only the exact points, so if an annotation would be on a border, it’s annoation view would be cut in the middle. See example, when zoomed just to show two annotations by their coordinates:
Our custom annotation box width is not taken into consideration on the sides.
var calculatedMaxPinWidth: CGFloat = 0
var calculatedMaxPinHeight: CGFloat = 0
3. When adding annotations to map, calculate those helper variables
When we provide a custom view for our annotations, we also update our calculatedMaxPinWidth
and calculatedMaxPinHeight
:
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard !(annotation is MKUserLocation) else { return nil }
let identifier = "mapPoint"
var annotationView: MKAnnotationView!
if let dequeuedAnnotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) {
annotationView = dequeuedAnnotationView
annotationView?.annotation = annotation
}
else {
let av = MKAnnotationView(annotation: annotation, reuseIdentifier: identifier)
annotationView = av
}
if let mapPointAnnotation = annotationView.annotation as? MapPoint {
let mapPinAccessory = MapPinAccessoryView.init()
mapPinAccessory.icon.image = UIImage(named: mapPointAnnotation.imageName)
mapPinAccessory.titleLabel.text = mapPointAnnotation.title
mapPinAccessory.layoutIfNeeded()
calculatedMaxPinWidth = max(mapPinAccessory.frame.width, calculatedMaxPinWidth)
calculatedMaxPinHeight = max(mapPinAccessory.frame.height, calculatedMaxPinHeight)
// To position touch-down in the middle bottom part
annotationView.layer.anchorPoint = CGPoint.init(x: 0.5, y: 1)
annotationView.addSubview(mapPinAccessory)
annotationView.frame = mapPinAccessory.frame
}
return annotationView
}
4. When user drags Info view - adjust it’s topAnchor constraint’s constant
If we have some UIPanGestureRecognizer
added to the info view, which would call draggedView()
whenever we do dragging, we would simply update the top anchor (yOffset)
constraint for info view (to move it).
@objc func draggedView() {
let translation = panGesture.translation(in: self.view)
// this way we force that user can't drag higher than actual annotation height
yOffset.constant = max(calculatedMaxPinHeight, yOffset.constant + translation.y)
panGesture.setTranslation(CGPoint.zero, in: self.view)
recenterMapAnnotations()
}
5. Recenter map on annotations
When user drags the info view, we call setVisibleMapRect
on map view, providing map rectangle for all annotations and our recalculated edge insets:
func recenterMapAnnotations() {
let region = self.regionFor(mapPoints: mapView.annotations as! [MapPoint])
let visibleAnnotationsMapRect = MKMapRectForCoordinateRegion(region: region)
// 5 is used as an extra space from sides
let top = calculatedMaxPinHeight + 5
let bottom = mapView.frame.height - yOffset.constant - self.view.safeAreaInsets.bottom + 5
let side = calculatedMaxPinWidth/2 + 5
mapView.setVisibleMapRect(visibleAnnotationsMapRect, edgePadding:
UIEdgeInsets(top: top, left: side, bottom: bottom, right: side), animated: false)
}
Annd… Done!
Helper methods
Helper method to get a region from given points
func regionFor(mapPoints points: [MapPoint]) -> MKCoordinateRegion {
var r = MKMapRect.null
for i in 0 ..< points.count {
let p = MKMapPoint(points[i].coordinate)
r = r.union(MKMapRect(x: p.x, y: p.y, width: 0, height: 0))
}
var region = MKCoordinateRegion(r)
// 0.002 is simply to be minimum zoom level.
region.span.latitudeDelta = max(0.002, region.span.latitudeDelta)
region.span.longitudeDelta = max(0.002, region.span.longitudeDelta)
return region
}
Helper method to get MKMapRect from region
func MKMapRectForCoordinateRegion(region:MKCoordinateRegion) -> MKMapRect {
let topLeft = CLLocationCoordinate2D(latitude: region.center.latitude + (region.span.latitudeDelta/2), longitude: region.center.longitude - (region.span.longitudeDelta/2))
let bottomRight = CLLocationCoordinate2D(latitude: region.center.latitude - (region.span.latitudeDelta/2), longitude: region.center.longitude + (region.span.longitudeDelta/2))
let a = MKMapPoint(topLeft)
let b = MKMapPoint(bottomRight)
return MKMapRect(origin: MKMapPoint(x:min(a.x,b.x), y:min(a.y,b.y)), size: MKMapSize(width: abs(a.x-b.x), height: abs(a.y-b.y)))
}