How To Completely Customise Your Map Annotations Callout Views

Update December 2017: Fully updated for Xcode 9 and Swift 4.

In a previous tutorial, you learnt how to customise the map annotations’ callouts. The detailCalloutAccessoryView property was a nice add to the MKAnnotationView class in iOS 9.

However, such extension was limited by only providing a left, right and detail callout views. More than that, you always had to assign a title string to the pin annotation in order to make the callout show up for you. And that actually wasn’t always useful.

So why does a complete annotation callout customisation comes in handy in some circumstances?

Nice question, let’s say you don’t want to put a title in your annotation callout? Well, just leave the title string empty! But that will lead to something like this:

Annotation callout

The title has left an annoying white space at the top of the callout view.

Also, in many cases, you may find yourself uncomfortable with the three system-provided accessories views (left, detail and right) and would want to reorganize the callout elements in a way to add more controls to the view or set your own layout. Fortunately, MapKit leaves a room for you to completely customize the map annotations callout views.

And this tutorial is here to guide you, step by step, on how to do so.

As always, let’s do this!

Begin by downloading the starter project to work with here.

Expand the project files in the Project navigator view, you will notice I prepared a custom xib file called “CustomCalloutView” that contains three labels and a UIImageView, along with a UIView class to manage its outlets.

This UIView will replace the standard annotation callout. You may also change it and apply the Auto Layout constraints the way you want it to look.

Ok, let’s start by placing some annotations in the map. The annotations will represent some starbucks in the Paris area.

Select ViewController.swift file from the Project navigator view to open it in the editor. Then place the following declarations at the top, right after the class declaration:

var coordinates: [[Double]]!
var names:[String]!
var addresses:[String]!
var phones:[String]!

Next, locate the viewDidLoad method and implement the code below inside:

// 1
coordinates = [[48.85672,2.35501],[48.85196,2.33944],[48.85376,2.33953]]// Latitude,Longitude
names = ["Coffee Shop · Rue de Rivoli","Cafe · Boulevard Saint-Germain","Coffee Shop · Rue Saint-André des Arts"]
addresses = ["46 Rue de Rivoli, 75004 Paris, France","91 Boulevard Saint-Germain, 75006 Paris, France","62 Rue Saint-André des Arts, 75006 Paris, France"]
phones = ["+33144789478","+33146345268","+33146340672"]
self.mapView.delegate = self
// 2
for i in 0...2
{
  let coordinate = coordinates[i]
  let point = StarbucksAnnotation(coordinate: CLLocationCoordinate2D(latitude: coordinate[0] , longitude: coordinate[1] ))
  point.image = UIImage(named: "starbucks-\(i+1).jpg")
  point.name = names[i]
  point.address = addresses[i]
  point.phone = phones[i]
  self.mapView.addAnnotation(point)
}
// 3
let region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 48.856614, longitude: 2.3522219000000177), span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1))
self.mapView.setRegion(region, animated: true)

A quick explanation is needed here:

//1: Here you just filled the arrays with some starbucks coordinates and informations, also you set the current class as the map view delegate since we will implement some MKMapView protocol methods here.

//2: Looping over the starbucks arrays, instantiating an annotation for each object and add it to the map view. You will implement the StarbucksAnnotation custom class right away.

//3: After placing all the annotations, you just centered the map around the Paris area with a convenient zoom level.

Also, don’t forget to change the class declaration like below, so that the class now conforms to the MKMapViewDelegate protocol:

class ViewController: UIViewController, MKMapViewDelegate {

Alright, time to implement the custom MKAnnotation class that will handle a reference for all the pin informations (image, phone, coordinates, etc).

Select “File\New\File” from the menu and choose the “Swift File” template. Call it “StarbucksAnnotation” and hit the “Create” button.

Erase the default content that comes with the “StarbucksAnnotation” class and implement the following code inside:

import MapKit

class StarbucksAnnotation: NSObject, MKAnnotation {
    
    var coordinate: CLLocationCoordinate2D
    var phone: String!
    var name: String!
    var address: String!
    var image: UIImage!
    
