One of the most recurrent topics when working with the MapKit framework and annotations is customising the annotations and callout views in the map to match the design of the application we are working on. Unfortunately, unless you want to stick to the most basic, iOS-looking styling, it’s not easy at all to do so, and the MapKit framework does not really give you the flexibility that you need for customising your map.

Apple example source code “MapCallouts: Using MapKit Annotations” is just hideous, and doesn’t really address the problem of customising the map annotation views. There, in the image, you might see several pins. One of them (the one at the bottom with a “Tea garden” title and an image) might seem like a custom callout, but it’s not, it’s the annotation view. You cannot click on it (at least, nothing happens if you do so), it cannot contain buttons or any interactive elements… it’s just not what you would probably need for a modern application, and the other two pins will show you a “somewhat” customised callout, but you won’t be able to get a fully customised callout view using this code sample.

There are some code samples out there talking about customisation, but they either don’t get a full customisation or you end up with a callout whose elements are not interactive, i.e: buttons cannot be pushed, table views cannot be scrolled, etc…

In this post, I describe a method for making completely custom annotation views that actually work, and are fully interactive (including inner buttons, table views, etc). As always, you can download the full sample code project in my Github repository.

The problem with annotations and callouts in MapKit

This figure shows the structure of a map with all the elements. The little red pin you are probably used to see in Maps applications is the annotation view, and the bubble that shows up when you click on it its called the callout view, and it’s the default callout that MapKit offers for you to show. Now, if asked “what parts of the callout view can you completely customise?” you would be tempted to say “all of them”, but actually, MapKit only gives you control on the central one, the “Detail Callout Accessory View”. The left and right callout button views, which are commonly used to show buttons and images, can be hidden, so that’s not a problem. The really annoying element here is the title UILabel in orange.

If you really want to customise your annotation callouts, this label is a real annoyance. You can edit the text and some properties, but MapKit won’t give you full access to this title label. If you set the label to be empty, the label won’t show… because the whole bubble will not show at all! And if you set it to a single space (” “), you will get the bubble and no title, but of course the space for the label will be there (nice job, Apple), so the only way to remove it is through a hacky method involving iterating through the detail callout accessory view superviews hierarchy, finding the title label and removing it. This solution is not only hacky/patchy and vulnerable to MapKit changes, but it has some nasty side problems like the autolayout animation expanding the bubble to its full width and then removing the label and shrinking the bubble in a clearly visible and unprofessional effect.

Thus, the only real way of customising the callouts is setting the annotation view’s property “canShowCallout” to false and taking care of the whole process of showing the callout, dismissing it, making sure that the interactive elements work, etc. This last part is specially difficult, and it’s usually left apart when discussing on custom annotations and callouts. So all posts and articles I’ve found out there lack addressing the interactivity of the elements. We’ll take care to make all controls fully actionable.

Our sample application

Our sample application is called “Your Personal Wishlist”, it has a list of people around the world, and each person has a wishlist of things they would like to buy.

These people are represented in the map as pins (we will use a custom blue flag pin), and when one pin is clicked, the card for this person will show up in the callout, completely customised.

The bubble view will be contained in a Nib/Xib file, so we will have full control on its design.

The list of elements is actually a UITableView, and we will be able to scroll through it. The button is also clickable, and when pressed, will take us to another screen where we can see a detailed view of this person and their wishlist.

Hands on!

First we will add a MapView to our view controller, add MapKit and pin the map to the edges of the screen. We will also set the delegate of the map to be our view controller.

Next, we will create our model. We will need a Person class to define a person, with a name, avatar, coordinate and wishlist.

class Person: NSObject {
   var name: String
   var avatar: UIImage
   var wishList = [String]()
   var location: CLLocationCoordinate2D = kCLLocationCoordinate2DInvalid
   ...
}

Then, we will need a singleton class to manage the creation of the people from around the globe and initialise the model that we will show in our map:

// quick singleton implementation for our PeopleWishListManager.
private let _singletonInstance = PeopleWishListManager()

private let kPeopleWishListManagerNumberOfPeople = 21

