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:
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 DetailViewController
that 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:
- The
previewingContext.sourceView
property will be an MKAnnotationView if the user 3D pressed on an annotation. - 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.