    init(coordinate: CLLocationCoordinate2D) {
        self.coordinate = coordinate
    }
}

As you can see, the class should conform to the MKAnnotation protocol in order to provide any annotation-related data. In our case, the phone, coordinate, name, address, image are all relevant to the annotation and will all be attached to the starbucks pin and fetched when necessary.

Now you can build and run the project successfully. All of the three pin annotations will be correctly placed on the map view.

Switch back to the “ViewController.swift” class and implement the following MKMapViewDelegate protocol method:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {        
if annotation is MKUserLocation
   {
      return nil
   }
   var annotationView = self.mapView.dequeueReusableAnnotationView(withIdentifier: "Pin")
   if annotationView == nil{
       annotationView = AnnotationView(annotation: annotation, reuseIdentifier: "Pin")
       annotationView?.canShowCallout = false
   }else{
       annotationView?.annotation = annotation
   }
   annotationView?.image = UIImage(named: "starbucks")
   return annotationView
}

The method above will be called each time an annotation is about to show in the map. Basically, it will ask for a reusable annotation from the map view. If no reusable object is available in the queue, it will instantiate a new MKAnnotationView object (instance of the “AnnotationView” class).

Note that you set the canShowCallout property to false, this is important since you gonna provide your own custom callout view for the annotation and hence you tell the system NOT to show the default provided callout.

Let’s create the “AnnotationView” class. Select “File\New\File” from the menu, choose “Swift File” template and name it “AnnotationView”. Remove its default content and replace it with the following:

import MapKit

class AnnotationView: 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
    }
}

The code above is necessary to track the touch click on the custom callout views since you will no longer use the default annotation view callout, but instead a custom UIView 🙂

The project can now compile and run correctly, but the custom callouts views can not be shown yet.

In order to add the custom view and make it behave as the annotation callout, you will need to choose the right moment to do so. The correct moment is when the annotation is selected, at such event, you will add the custom view as a subview to the annotation view 🙂

The idea is clear now, fortunately there is a ready to go MKMapViewDelegate protocol method (mapView:didSelectAnnotationView:) that will let us know each time an annotation view is selected.

Switch to the “ViewController.swift” file and implement the following code before the class closing bracket:

 func mapView(_ mapView: MKMapView,
        didSelect view: MKAnnotationView)
{
    // 1
    if view.annotation is MKUserLocation
    {
       // Don't proceed with custom callout
       return
    }
    // 2
    let starbucksAnnotation = view.annotation as! StarbucksAnnotation
    let views = Bundle.main.loadNibNamed("CustomCalloutView", owner: nil, options: nil)
    let calloutView = views?[0] as! CustomCalloutView
    calloutView.starbucksName.text = starbucksAnnotation.name
    calloutView.starbucksAddress.text = starbucksAnnotation.address
    calloutView.starbucksPhone.text = starbucksAnnotation.phone
    calloutView.starbucksImage.image = starbucksAnnotation.image
    let button = UIButton(frame: calloutView.starbucksPhone.frame)
    button.addTarget(self, action: #selector(ViewController.callPhoneNumber(sender:)), for: .touchUpInside)
    calloutView.addSubview(button)
    // 3
    calloutView.center = CGPoint(x: view.bounds.size.width / 2, y: -calloutView.bounds.size.height*0.52)
    view.addSubview(calloutView)
    mapView.setCenter((view.annotation?.coordinate)!, animated: true)
}

Some explanation is needed here:

// 1: First, the method will check whether the selected annotation is a user location related annotation, in such case, it will return without doing anything.

// 2: Here, you cast the annotation to the “StarbucksAnnotation” class in order to fetch the related data for the starbucks objects. Then, you instantiated the “CustomCalloutView” xib file and set the name/address/phone/image data to show on the view. You also created a button with the same frame as the phone label in order to respond to the click and fire a method to launch the phone call.

// 3: Finally, you centered the callout view to align it right above the annotation view.

Implement the selector method that gets running when you click on the phone label:

    @objc func callPhoneNumber(sender: UIButton)
    {
        let v = sender.superview as! CustomCalloutView
        if let url = URL(string: "telprompt://\(v.starbucksPhone.text!)"), UIApplication.shared.canOpenURL(url)
        {
            UIApplication.shared.openURL(url)
        }
    }

The code from the method above will cast the selected view to retrieve the phone number from the CustomCalloutView property.

So far so good, you can now build and run the app. The callouts will show correctly. However, you will notice that the obvious behaviour of dismissing an annotation view callout when another annotation is selected is not occurring.

To make this happen, let’s implement one last protocol method in order to deselect an annotation view and always keep one callout view shown on the map.

Always inside the “ViewController.swift” file, Implement the following method code before the class closing brackets:

func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
        if view.isKind(of: AnnotationView.self)
        {
            for subview in view.subviews
            {
                subview.removeFromSuperview()
            }
        }
 }

