iOS
MapKit
UIView
Swift

MapKit Custom callout for annotation with touch response

When the user clicks on a map annotation, a callout window is expected to appear. Here, we demonstrate how to add a custom callout window to the MKMapView when this event occurs, and how to ensure the callout responds to future touch events.

First, we must create the CustomCalloutView, a container which can hold any content. Regardless of the contents, we must set translatesAutoresizingMaskIntoConstraints to false to ensure AutoLayout constraints can be used. We also add a tap gesture recognizer so that this callout can respond to touch events.

class CustomCalloutView: UIView {
    convenience init() {
        self.translatesAutoresizingMaskIntoConstraints = false
        let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
        tap.numberOfTapsRequired = 1
        self.addGestureRecognizer(tap)
    }

    @objc func onTap() {}
}

We must also subclass MKAnnotationView and override touch event handlers to ensure that its subviews are considered during user touch events.

class CustomAnnotationViewClass : MKAnnotationView {

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hitView = super.hitTest(point, with: event)
        if (hitView != nil)
        {
            self.superview?.bringSubview(toFront: self)
        }
        return hitView
    }


    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let rect = self.bounds
        var isInside: Bool = rect.contains(point)
        if(!isInside) {
            for view in self.subviews {
                isInside = view.frame.contains(point)
                if isInside {
                    break
                }
            }
        }
        return isInside
    }
}

When the MKMapView delegate is required to provide a view for an annotation, which is added to the map and should display our CustomCalloutView, we should ensure the CustomAnnotationViewClass is returned so that its touch behaviour can be exploited. Note that we disable the default callout here.

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    var view = mapView.dequeueReusableAnnotationView(withIdentifier: "identifier")
    if(nil == view) {
        view = CustomAnnotationViewClass(annotation: annotation, reuseIdentifier: "identifier")
    }

    view?.annotation = annotation
    view?.image = UIImage(named: "someImage")
    view?.canShowCallout = false
    return view
}

When the user touches this annotation, we must must add the CustomCalloutView to the MKAnnotationView (which is in fact our CustomAnnotationViewClass from earlier) and constrain it to its parent. For visual effect, we can also animate its appearance (e.g. fade-in and grow to full-size).

func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {

    view.subviews.filter { $0 is CustomCalloutView }.forEach{
        $0.removeFromSuperview()
    }

    let callout = CustomCalloutView()


    view.addSubview(callout)
    view.addConstraints([
        NSLayoutConstraint(item: callout,
                           attribute: .centerX,
                           relatedBy: .equal,
                           toItem: view,
                           attribute: .centerX,
                           multiplier: 1.0,
                           constant: 0
        )
        NSLayoutConstraint(item: callout,
                           attribute: .bottom,
                           relatedBy: .equal,
                           toItem: view,
                           attribute: .top,
                           multiplier: 1.0,
                           constant: 0.0
        )
    ])

    callout.alpha = 0.0
    callout.contentScaleFactor = 0.0
    UIView.animate(withDuration: 0.5) {
        callout.alpha = 1.0
        callout.contentScaleFactor = 1.0
    }
}

Finally, we must not forget to remove the custom callout when the annotation is deselected

func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
    var callout = view.subviews.filter({ (subview) -> Bool in
        subview is CustomCalloutView
    }).first

    callout?.removeFromSuperview()
    callout = nil
    view.constraints.forEach{
        view.removeConstraint($0)
    }
}