class PeopleWishListManager: NSObject {
  // shared instance of PeopleWishListManager.
  class var sharedInstance: PeopleWishListManager { return _singletonInstance }
 
  // people wishlist
  var people = [Person]()
 
  // MARK: - init
  override init() {
    super.init()
    populatePeopleList()
  }

  func populatePeopleList() {
    let names = ["Oren Nimmons", "Flor Addington", "Bernadette Bachus", ...]
 
    let coordinates = [CLLocationCoordinate2D(latitude: 47.57273, longitude: -52.68997)...]
 
    people = []
    for i in 0..<kPeopleWishListManagerNumberOfPeople {
      let wishlist = giveMeAWishList()
      let name = names[i]
      let avatar = UIImage(named: "avatar\(i+1)")!
 
      let person = Person(name: name, avatar: avatar)
      person.wishList = wishlist
      person.location = coordinates[i]
      people.append(person)
   }
 }
 
 func giveMeAWishList() -> [String] {
   let items = ["Watch", "Purple pen",..., "bottle of perfume"]
 
   let num = arc4random_uniform(3) + 1
   var wishlist = [String]()
   for _ in 0..<num {
      let index = Int(arc4random_uniform(UInt32(items.count)))
      wishlist.append(items[index])
   }
   return wishlist
  }
}

I just picked a series of names, coordinates, and a list of random objects. I also used some free icons I found from the guys at “Creative Tail”. With all this information in place, we are in the position of populating a list of 21 people.

Now that we have our main view controller and model in place, it’s time to take care of the elements of our map, the annotations and callouts.

Building our annotation and annotation view

The first step is building our annotation and annotation view. The annotation is the “model” part of the equation, and carries the coordinate and main any piece of information needed to be displayed in the annotation view. The annotation view is the pin you see on the map, and controls the interactions that happen when you click on it. Given that we are completely customising the annotation and callout, the annotation view will have to show the callout when the annotation is clicked, dismissing any previous annotation. We will take care of that thanks to the selected/unselected state of the annotation (via the “setSelected” method), to show the callout when selected and dismiss it when unselected.

Our annotation will be a NSObject implementing theMKAnnotation protocol, and will simply contain the person and a coordinate:

class PersonWishListAnnotation: NSObject, MKAnnotation {
  var person: Person
  var coordinate: CLLocationCoordinate2D { return person.location }
 
  init(person: Person) {
     self.person = person
     super.init()
  }
 
  var title: String? {
    return person.name
  }
 
  var subtitle: String? {
    return person.wishList.joined(separator: ", ")
  }
}

Notice how we defined title and subtitle, even though we are not going to need them (as we won’t be using MapKit default callout accessory view).

Now it’s the time to define our annotation view. It will be a subview of the MKAnnotationView class.

class PersonWishListAnnotationView: MKAnnotationView {
  // data
  weak var customCalloutView: PersonDetailMapView?
  override var annotation: MKAnnotation? {
     willSet { customCalloutView?.removeFromSuperview() }
  }
 
  // MARK: - life cycle
 
  override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
    super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
    self.canShowCallout = false // 1
    self.image = kPersonMapPinImage
  }
 
  required init?(coder aDecoder: NSCoder) {
     super.init(coder: aDecoder)
    self.canShowCallout = false // 1
    self.image = kPersonMapPinImage
  }
 
  // MARK: - callout showing and hiding
  override func setSelected(_ selected: Bool, animated: Bool) {
    super.setSelected(selected, animated: animated)
 
    if selected { // 2
      self.customCalloutView?.removeFromSuperview() // remove old custom callout (if any)
 
      if let newCustomCalloutView = loadPersonDetailMapView() {
        // fix location from top-left to its right place.
        newCustomCalloutView.frame.origin.x -= newCustomCalloutView.frame.width / 2.0 - (self.frame.width / 2.0)
        newCustomCalloutView.frame.origin.y -= newCustomCalloutView.frame.height
 
        // set custom callout view
        self.addSubview(newCustomCalloutView)
        self.customCalloutView = newCustomCalloutView
 
        // animate presentation
        if animated {
          self.customCalloutView!.alpha = 0.0
          UIView.animate(withDuration: kPersonMapAnimationTime, animations: { 
            self.customCalloutView!.alpha = 1.0
          })
        }
      }
    } else { // 3
      if customCalloutView != nil {
        if animated { // fade out animation, then remove it.
          UIView.animate(withDuration: kPersonMapAnimationTime, animations: { 
            self.customCalloutView!.alpha = 0.0
          }, completion: { (success) in
            self.customCalloutView!.removeFromSuperview()
          })
        } else { self.customCalloutView!.removeFromSuperview() } // just remove it.
      }
    }
  }
 
  func loadPersonDetailMapView() -> UIView? { // 4
    let view = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 280))
    return view
  }

  override func prepareForReuse() { // 5
    super.prepareForReuse()
    self.customCalloutView?.removeFromSuperview()
  }
}

