3D Touch with MKMapView

How to add previewing to map view annotations

May 2, 2016 - 7 minute read -
swift ios 3d-touch

Why?

I love 3D Touch. Some people forget it’s there, or think it’s a fad, but I believe 3D Touch peek and pop is incredibly useful in many places.

Take, for example, a traditional app with a map view. Typically there will be annotations, or pins of some kind, that allow you to learn more about a location. However, looking at said location usually requires three taps: one to activate the callout popup, another to tap on the popup and push the details, and yet another to go back and continue exploring the map. This is a lot of work, and fairly time consuming due to all the animations. In this post, we’ll add 3D Touch to MKMapView annotations, allowing users to preview locations by pressing on pins or callout views.

Plus, it just looks pretty cool:

3D Touch Demo

Enough justification, let’s do it!

If you haven’t yet implemented peek/pop, I highly reccomend you check out this blog post by the Almighty Kraken. However, this is hopefully simple enough that you’ll be able to follow along, even if it’s your first time.

For the demo, I’ve created a simple MKAnnotation subclass called Annotation that contains a title and a color. We also have a MapViewController with an MKMapView, and a DetailViewControllerthat simply has a label to show the location title.

The first thing to do when setting up peek and pop is to conform to UIViewControllerPreviewingDelegate. For now we’ll have empty placeholders, so we can use registerForPreviewingWithDelegate(_:sourceView:) without complaints. I like to do so in an extension:

extension MapViewController: UIViewControllerPreviewingDelegate {

    func previewingContext(previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
		return nil
    }

    func previewingContext(previewingContext: UIViewControllerPreviewing, commitViewController viewControllerToCommit: UIViewController) {

    }
}


Now we need a view that the user can 3D Touch on. This is actually fairly simply — when configuring your annotation views, all you need to do is register each one for 3D Touch:

func mapView(mapView: MKMapView, viewForAnnotation annotation: MKAnnotation) -> MKAnnotationView? {

    // If it's not an annotation, return nil, so we don't interfere with anything else
    guard let annotation = annotation as? Annotation else {
        return nil
    }

    // If we can deque an annotation, do so
    if let annotationView = mapView.dequeueReusableAnnotationViewWithIdentifier(annotationViewID) {
        annotationView.annotation = annotation
        return annotationView
    }
    else {
        let annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: annotationViewID)
        annotationView.rightCalloutAccessoryView = UIButton(type: .DetailDisclosure)
        annotationView.canShowCallout = true

        // This is the magic sauce
        registerForPreviewingWithDelegate(self, sourceView: annotationView)

        return annotationView
    }
}


Now that we know how we’re registering for 3D Touch, we can implement our delegate functions. I have a utility function to create a detail view controller for an annotation, but that implementation is unimportant. What is important is to realize two things:

  1. The previewingContext.sourceView property will be an MKAnnotationView if the user 3D pressed on an annotation.
  2. We can access the underlying annotation with annotationView.annotation.

Now we can implement viewControllerForLocation like so:

func previewingContext(previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {

    guard let annotationView = previewingContext.sourceView as? MKPinAnnotationView else {
        return nil
    }
    guard let annotation = annotationView.annotation as? Annotation else {
        return nil
    }

    if let popoverFrame = rectForAnnotationViewWithPopover(annotationView) {
        previewingContext.sourceRect = popoverFrame
    }

    return viewControllerForAnnotation(annotation)
}


Now, what’s this popoverFrame nonsense all about? Well, unfortunately the sourceRect of the previewContext (anything inside this rectangle doesn’t blur as the user presses) doesn’t incluce the popover view, if one is showing. It’s rather ugly, but the only way I could get the rect that includes the popover was by looping through the annotations subviews to find it, and adjusting the sourceRect to a frame that includes the popover. My implementation of it goes like this (but if you know a better way, please let me know):

func rectForAnnotationViewWithPopover(view: MKAnnotationView) -> CGRect? {

    var popover: UIView?

    for view in view.subviews {
        for view in view.subviews {
            for view in view.subviews {
                popover = view
            }
        }
    }

    if let popover = popover, frame = popover.superview?.convertRect(popover.frame, toView: view) {
        return CGRect(
            x: frame.origin.x,
            y: frame.origin.y,
            width: frame.width,
            height: frame.height + view.frame.height
        )
    }

    return nil
}


Once that’s done, it’s easy to finish up the delegate methods — we just need to push our view controller to commit it:

func previewingContext(previewingContext: UIViewControllerPreviewing, commitViewController viewControllerToCommit: UIViewController) {
    navigationController?.pushViewController(viewControllerToCommit, animated: true)
}


That was easy!

And that’s it! Users can now 3D Touch your annotation views or callouts to preview locations on a map. You can check out the sample project that fully implements this on Github. Also check out my app, Grove, which inspired me to implement this.