Building the perfect iOS Map (II): Completely custom annotation views - Digital Leaves
1386
post-template-default,single,single-post,postid-1386,single-format-standard,qode-social-login-1.0,qode-restaurant-1.0,ajax_fade,page_not_loaded,,select-theme-ver-4.1,wpb-js-composer js-comp-ver-5.2,vc_responsive

Building the perfect iOS Map (II): Completely custom annotation views

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.

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:

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:

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.

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:

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.

 

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:

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:

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:

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!

 

18 Comments
  • jamie

    March 24, 2017 at 10:42 am Reply

    this tutorial is awesome!! thanks so much!

  • Paulo

    March 31, 2017 at 2:53 pm Reply

    how are you ignacio? Nice turorial. I was just wondering of instead creating random users and uploading them to the map, how can I upload registered users from a certain database like firebase, and integrate them into the map. thanks

    • Ignacio Nieto Carvajal

      April 5, 2017 at 4:36 pm Reply

      Hi there Paulo!
      Sorry for my late reply. Well, that would be a completely different tutorial on its own :). You need to download the data from your users in firebase somehow and create the user entities as you download them. This, of course, is the summary, and as I told you is stuff worth a tutorial on its own.

  • Marlies

    May 8, 2017 at 6:13 am Reply

    Hi Ignacio!
    Thanks for the great tutorial!
    I was trying to recreate it with my own .xib. Everything works perfectly, except the callout looks like this https://snag.gy/jD5JLq.jpg (it’s only a white rectangle without the triangle at the bottom). I can’t find anything that’s different from your code and xib, other than mine uses StackViews and I don’t have a Person Object. The applyArrowDialogAppearanceWithOrientation(arrowOrientation: .down) function doesn’t do anything in my implementation. Do you have an idea were I went wrong?

    • Ignacio Nieto Carvajal

      May 8, 2017 at 11:43 am Reply

      Hi there Marlies, thanks.
      It’s hard to know without having a look at the .xib file or the source code. Some things off the top of my head:
      1. Is the “background view” where you are calling applyArrowDialogAppearanceWithOrientation the only white view covering the callout? In my case, the button/view is the only element providing a white background, so when I cut it via the applyArrow… method, there’s nothing else “covering” the background with white.
      2. Are you leaving enough distance from the last element at the bottom of the callout to the bottom layout guide? In my case, I leave a distance (via a vertical distance constraint) between my button and the bottom edge of 43 pixels. This distance needs to be there.

      Hope it helps!

      • Marlies

        May 29, 2017 at 9:23 am Reply

        Hi Ignacio,

        setting the opacity of the background of the view to 0% solved it for me, thank you so much!

  • Trevor Lyons

    May 17, 2017 at 9:11 am Reply

    Hi Ignacio, at the beginning of your tutorial you showed an image with what appears to be a custom annotation view from a nib/ xib. I currently have a project where I am loading my annotation view from an image but would like to change it to something that can contain more data without using a callout.

    Do you have any quick advice for implementing this? I’m currently trying to load the .xib through the viewfor annotation function.

    Thanks, Trevor.

    • Ignacio Nieto Carvajal

      May 18, 2017 at 10:01 am Reply

      Hi there David,
      What’s shown at the beginning of the tutorial is Apple’s sample code for “custom annotations”. These are just the images that replace the pins, so it’s just an image. If you want to show some extra data, and this data needs to be somewhat interactive, you need a callout.
      However, if you can “draw” this data into an image (like text, for instance), then you can try doing it by putting an image instead of a pin, but I definitely don’t recommend that.

      Hope it helps!

  • Horatiu Flaviu Muresan

    May 27, 2017 at 8:56 am Reply

    Hello Ignacio! I have been looking all over the internet for a great tutorial for a custom MKMapView. Yours is the best so far. However, when I try to implement it in my own applications, the annotations won’t show on the map. I am designing an application for money exchange agencies to upload their services in order to be more discoverable and to get customers. I am wondering what I missed from the tutorial, since I am still debugging it for 4 days now. Maybe you can help, I will post the classes. I am getting the data from firebase, and add it to my custom AnnotationView.

    Code: https://gist.github.com/anonymous/555af9686aa50fbb3434600fbfdd2d79

    .xib files printscreen: https://prnt.sc/fcn4tc https://prnt.sc/fcn4pw

    Console Output: https://gist.github.com/anonymous/7ac61a64540b9d8a131d2edd3c1a0540

    Any help will be much appreciated. Thank you!

    • Horatiu Flaviu Muresan

      May 27, 2017 at 10:11 am Reply

      Actually, I have found the issue.. The button name in my custom annotation view was mistyped. However, this tutorial works just fine, best I found on the web so far. Maybe the code I posted would help somebody else integrate this with firebase!

      • Ignacio Nieto Carvajal

        May 27, 2017 at 9:07 pm Reply

        Hi there! Sorry for not answering before. I’m glad you found the issue.

        Thanks for your kind words about my post, I’m happy to know you found it useful.

        Best!

  • Logan Koshenka

    June 3, 2017 at 2:38 am Reply

    Very helpful tutorial. I’m building an app I’ve been working on for a while and would love to speak more with you about custom MKAnnotation and MKAnnotationView classes. Do you do any sort of office hours or consulting? Please let me know. Regardless, super helpful tutorial and looking forward to more!

  • Matthias

    September 25, 2017 at 11:04 am Reply

    Hi Ignacio,

    First of all – thank you very much for that awesome and detailed tutorial!

    I have tried to integrate the custom annotations into my project, but I am stuck now…

    Maybe you can help me from the far:)

    I managed to integrate the model until the Point where you have the Array of Person objects to view in the map.

    However, when the map opens I only get the default annotations. No custom map Pin and no custom annotation..

    I am loading the custom annotations when a certain Button is clicked, Not when the view loads.

    Can this be a problem?

    If you need to see the project I can upload it somewhere for you.

    Thanks,
    Matthias

    • Ignacio Nieto Carvajal

      September 27, 2017 at 7:26 am Reply

      Hi there Matthias, thanks for your comment, glad you liked it.
      It’s hard to tell without having a look at your code, but take into account that annotations cannot be added and then immediately get displayed because of how MapView and its delegate work. The annotations added to the map are displayed when the delegate viewForAnnotation: etc are called. Thus, it may happen that your annotations are being added after calling the delegate methods. If so, just refresh the map. Another possibility is that the class is not the delegate for the map view? That actually happens to me from time to time. I forget to add the delegate connection.

      Hope it helps!

      • Matthias

        September 27, 2017 at 6:35 pm Reply

        You are the man :)
        Thanks again for the tip with the delegate… I wasn’t searching there at all… kept debugging for hours and was wondering where the magic was in your example haha – obviously, one click+drag only

        • Ignacio Nieto Carvajal

          September 27, 2017 at 6:41 pm Reply

          No problem! Really glad to know it helped!
          It has happened to me sometimes. I’m banging my head against a wall for hours, and then someone suggests “have you checked this…?” and that’s exactly it :)

Post a Comment

Before you continue...

Hi there! I created the Digital Tips List, the newsletter for Swift and iOS developers, to share my knowledge with you.


It features exclusive weekly tutorials on Swift and iOS development, Swift code snippets and the Digital Tip of the week.


Besides, If you join the list you'll receive the eBook: "Your First iOS App", that will teach you how to build your first iOS App in less than an hour with no prior Swift or iOS knowledge.

I hate spam, and I promise I'll keep your email address safe.