Let’s go through this code step by step:

  1. First, we need to make sure that we set canShowCallout to false, to avoid showing the default MapKit callout bubble. Then we define the image that we are going to use for our pin.
  2. the setSelected method is the right place to show and dismiss our custom callout. When called, we will need to differentiate if the annotation view has been selected or deselected. The first case implies selecting the annotation. In this case we should show the callout, but we must be careful to dismiss any previous annotation callout being shown using removeFromSuperview() (this happens if you select a pin and then select another one, we must dismiss the first callout and show the second one). We will store our custom callout in an instance variable called customCalloutView, and use the method “loadPersonDetailMapView” to load it. At the moment, as we haven’t designed our custom view yet, we will just return a rectangular white UIView. After adding this view as a subview of the annotation, we will need to adjust it’s position. When first added, the view will be shown from the top-left corner of the pin, covering it, while we want to show it horizontally centered and with the bottom side of the callout view at the top of the pin (displaying as a bubble on top center of the pin), so we need to adjust the X and Y positions as shown.
  3. In case of deselecting the annotation, we just dismiss the custom callout view (if any).
  4. This is the method where we would load our custom callout view. For the moment we will just return a 240×280 white rectangle.
  5. when prepareForReuse is called, the annotation is going to be reused from the current pin position to a new one, so we need to remove the custom callout.

Now we can adjust our ViewController to show our annotations in the map:

class ViewController: UIViewController, MKMapViewDelegate, PersonDetailMapViewDelegate {
  // outlets
  @IBOutlet weak var mapView: MKMapView!
 
  // data
  var selectedPerson: Person?
 
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    configurePeopleInMap()
  }
 
  func configurePeopleInMap() {
    var annotations = [MKAnnotation]()
    for person in PeopleWishListManager.sharedInstance.people {
      let annotation = PersonWishListAnnotation(person: person)
      annotations.append(annotation)
    }
    mapView.removeAnnotations(mapView.annotations)
    mapView.addAnnotations(annotations)
  }
 
  // MARK: - MKMapViewDelegate methods
  func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
    let visibleRegion = MKCoordinateRegionMakeWithDistance(userLocation.coordinate, 10000, 10000)
    self.mapView.setRegion(self.mapView.regionThatFits(visibleRegion), animated: true)
  }

  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    if annotation is MKUserLocation { return nil }
    var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: kPersonWishListAnnotationName)
 
    if annotationView == nil {
      annotationView = PersonWishListAnnotationView(annotation: annotation, reuseIdentifier: kPersonWishListAnnotationName)
      (annotationView as! PersonWishListAnnotationView).personDetailDelegate = self
    } else {
      annotationView!.annotation = annotation
    }
    return annotationView
  }

}

In viewWillAppear we call configurePeopleInMap(), that will just call our PeopleWishListManager and initialise the annotations with the Person instances. then in mapView:viewForAnnotation: we will dequeue the appropriate PersonWishListAnnotationView that corresponds to that annotation.

If you run this code right now, you will notice that the pins are correctly shown, but when we click on any of them, we just get an empty view. That’s alright, let’s design our custom callout view next!

Designing our Custom Callout View

Our custom callout view will contain a fancy design of the person’s profile, including the wishlist and a “See details” button to segue to another view controller showing the details for that person.