The code above will test whether the deselected annotation view is an instance of the “AnnotationView” class, in which case it will run through its subviews and remove them. Remember the “CustomCalloutView” object was added as a subview of the annotation view, and this makes it behave like any other subview in the annotation views hierarchy.

That’s it, run the app and enjoy your work 🙂

As usual, the final project source code is available for download here.

I would be interested to know how do you guys customize your map annotation views’ callouts? Let me know in the comments below.

Malek
iOS developer with over than 11 years of extensive experience working on several projects with different sized startups and corporates.

43 Comments

  1. Hi, Malek! When I try to run the app on my iPhone 5, I get the “ambiguous use of subscript error” associated with the line below: let point = LocationsAnnotation(coordinate: CLLocationCoordinate2D(latitude: coordinate[0] as! Double, longitude: coordinate[1] as! Double)). Any thoughts on this? Thanks for the tutorials.

  2. Hi Joseph, I didn’t used a LocationsAnnotation class on the Tutorial. Can you provide me with more code from your project? Thanks!

  3. Hi, Malek! Thank you for your reply. My apologies for uploading the code without any context. My uploaded code corresponds to your code below. It runs fine in the simulator, but when trying to run it on my iPhone 5 the message “ambiguous use of subscript error” corresponds to the line marked with an asterisks. And, the error occurs within your project and mine. Also, I only made changes to variable names throughout all my files. There’s a longer sample of the code I used below the dashed line. Thank you for your help and all of the great tutorials!

    for i in 0…2
    {
    let coordinate = coordinates[i]
    let point = StarbucksAnnotation(coordinate: *CLLocationCoordinate2D(latitude: coordinate[0] as! Double, longitude: coordinate[1] as! Double))
    point.image = UIImage(named: “starbucks-(i+1).jpg”)
    point.name = names[i]
    point.address = addresses[i]
    point.phone = phones[i]
    self.mapView.addAnnotation(point)

    class ViewController: UIViewController, MKMapViewDelegate {
    @IBOutlet var mapView: MKMapView!
    var coordinates: [AnyObject]!
    var names:[String]!
    var infos:[String]!

    override func viewDidLoad() {
    super.viewDidLoad()

    // 1

    coordinates = [[35.91392,-79.053074],
    [35.912409,-79.044708],
    [35.914558,-79.053252],
    [35.903131,-79.043632]]

    names = [“Battle Hall”,
    “Cobb Dorminatory”,
    “Franklin Street”,
    “Horton Residence Hall”]

    infos = [“Include info here.”,
    “Include info here.”,
    “Include info here.”,
    “Include info here.”]

    self.mapView.delegate = self

    // 2

    for i in 0…3 //This is where the issue seems to lie
    {
    let coordinate = coordinates[i]
    let point = LocationsAnnotation(coordinate: *CLLocationCoordinate2D(latitude: coordinate[0] as! Double, longitude: coordinate[1] as! Double))
    point.image = UIImage(named: “tour-(i+1).jpg”)
    point.name = names[i]
    point.info = infos[i]
    self.mapView.addAnnotation(point)

    }

  4. Hi Joseph, please change the first two lines inside the for loop as below. This will fix the error 🙂

    let coordinate:[Double] = coordinates[i] as! [Double]
    let point = StarbucksAnnotation(coordinate: CLLocationCoordinate2D(latitude: coordinate[0], longitude: coordinate[1]))

  5. hi, when i try to insert a func witch calls a phone number, it dosent work, i mean, when y press the label nothing happens. i downloaded your example but nothing happens again
    could you tell me any solution?
    thanks a lot

  6. This example gives me all that I am looking for, except that the tapGesture does not work. I tried adding a function =>
    func CallPhoneNumber(sender: UITapGestureRecognizer)
    {
    print(“Hello”)
    }

    But this did not work.

    Any ideas?

  7. Hi Malek, thank you for the tutorial, it helped me A LOT!
    I am a beginner with iOS development, so excuse me if my question is obvious: can you teach us also how to dismiss the popover by tapping on it?
    Thank you again!

  8. Hi Malek

    With your suggested snippet to Joseph question the tutorial now runs on my iPhone 6 Plus, so thank you. One thing I have noticed is when you click on a pin that’s near the edge of a map, the callout isn’t fully visible. Is this problem solvable?

  9. Hi Andy,
    Many thanks for your comment 🙂
    I would suggest to center the map programmatically once the annotation view is selected. This way, the callout will be always fully visible.
    To do so, please add the following line of code at the end of the mapView:didSelectAnnotationView: protocol method:

    mapView.setCenterCoordinate((view.annotation?.coordinate)!, animated: true)

    Happy coding 🙂

  10. Thanks Malek and such a quick response. Could you add that feature and the code snippet that cured the compiler error to the tutorial as I am sure that will help other viewers.

    Now I would like to touch the callout itself and run some code, is this possible? So retain the deselection elsewhere in the view but either add a button or right side disclosure used in the standard callout.

  11. Hi Malek,
    10x for the tutorial.
    Actually, I need your help..

    I add a button to the customCalloutView.xib and i take her outlet and action.
    In the ViewController.swift , in the didSelectAnnotation method , i add :
    “calloutView.clickBtn.addTarget(self, action: #selector(ViewController.click), forControlEvents: .TouchUpInside)”
    where i define the “click” function in “ViewController.swift” as following :
    ” func click(){
    print(“clickeddd”)
    let ac = UIAlertController(title: “hi”, message: “hiiii”,
    preferredStyle: .Alert)
    ac.addAction(UIAlertAction(title: “OK”, style: .Default, handler:
    nil))
    presentViewController(ac, animated: true, completion: nil)
    }”

    but nothing happens…

    plz help , how i make the action of button works ?

  12. Recently Apply introduced Swift3 and there are some changes that are not applicable. Would you mind updating it please? The problem I am getting is at your coordinate, I got an error that says, Contextual type AnyObject cannot be used with with array literal.

    coordinates = [[48.85672,2.35501],[48.85196,2.33944],[48.85376,2.33953]]

    Thank you for your tutorial. I love them

  13. Quick question! When the custom annotations pop up, you tap once and the custom annotation will disappear, I also noticed that you implement a tapgesturerecognizer which however doesn’t call the number on the custom annotation because when you tried tapping on the number, the custom annotation disappear. Please advise

  14. Hello Malek, thank you for this tutorial which is very interesting, especially when you learn. I have two questions 1 / How to click on an anotation to get to a view of my application. 2 / How to put a different image for “annotationView? .image” starbucks “”. Thanks for your feedback.

  15. Hey @Malek_T:disqus , a few others have commented about the touch gesture recognizer not being called correctly. I’m having the same problem. Could you share some advice on how to fix it with us?

  16. Hi Andrew, I have edited the tutorial and the related project to answer your question 🙂
    Enjoy and don’t forget to subscribe!

  17. Hi Fabrice,

    I have edited the tutorial as well as the project source code to demonstrate how to track clicks on the callout views. Enjoy 🙂

  18. Hi Kelvin,

    Sorry for that, I have edited the tutorial as well as the project source code to demonstrate the callout clicks. Enjoy 🙂

  19. Hi Andy,

    I have edited the tutorial as well as the project source code to show you how to track click events on the callout subviews. Enjoy 🙂

  20. Hello thank you for this return, because of my bad English I do not know if I was clear in my message. In fact following your tutorial, I created a card with 5 pins and visiblent at the same time. Each pin to an annotation with a button that must return to a view corresponding to each annotation. In fact I think I was mistaken in asking the question, but I wanted to know if you can help me to define the segues to see the different views. Sorry and thank you

  21. Wow, @Malek_T:disqus I just want to say amazing. I have been working on trying to get hitTest to work on my MKAnntotationView for the last cpl of days from old tutorials and I was ready to give up for a few days and you just updated this tut a few hours ago. So I finally got that to work correctly. Yet I do have a question. Following your tutorial, instead of using the addTarget to get the function to call a number. I want to use the addTarget to get driving directions from my annotation, which is using the CustomAnnotation that has coordinates that are pulled from an array on firebase. So how would I use the button.addTarget(self, action: Selector(“”) for, .touchUpInside) to use the annotation make that happen.

    Its funny how you can run in circles trying to find a solution to a problem, and then a few hours later another piece of the puzzle emerges. It was if you were reading my mind this morning. Thanks again for your work.

  22. well I got that addTarget to work

    button.addTarget(self, action: #selector(MapViewVC.getDirections(sender:)), for: .touchUpInside)

    I didn’t realize you were calling the ViewController called ViewController.

  23. And I made some more progress. So I created a variable
    var selectedAnnotation: MyCustomAnnotation!

    and in the didSelect func
    self.selectedAnnotation = view.annotation as? MyCustomAnnotation

    so now when you click on the addTarget function it can now grab the selected annotation coordinates in the custom func, which yours is the
    func callPhoneNumber.

    This is in case anyone was needing the coordinates instead of just the number. Almost there. Your updated tutorial really helped today.

  24. Hi Malek

    Thanks for updating tutorial and source code, I have downloaded it and all works great, thank you very much. I will now try and incorporate some of the features in my app.
    Regards Andrew

  25. Well thank you, like I said originally, I spent the last few days trying to hitTest to work from old swift tutorials and stack overflow questions to no avail. This updated tutorial of yours solved my issue.

  26. Thank you Malek for this return. How for each annotation by clicking on it can you go to differentq viewcontroller ?. thank you

  27. Hi! I have got a problem with button action.Button doesnt clicks in simulator, but it can be clicked programmatically. Here is a fragment of code in didselectAnnotation view.

    let starbucksAnnotation = view.annotation as! CustomAnnotation
    let views = NSBundle.mainBundle().loadNibNamed(“callOutView”, owner: nil, options: nil)
    let calloutView = views?[0] as! CustomCalloutView
    calloutView.botOption.text = “test”
    calloutView.topOption.text = “Babysitting”
    let button = UIButton(frame: calloutView.frame)
    button.userInteractionEnabled = true
    button.addTarget(self, action: #selector(self.buttonClicked(_:)), forControlEvents: UIControlEvents.TouchUpInside)
    button.backgroundColor = UIColor(red: 102/255, green: 250/255, blue: 51/255, alpha: 0.5)
    calloutView.addSubview(button)
    self.delay(2) {
    button.sendActionsForControlEvents(.TouchUpInside)
    }

  28. Hello Malek, I looked at your example, but when I click on an annotation I will not go on another viewcontroller? Thanks for your feedback

  29. I am unable to click on callout view. I tried so much but i couldn’t get that. Can you suggest me?

  30. Malek.. Very helpful. Thank you for taking the time to do this and for your generosity in sharing.

  31. Hi Malek,

    thank you this tutorial !!! I’ve spend hours in evaluating this problem and finally find your (great) solution … I’m deeply impressed 🙂

  32. this code doesn’t work for annotations. All i have on my map are 3 pins and no additional subviews when clicking on pins. Please upgrade your code.

  33. How could I make this work for MKLocalSearch results? I am unsure how to wire the MKPlaceMark and MKMapItem data to my custom call out

  34. Hello there is a problem that I’ve encounter on this tutorial, it seems that if there are a lot pin behind the callout when you tapped on the callout it select new pin instead of calling the function for that callout (the is a button on the custom callout)

Comments are closed.