Building Custom Map Annotation Callouts – Part 2
Part 1 showed how to build a custom map callout that provides more content flexibility than the native callout, but maintains the expected look and behavior. In part 2 we will add a very common element of the map interface into our custom callout – the accessory button. At first glance this seems simple: just add a button to the callout. However, MapKit intercepts touch events and causes undesired callout behavior. The code used to add an accessory button is also applicable to any other button(s) or responders you may want to add to a callout, giving you the flexibility to do what you feel is best for your users.
Add the Button
We will begin by adding the button as we normally would, to see this behavior in action. This will be done by creating a subclass of the custom callout from part 1. Notice the attempt to call the standard callback for an accessory tap in calloutAccessoryTapped.
@synthesize accessory = _accessory;
- (id) initWithAnnotation:(id <mkannotation>)annotation reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithAnnotation:annotation reuseIdentifier:reuseIdentifier]) {
self.accessory = [UIButton buttonWithType:UIButtonTypeDetailDisclosure];
self.accessory.exclusiveTouch = YES;
self.accessory.enabled = YES;
[self.accessory addTarget: self
action: @selector(calloutAccessoryTapped)
forControlEvents: UIControlEventTouchUpInside | UIControlEventTouchCancel];
[self addSubview:self.accessory];
}
return self;
}
- (void)prepareContentFrame {
CGRect contentFrame = CGRectMake( self.bounds.origin.x + 10,
self.bounds.origin.y + 3,
self.bounds.size.width – 20,
self.contentHeight);
self.contentView.frame = contentFrame;
}
- (void)prepareAccessoryFrame {
self.accessory.frame = CGRectMake(self.bounds.size.width – self.accessory.frame.size.width – 15,
(self.contentHeight + 3 – self.accessory.frame.size.height) / 2,
self.accessory.frame.size.width,
self.accessory.frame.size.height);
}
- (void)didMoveToSuperview {
[super didMoveToSuperview];
[self prepareAccessoryFrame];
}
- (void) calloutAccessoryTapped {
if ([self.mapView.delegate respondsToSelector:@selector(mapView:annotationView:calloutAccessoryControlTapped:)]) {
[self.mapView.delegate mapView:self.mapView
annotationView:self.parentAnnotationView
calloutAccessoryControlTapped:self.accessory];
}
}
@end
[/objc]
We will also implement that callback in the map view delegate. Normally a new view would be pushed on to the navigation stack at this point, but for this example it will be simpler to just display an alert.
[objc collapse="false" wraplines="false"] - (void)mapView:(MKMapView *)mapViewannotationView:(MKAnnotationView *)view
calloutAccessoryControlTapped:(UIControl *)control {
UIAlertView * alert = [[[UIAlertView alloc] initWithTitle:@"Asynchrony Solutions"
message:@"Callout Accessory Tapped"
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil] autorelease];
;
}
[/objc]
Prevent Deselection of the Parent Annotation
If the button is tapped now, the alert will be displayed, but the callout is removed because the touch event also caused the parent annotation to be deselected just as if the button were not there. To solve this problem, we will have to disable selection changes on the parent annotation and make a small change to mapView:didDeselectAnnotationView: in the mapView delegate.
First off, we need to subclass MKPinAnnotationView (or MKAnnotationView if using a custom annotation) to add a preventSelectionChange property and override setSelected:animated:.
BOOL _preventSelectionChange;
}
@property (nonatomic) BOOL preventSelectionChange;
@end
[/objc]
[objc collapse="false" wraplines="false"]
@implementation BasicMapAnnotationView
@synthesize preventSelectionChange = _preventSelectionChange;
- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
if (!self.preventSelectionChange) {
[super setSelected:selected animated: animated];
}
}
@end
[/objc]
When the button is tapped, the callout needs to set the new preventSelectionChange property to YES and set it back to NO a short time later (1 second seems to be a good delay for this call). This needs to be done before the typical touch event callbacks are invoked so we will override hitTest:withEvent:. Also, The mapView keeps track of which annotations are selected, so when selection changes on the parent are re-enabled, the map view needs to be forced to select the annotation again.
UIView *hitView = [super hitTest:point withEvent:event];
if (hitView == self.accessory) {
[self preventParentSelectionChange];
[self performSelector:@selector(allowParentSelectionChange) withObject:nil afterDelay:1.0];
}
return hitView;
}
- (void) preventParentSelectionChange {
BasicMapAnnotationView *parentView = (BasicMapAnnotationView *)self.parentAnnotationView;
parentView.preventSelectionChange = YES;
}
- (void) allowParentSelectionChange {
[self.mapView selectAnnotation:self.parentAnnotationView.annotation animated:NO];
BasicMapAnnotationView *parentView = (BasicMapAnnotationView *)self.parentAnnotationView;
parentView.preventSelectionChange = NO;
}
[/objc]
Even though the selection change is disabled on the parent annotation view, the map view will still invoke the delegate method mapView:didDeselectAnnotationView:. Add an additional condition to the if-statement to prevent removal when the annotation view is not allowing selection changes.
if (self.calloutAnnotation &&
view.annotation == self.customAnnotation &&
!((BasicMapAnnotationView *)view).preventSelectionChange) {
[self.mapView removeAnnotation: self.calloutAnnotation];
}
}
[/objc]
Prevent Selection of Other Annotations
With the above code, the callout now behaves as expected in most situations; however, if another annotation happens to be under the button, it will be selected. The simplest way to solve this is to disable all the annotation views on the map except the custom callout and the parent annotation. We can find all of the other annotation views by getting the subviews of the superview of the callout, and checking that they inherit from MKAnnotationView. Also, they must be re-enabled a short time later (again, a 1 second delay works well).
UIView *hitView = [super hitTest:point withEvent:event];
if (hitView == self.accessory) {
[self preventParentSelectionChange];
[self performSelector:@selector(allowParentSelectionChange) withObject:nil afterDelay:1.0];
for (UIView *sibling in self.superview.subviews) {
if ([sibling isKindOfClass:[MKAnnotationView class]] && sibling != self.parentAnnotationView) {
((MKAnnotationView *)sibling).enabled = NO;
[self performSelector:@selector(enableSibling:) withObject:sibling afterDelay:1.0];
}
}
}
return hitView;
}
- (void) enableSibling:(UIView *)sibling {
((MKAnnotationView *)sibling).enabled = YES;
}
[/objc]
Conclusion
Now the Custom Map Callout is complete. Using the code and concepts presented in this post and Part 1, you have the tools to build callouts that fit your needs. With minor adjustments to this code, you can add multiple buttons, implement a callout with adjustable width, or change the look of the callout to match your application’s style.
You may download the full source code to see a working example.