We will need to create the UIView and the Nib/Xib file independently. First, we will create a new file in “User Interface” -> “View”. This will be our Nib view file, and we will call it “PersonDetailMapView.xib”. We will add all the elements and the autolayout constraints. Next, we will add a new file in “Source” -> “Cocoa Touch Class” and make it a “UIView” type of object. We will call this file “PersonDetailMapView.swift”.

Then we need to link them together. In order to do that, we will go to the hierarchy, select the root view, and go to the inspector, to the “Custom Class” tab and enter “PersonDetailMapView”. It’s important not to specify that in the File Owner’s or any other place but in the main view. It’s a shame Apple still doesn’t include a UIView NIB/Swift combo like the ones for UIViewController or UITableViewCell.

Now we can add our outlets for all the elements in the IB view. Once done, it’s important to go to the swift code and set our view as the delegate and data source for the UITableView, and also define the IBAction for the “See Details” button.

 

protocol PersonDetailMapViewDelegate: class { // 1 
  func detailsRequestedForPerson(person: Person)
}

class PersonDetailMapView: UIView, UITableViewDelegate, UITableViewDataSource {
  // outlets
  @IBOutlet weak var backgroundContentButton: UIButton!
  @IBOutlet weak var personImageView: UIImageView!
  @IBOutlet weak var personName: UILabel!
  @IBOutlet weak var wishListTableView: UITableView!
  @IBOutlet weak var seeDetailsButton: UIButton!
 
  // data
  var person: Person!
  weak var delegate: PersonDetailMapViewDelegate?
 
  override func awakeFromNib() {
    super.awakeFromNib()
 
    // setup list
    wishListTableView.register(UINib(nibName: "PersonWishListItemTableViewCell", bundle: nil), forCellReuseIdentifier: "PersonWishListItemTableViewCell")
    wishListTableView.delegate = self   // 2
    wishListTableView.dataSource = self // 2
 
    // appearance
    backgroundContentButton.applyArrowDialogAppearanceWithOrientation(arrowOrientation: .down) // 3
  }
 
  @IBAction func seeDetails(_ sender: Any) { // 4
    delegate?.detailsRequestedForPerson(person: person)
  }
 
  func configureWithPerson(person: Person) { // 5
    self.person = person
 
    personImageView.image = person.avatar
    personName.text = person.name
    wishListTableView.reloadData()
  }
 
  // MARK: - UITableViewDelegate/DataSource methods
 
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return person?.wishList.count ?? 0
  }
 
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // 6
    let cell = tableView.dequeueReusableCell(withIdentifier: "PersonWishListItemTableViewCell", for: indexPath) as! PersonWishListItemTableViewCell
    if let item = person?.wishList[indexPath.row] { cell.configureWithItem(item: item) }
    return cell
  }
 
}

Let’s analyse this code step by step:

  1. We will set a protocol to have our delegate know when the user clicks on the “See Details” button.
  2. it’s important to set ourselves as the datasource/delegate of the table view in order for it to be populated and react to user interaction.
  3. When we refuse to use the default MapKit callout, we need to take care of everything, and that includes giving the proper bubble appearance to the callout and setting the arrow at the bottom. This category of UIView, with the applyArrowDialogAppearanceWithOrientation() method, just creates a Bezier path around the view with a bottom arrow. If you want to know more about Bezier paths and applying custom shapes to views and graphic elements, please have a look at my post “Bezier paths in practice (I): From basic shapes to custom designable controls“.
  4. This IBAction calls our delegate when the button is pressed.
  5. The configureWithPerson(person:) method will configure the cell for certain person, set the outlets, images, names, and reload the wishlist.
  6. The table view will contain simple rows, composed of just a label with the wishlist item’s name. Notice how we loaded the Nib for this custom cell in the viewDidLoad method. This Nib is really simple, has just one outlet, and looks like this:

Now that we have our custom callout view properly designed, and linked to the right behaviour, we can modify our PersonWishListAnnotationView to return an instance of this custom view in the loadPersonDetailMapView method. We will redefine it as follows, returning now a PersonDetailMapView instead of a simple UIView:

