A Custom Callout View for iOS
There’s a great UI widget that’s been on iOS since day one:
Ever wonder what that black pointy bubble is exactly? It’s kind of a strange beast. The way it “bounces” when opening is an animation that I believe is unique across the entire iOS platform. It kind of grows out from the tip of the arrow then springs back like a
UIAlertView. I still find it fun and satisfying to watch.
So when we were creating our indoor mapping UI for Meridian, I naturally wanted to use this widget. Unfortunately, it’s considered a private API. Bummer!
There are some existing 3rd-party controls out there that mimic this one, but they aren’t a perfect replica. And I demand perfection! So I made my own version, and dubbed it
Just Like UICalloutView
The name of Apple’s control is
UICalloutView, and it’s a completely private class. You can display one in your app, but only if you’re using
MKMapView. And even then, you don’t get much control over it; it’s created and managed for you by the system when the user touches an
MKAnnotationView (the little red pin).
UICalloutView is surprisingly complex. To make a good replacement class requires studying it intently and learning its quirks. Starting with the background graphics:
You can see that we won’t get away with a single stretchable
UIImage like most button backgrounds. These images need to be arranged and scaled differently depending on the size of the callout and the position of the anchor arrow.
So conceptually, the arrow needs to be positioned first, then the left and right single pixel “filler” images are stretched and applied, and the left/right caps are slapped on the ends.
Here’s a taste:
UIView Frame Helpers
You may wonder what those strange
$left/$right/etc properties are in the code sample above. In the process of writing this control, I found myself needing to do a lot of fiddling with the frames of its various subviews.
Of course, if you want to change part of a view’s frame, you need to declare an intermediate
CGRect variable; you can’t just write code like “
view.frame.origin.x = 50" because
origin is a member of the struct
CGRect and blah blah long unsatisfying explanation.
I’m sure this workaround will be familiar to you:
Rather than write tedious pages of this stuff, I made some category methods on
UIView to make this the one line of code it should be:
…which I ought to have done years ago!
SMCalloutView supports exactly four content elements:
- Left “accessory” view
- Right “accessory” view
Additionally, Apple cheats and supports “gold star” images in the subtitle area on iOS 6. But let’s ignore this for now, yes? [Update: no need to ignore it, because SMCalloutView now supports arbitrary views for title/subtitle!]
I wanted to match the layout pixel-for-pixel, because I am crazy like that. So I did a lot of testing. One great technique for examining view layouts is
recursiveDescription, an undocumented method on
UIView that traces out a nice indented hierarchy of your views.
I simply embedded an
MKMapView in my sample app, caused the
UICalloutView to appear, then called
recursiveDescription in the debugger:
Ah ha! There’s the
UICalloutView exposed, and all its subviews: the background images, the accessory views, the title and subtitle. So that’s an easy way to figure out the precise x/y of subviews without having to make guesses then compare results in Photoshop.
I expected the animation was going to be the hardest part to get right. Fortunately, Matthias Tretter pointed me to Sam Vermette’s excellent custom CAKeyframeAnimation. This animates the “transform.scale” property of a
CALayer such that it grows from nearly invisible to a bit greater than 100%, then “wobbles” once into the final size.
Sam’s animation was almost perfect, but when I compared it to
UICalloutView animating in the iOS Simulator’s “slow-mo” mode, the timing seemed a bit off. So I ran some tests that printed the
UICalloutView layer’s current animations on a timer, and discovered that Apple’s “bounce” is just a simple set of
That got me the exact duration and scale numbers I was craving. Now you can popup
UICalloutView in simulator slow-mode and they look identical.
Additionally, callouts need to expand not from the center (the default), but from the tip of the arrow. This is easily accomplished by setting the callout layer’s
The callout is designed to point at whatever the user selects, in a visually pleasing way.
One thing I learned is that
UICalloutView always prefers to be in the middle of the screen. If it can scoot its pointer left or right while keeping itself in the center, it’ll do so. But if the target is too far away from the calculated size of the bubble, it grudgingly moves over.
Additionally, one of my favorite tricks it can do is when you touch something that’s really close to the edge…
…Instead of allowing the popup to be partially offscreen, it first scrolls the map just enough to fit the callout perfectly.
That was a lot of fun to replicate (Math!); although it does introduce an extra wrinkle: to support this feature, the callout view must “collaborate” with the view containing it. This is why
SMCalloutView has the delegate method
If you look closely at Apple’s images for their callout control, you’ll notice they have an extra graphic for an upward-pointing arrow:
It seems like
UICalloutView was designed to support pointing up at things as well as down, but
MKMapView never activates this mode. Luckily, it wasn’t too much work to make
SMCalloutView support it. I now present to you: A never-before-seen upward-pointing callout view!
You can tell it to appear pointing up or down or you can let it decide for itself based on the situation.
SMCalloutView also allows you to set your own background graphics, so you can customize your popup to make it look all special. By default the graphics will match the system callout, and what’s cool is that you don’t have to add any extra images to your project to make it work: the images are base64-encoded right into the Objective-C source (they were super tiny).
That’s about it for
SMCalloutView. There’s more it could do in the future but I think it’s ready to ship as-is. Go download or fork it on Github, and let me know if you find any bugs or misbehavings, or if you have trouble grokking how to use it.