To get this to work on pre-4.0 devices, just put a check in the hitTest method: if this is a pre-4.0 device, then after [self preventParentSelectChange];
invoke
[self calloutAccessoryTapped];
Thanks again for this great code.
Hi
I’m a beginner in Phone dev.
looking at your screenshot, I see that the standard title/subtitle has been replaced by a more enriched object set (an image+button).
what I would like to do in my app is displaying in the callout bubble
- a title
- below the title, the results of a webservice, basically a list such as :
image 1 – name1
image 2 – name2
image 3 – name3
where the callout bubble could fit this content,
+ a button like yours.
is it possible ? how long do you think it would take to be coded ?
thanks for your help that will be very precious to me…
regards
Dan.
Has anybody managed to deploy this to OS 3.1.3. I’m running XCode 3.2.4 on SDK 4.1, so I’ve set my Base SDKs to iOS Device 4.1 and the iOS Deployment Target to iOS 3.1.3, but the custom callout with the image never shows. The other callouts work perfectly. I’ve tried the comment for the pre-4.0 devices but still doesnt work.
Jean, to get the callout to show up on pre-4.0 devices you will need to call back to a method your map view controller from within setSelected:animated: on the map annotation.
Dan, to add content to the custom map callout it is as simple as setting the height required and adding views to the custom callout. In this example I just added an image, but it would be simple to add a few UILabels instead.
Is it possible to have phone number recognition in text in the callout view?
So the user can call that number or do I have to use this solution with a button?
Hi,
happy new year @all.
Works great now for me with multiple annotations. On 3.1 it also works, but what could be the problem that the callout doesn’t center on the map / the map doesn’t center the selected annotation??
Greetings
[...] Part 2 covers adding a button to the custom callout, which is not as simple as it sounds. [...]
Thank you for your tutorial.
Great work!
Hey, great tutorial!
How can i avoid replacing current location (blue bubble) with standard red pin ?
//found soultion for it ^^ if someone is interested
if (annotation == self.mapView.userLocation){
return nil;
}
Thanks you very much. This was exactly what I needed. It saved me for a ton of work.
Great work! Thanks for sharing it. Saved me a lot of time and effort!
Hi! Very cool tutorial, thank you for your work. I found what i needed!
Anyone know how to get the annotations title to appear inside the callout? I created the UILabel, but cannot get the title information to work.
Hi,
Thanks for the tutorial. How do I get the accessory button for the text display callout instead of the picture display?
Thanks,
Venkat
Hi, is this tutorial code under some kind of license? Which one?
Thanks for such useful tutorial!
Are we allowed to use some of this code in an app for sale?
Are you sure you need to do all this for just a custom annotation. It works, but either your code is unnecessarily bloated, or cocoa is a pain.