func loadPersonDetailMapView() -> PersonDetailMapView? {
  if let views = Bundle.main.loadNibNamed("PersonDetailMapView", owner: self, options: nil) as? [PersonDetailMapView], views.count > 0 {
    let personDetailMapView = views.first!
    personDetailMapView.delegate = self.personDetailDelegate
    if let personAnnotation = annotation as? PersonWishListAnnotation {
      let person = personAnnotation.person
      personDetailMapView.configureWithPerson(person: person)
    }
    return personDetailMapView
  }
  return nil
}

Here, we just load the view from the nib, and configure it with the proper person of this annotation view’s annotation.

If you run the application now, upon clicking on a pin, you will get the view, properly positioned agains the annotation in the map, and showing the right info for the selected person, hooray! But you will notice that we cannot click in the button or scroll through the list. All interaction seems to be captured by the map and not passing through the custom callout. This is exactly what’s happening, and in order to solve that, we need to specify some hit tests for the views we want to be interactive.

Adding Interactivity to our Custom Callout

The problem with our custom callout controls is that the map, and its associated gesture recognisers, are capturing all touches and interactions inside the map, so user touches don’t pass through to the button or the list. In order to solve that, we need to make use of the “hitTest” methods for both our annotation view and our custom callout view.

hitTest is a method that’s used to identify whether an interaction event (like a touch on a view) affected certain views or subviews of the control being tested. This method has multiple applications, but it’s relatively obscure and not well known to many programmers.

So our strategy will be the following: first, we will define hitTest in our PersonWishListAnnotationView custom annotation view. When a touch triggers this method, if the touch affected the superview or another external view, unrelated to the annotation or the custom callout, we’ll let it pass through to avoid affecting the normal behaviour of the map. However, if the touch event happened in our custom callout (supposing the annotation has been selected and the custom callout is showing), we will call the hitTest method on our custom callout view:

 
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  // if super passed hit test, return the result
  if let parentHitView = super.hitTest(point, with: event) { return parentHitView }
  else { // test in our custom callout.
    if customCalloutView != nil {
      return customCalloutView!.hitTest(convert(point, to: customCalloutView!), with: event)
    } else { return nil }
  }
}

So, in our PersonDetailMapView custom callout view, we will also redefine the hitTest method, and we will check the hit test of every subview we might be interested in. This, in our current case, includes the “See Details” button and the UITableView list:

// MARK: - Hit test. We need to override this to detect hits in our custom callout.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  // Check if it hit our annotation detail view components.

  // details button
  if let result = seeDetailsButton.hitTest(convert(point, to: seeDetailsButton), with: event) {
    return result
  }
  // list
  if let result = wishListTableView.hitTest(convert(point, to: wishListTableView), with: event) {
    return result
  }
  // fallback to our background content view
  return backgroundContentButton.hitTest(convert(point, to: backgroundContentButton), with: event)
}

As you might see, we included the background view covering the entirety of our custom callout view. This background view serves as a “cover” to the callout. If we add it, pinned to the edges, and return its hitTest by default when everybody else fails, we make sure that the callout is “touched” when no concrete control is (buttons, lists, etc), and so the callout won’t be dismissed (unselected) by the map when clicking inside.

Final touches

Now the rest is easy as pie. We just need to set our delegates in place from the ViewController to the PersonDetailMapView via the PersonWishListAnnotationView (ViewController -> PersonWishListAnnotationView -> PersonDetailMapView), add a new UIViewController for the details screen and make sure to do the segues and set the right person in prepareForSegue.

As always, you can download the full code sample project in my Github repository. If you have any question, please don’t hesitate to comment. Also, if you want to be always up to date about new posts in this series, join the “Digital Tips” list.

Where to go from here

In the previous post of this series, “Building The Perfect iOS Map (I): Synchronising a Map and a List“, we learned to synchronise a list (UITableView) with the map (MKMapView) to make them work together like in AirBnB or Idealista. In a future post, we will talk about faking the device location for debugging purposes, and about overlays. Stay tuned!