Building Custom Map Annotation Callouts – Part 1

52

The iPhone’s Map Annotation Callouts are very useful for displaying small amounts of information when a map pin (annotation) is selected. One problem with the standard callouts present in iOS is the inability to change the height of the callout.

For example, you may want to display a logo or other image that is taller than the default callout. Or you may want to display an address and phone number on separate lines under the title. Both of these scenarios are impossible using the standard iOS callouts. There are many steps to building a good replacement callout with the proper look and behavior, but it can be done.

Map Callout Compare

Part 1 (explained here) will explain how to build a custom map callout.

Part 2 covers adding a button to the custom callout, which is not as simple as it sounds.

Put it on the map (and take it off)

For this example we will create two simple map annotations in the view controller – one will display the standard callout and the other will display the custom callout.

To place the “custom callout annotation” on the map we will add the custom annotation when the mapView calls the mapView:didSelectAnnotationView: method, and we will remove the callout on the corresponding deselect method, mapView:didDeselectAnnotationView:. In mapView:viewForAnnotation: we return an instance of our custom MKAnnotationView subclass. Also, we disable the standard callout on the “parent” annotation view, which we will show the custom callout for.

[objc collapse="false" wraplines="false"] - (void)mapView:(MKMapView *)mapView didSelectAnnotationView:(MKAnnotationView *)view {
if (view.annotation == self.customAnnotation) {
if (self.calloutAnnotation == nil) {
self.calloutAnnotation = [[CalloutMapAnnotation alloc] initWithLatitude:view.annotation.coordinate.latitude
andLongitude:view.annotation.coordinate.longitude];
} else {
self.calloutAnnotation.latitude = view.annotation.coordinate.latitude;
self.calloutAnnotation.longitude = view.annotation.coordinate.longitude;
}
[self.mapView addAnnotation:self.calloutAnnotation];
self.selectedAnnotationView = view;
}
}

- (void)mapView:(MKMapView *)mapView didDeselectAnnotationView:(MKAnnotationView *)view {
if (self.calloutAnnotation && view.annotation == self.customAnnotation) {
[self.mapView removeAnnotation: self.calloutAnnotation];
}
}

- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id<MKAnnotation>)annotation {
if (annotation == self.calloutAnnotation) {
CalloutMapAnnotationView *calloutMapAnnotationView = (CalloutMapAnnotationView *)[self.mapView dequeueReusableAnnotationViewWithIdentifier:@"CalloutAnnotation"];
if (!calloutMapAnnotationView) {
calloutMapAnnotationView = [[[CalloutMapAnnotationView alloc] initWithAnnotation:annotation
reuseIdentifier:@"CalloutAnnotation"] autorelease];
}
calloutMapAnnotationView.parentAnnotationView = self.selectedAnnotationView;
calloutMapAnnotationView.mapView = self.mapView;
return calloutMapAnnotationView;
} else if (annotation == self.customAnnotation) {
MKPinAnnotationView *annotationView = [[[MKPinAnnotationView alloc] initWithAnnotation:annotation
reuseIdentifier:@"CustomAnnotation"] autorelease];
annotationView.canShowCallout = NO;
annotationView.pinColor = MKPinAnnotationColorGreen;
return annotationView;
} else if (annotation == self.normalAnnotation) {
MKPinAnnotationView *annotationView = [[[MKPinAnnotationView alloc] initWithAnnotation:annotation
reuseIdentifier:@"NormalAnnotation"] autorelease];
annotationView.canShowCallout = YES;
annotationView.pinColor = MKPinAnnotationColorPurple;
return annotationView;
}

return nil;
}
[/objc]

Note: If building for iOS 3.x you will need to determine annotation selection another way (KVO, notifications, etc.).

 

Draw the callout (in the right place)

Now that we have the callout annotation placed on the map at the same coordinate as the parent annotation, we need to adjust the width and height of the callout view and adjust the center offset so that the view spans the entire width of the map and sits above the parent annotation. These calculations will be done during setAnnotation: because our contentHeight, offsetFromParent, and mapView properties should have been set by then. setNeedsDisplay will also be called in setAnnotation: so that the callout is redrawn to match up with the annotation.

[objc collapse="false" wraplines="false"] - (void)setAnnotation:(id <MKAnnotation>)annotation {
[super setAnnotation:annotation];
[self prepareFrameSize];
[self prepareOffset];
[self setNeedsDisplay];
}

- (void)prepareFrameSize {
CGRect frame = self.frame;
CGFloat height = self.contentHeight +
CalloutMapAnnotationViewContentHeightBuffer +
CalloutMapAnnotationViewBottomShadowBufferSize -
self.offsetFromParent.y;

frame.size = CGSizeMake(self.mapView.frame.size.width, height);
self.frame = frame;
}

- (void)prepareOffset {
CGPoint parentOrigin = [self.mapView
convertPoint:self.parentAnnotationView.frame.origin
fromView:self.parentAnnotationView.superview];

CGFloat xOffset = (self.mapView.frame.size.width / 2) –
(parentOrigin.x + self.offsetFromParent.x);

//Add half our height plus half of the height of the annotation we are tied to so that our bottom lines up to its top
//Then take into account its offset and the extra space needed for our drop shadow
CGFloat yOffset = -(self.frame.size.height / 2 +
self.parentAnnotationView.frame.size.height / 2) +
self.offsetFromParent.y +
CalloutMapAnnotationViewBottomShadowBufferSize;

self.centerOffset = CGPointMake(xOffset, yOffset);
}
[/objc]

 

The shape of the callout bubble is basically a round-rectangle with a triangle that points to the parent annotation. Determining where that point should be is a matter of finding the x-coordinate of the parent relative to it and adding the offsetFromParent.x property. Luckily UIView contains the handy convertPoint:fromView: method to handle the conversion between coordinate systems.

The steps to draw something similar to the standard callout are as follows:

  • Create the shape (path) of the callout bubble with the point in the right position to match up with the parent
  • Fill the path and add the shadow (adding the shadow here and then restoring the context prevents the shadow from being redrawn with each subsequent step)
  • Apply a stroke to the path (more opaque than the fill)
  • Create a round rectangle path to appear as the “gloss”
  • Fill the gloss path with a gradient
  • Convert the glass path to a “stroked path” (this will allow us to apply a gradient to the stroke)
  • Apply a gradient (light to transparent) to the stroked path

In code:

[objc collapse="false" wraplines="false"] - (void)drawRect:(CGRect)rect {
CGFloat stroke = 1.0;
CGFloat radius = 7.0;
CGMutablePathRef path = CGPathCreateMutable();
UIColor *color;
CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB();
CGContextRef context = UIGraphicsGetCurrentContext();
CGFloat parentX = [self relativeParentXPosition];

//Determine Size
rect = self.bounds;
rect.size.width -= stroke + 14;
rect.size.height -= stroke + CalloutMapAnnotationViewHeightAboveParent – self.offsetFromParent.y + CalloutMapAnnotationViewBottomShadowBufferSize;
rect.origin.x += stroke / 2.0 + 7;
rect.origin.y += stroke / 2.0;

//Create Path For Callout Bubble
CGPathMoveToPoint(path, NULL, rect.origin.x, rect.origin.y + radius);
CGPathAddLineToPoint(path, NULL, rect.origin.x, rect.origin.y + rect.size.height – radius);
CGPathAddArc(path, NULL, rect.origin.x + radius, rect.origin.y + rect.size.height – radius,
radius, M_PI, M_PI / 2, 1);
CGPathAddLineToPoint(path, NULL, parentX – 15,
rect.origin.y + rect.size.height);
CGPathAddLineToPoint(path, NULL, parentX,
rect.origin.y + rect.size.height + 15);
CGPathAddLineToPoint(path, NULL, parentX + 15,
rect.origin.y + rect.size.height);
CGPathAddLineToPoint(path, NULL, rect.origin.x + rect.size.width – radius,
rect.origin.y + rect.size.height);
CGPathAddArc(path, NULL, rect.origin.x + rect.size.width – radius,
rect.origin.y + rect.size.height – radius, radius, M_PI / 2, 0.0f, 1);
CGPathAddLineToPoint(path, NULL, rect.origin.x + rect.size.width, rect.origin.y + radius);
CGPathAddArc(path, NULL, rect.origin.x + rect.size.width – radius, rect.origin.y + radius,
radius, 0.0f, -M_PI / 2, 1);
CGPathAddLineToPoint(path, NULL, rect.origin.x + radius, rect.origin.y);
CGPathAddArc(path, NULL, rect.origin.x + radius, rect.origin.y + radius, radius,
-M_PI / 2, M_PI, 1);
CGPathCloseSubpath(path);

//Fill Callout Bubble & Add Shadow
color = [[UIColor blackColor] colorWithAlphaComponent:.6];
[color setFill];
CGContextAddPath(context, path);
CGContextSaveGState(context);
CGContextSetShadowWithColor(context, CGSizeMake (0, self.yShadowOffset), 6, [UIColor colorWithWhite:0 alpha:.5].CGColor);
CGContextFillPath(context);
CGContextRestoreGState(context);

//Stroke Callout Bubble
color = [[UIColor darkGrayColor] colorWithAlphaComponent:.9];
[color setStroke];
CGContextSetLineWidth(context, stroke);
CGContextSetLineCap(context, kCGLineCapSquare);
CGContextAddPath(context, path);
CGContextStrokePath(context);

//Determine Size for Gloss
CGRect glossRect = self.bounds;
glossRect.size.width = rect.size.width – stroke;
glossRect.size.height = (rect.size.height – stroke) / 2;
glossRect.origin.x = rect.origin.x + stroke / 2;
glossRect.origin.y += rect.origin.y + stroke / 2;

CGFloat glossTopRadius = radius – stroke / 2;
CGFloat glossBottomRadius = radius / 1.5;

//Create Path For Gloss
CGMutablePathRef glossPath = CGPathCreateMutable();
CGPathMoveToPoint(glossPath, NULL, glossRect.origin.x, glossRect.origin.y + glossTopRadius);
CGPathAddLineToPoint(glossPath, NULL, glossRect.origin.x, glossRect.origin.y + glossRect.size.height – glossBottomRadius);
CGPathAddArc(glossPath, NULL, glossRect.origin.x + glossBottomRadius, glossRect.origin.y + glossRect.size.height – glossBottomRadius,
glossBottomRadius, M_PI, M_PI / 2, 1);
CGPathAddLineToPoint(glossPath, NULL, glossRect.origin.x + glossRect.size.width – glossBottomRadius,
glossRect.origin.y + glossRect.size.height);
CGPathAddArc(glossPath, NULL, glossRect.origin.x + glossRect.size.width – glossBottomRadius,
glossRect.origin.y + glossRect.size.height – glossBottomRadius, glossBottomRadius, M_PI / 2, 0.0f, 1);
CGPathAddLineToPoint(glossPath, NULL, glossRect.origin.x + glossRect.size.width, glossRect.origin.y + glossTopRadius);
CGPathAddArc(glossPath, NULL, glossRect.origin.x + glossRect.size.width – glossTopRadius, glossRect.origin.y + glossTopRadius,
glossTopRadius, 0.0f, -M_PI / 2, 1);
CGPathAddLineToPoint(glossPath, NULL, glossRect.origin.x + glossTopRadius, glossRect.origin.y);
CGPathAddArc(glossPath, NULL, glossRect.origin.x + glossTopRadius, glossRect.origin.y + glossTopRadius, glossTopRadius,
-M_PI / 2, M_PI, 1);
CGPathCloseSubpath(glossPath);

//Fill Gloss Path
CGContextAddPath(context, glossPath);
CGContextClip(context);
CGFloat colors[] =
{
1, 1, 1, .3,
1, 1, 1, .1,
};
CGFloat locations[] = { 0, 1.0 };
CGGradientRef gradient = CGGradientCreateWithColorComponents(space, colors, locations, 2);
CGPoint startPoint = glossRect.origin;
CGPoint endPoint = CGPointMake(glossRect.origin.x, glossRect.origin.y + glossRect.size.height);
CGContextDrawLinearGradient(context, gradient, startPoint, endPoint, 0);

//Gradient Stroke Gloss Path
CGContextAddPath(context, glossPath);
CGContextSetLineWidth(context, 2);
CGContextReplacePathWithStrokedPath(context);
CGContextClip(context);
CGFloat colors2[] =
{
1, 1, 1, .3,
1, 1, 1, .1,
1, 1, 1, .0,
};
CGFloat locations2[] = { 0, .1, 1.0 };
CGGradientRef gradient2 = CGGradientCreateWithColorComponents(space, colors2, locations2, 3);
CGPoint startPoint2 = glossRect.origin;
CGPoint endPoint2 = CGPointMake(glossRect.origin.x, glossRect.origin.y + glossRect.size.height);
CGContextDrawLinearGradient(context, gradient2, startPoint2, endPoint2, 0);

//Cleanup
CGPathRelease(path);
CGPathRelease(glossPath);
CGColorSpaceRelease(space);
CGGradientRelease(gradient);
CGGradientRelease(gradient2);
}

- (CGFloat)yShadowOffset {
if (!_yShadowOffset) {
float osVersion = [[[UIDevice currentDevice] systemVersion] floatValue];
if (osVersion >= 3.2) {
_yShadowOffset = 6;
} else {
_yShadowOffset = -6;
}

}
return _yShadowOffset;
}

- (CGFloat)relativeParentXPosition {
CGPoint parentOrigin = [self.mapView convertPoint:self.parentAnnotationView.frame.origin
fromView:self.parentAnnotationView.superview];
return parentOrigin.x + self.offsetFromParent.x;
}
[/objc]

Note: in iOS 3.2 CGContextSetShadowWithColor reversed the direction of the y-axis offset, thus requiring theyShadowOffset method seen above.

 

Let’s Add Some Content

To allow the addition of content we will create a content view as a read-only property, which will allow our consumers to access it. An additional method, prepareContentFrame will be added and invoked from setAnnotation: to set the content frame.

[objc collapse="false" wraplines="false"] - (void)setAnnotation:(id <MKAnnotation>)annotation {
[super setAnnotation:annotation];
[self prepareFrameSize];
[self prepareOffset];
[self prepareContentFrame];
[self setNeedsDisplay];
}

- (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;
}

- (UIView *)contentView {
if (!_contentView) {
_contentView = [[UIView alloc] init];
self.contentView.backgroundColor = [UIColor clearColor];
self.contentView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth;
[self addSubview:self.contentView];
}
return _contentView;
}
[/objc]

 

In our map view controller we will add the following code in mapView:viewForAnnotation to place an image in the callout and set the proper content height.

[objc collapse="false" wraplines="false"] calloutMapAnnotationView.contentHeight = 78.0f;
UIImage *asynchronyLogo = [UIImage imageNamed:@"asynchrony-logo-small.png"];
UIImageView *asynchronyLogoView = [[[UIImageView alloc] initWithImage:asynchronyLogo] autorelease];
asynchronyLogoView.frame = CGRectMake(5, 2, asynchronyLogoView.frame.size.width, asynchronyLogoView.frame.size.height);
[calloutMapAnnotationView.contentView addSubview:asynchronyLogoView];
[/objc]

 

Animation

So far the callout looks similar to the native callout, but it is still lacking some of the behavior of the original. The callout needs to animate out from the parent annotation. Also, when the parent annotation is near the edge of the map view, the map should be adjusted to move the parent annotation in from the edge of the view.

The animation would be fairly simple if we could just adjust the frame of the callout view, however that will not scale the contents of the callout. Thus, we must use a CGAffineTransform. Apple has a good introducton to affine transforms. The transform will need to both scale the view and translate the view to make it appear to grow out of the parent annotation. Scaling is simple – a value of 1 is normal size and other values act as a multiplier, so smaller values shrink the view and larger values expand the view. If the parent is off-center on the x-axis the callout needs to be translated to keep the point fixed directly over the parent annotation. Likewise the y-axis must be translated so that it appears that the callout grows upward from parent. We need to hold on to the frame for these calculations because self.frame cannot be trusted during the animations. The calculations are done in the following two methods:

[objc collapse="false" wraplines="false"] - (CGFloat)xTransformForScale:(CGFloat)scale {
CGFloat xDistanceFromCenterToParent = self.endFrame.size.width / 2 – [self relativeParentXPosition];
return (xDistanceFromCenterToParent * scale) – xDistanceFromCenterToParent;
}

- (CGFloat)yTransformForScale:(CGFloat)scale {
CGFloat yDistanceFromCenterToParent = (((self.endFrame.size.height) / 2) + self.offsetFromParent.y + CalloutMapAnnotationViewBottomShadowBufferSize + CalloutMapAnnotationViewHeightAboveParent);
return yDistanceFromCenterToParent – yDistanceFromCenterToParent * scale;
}
[/objc]

 

There will be three steps to the animation to create the bounce-like effect of the standard callout. We cannot begin the animation with a scale of 0 because a transformation matrix with a scale of 0 cannot be inverted.

  1. Grow from very small to slightly larger than the final size
  2. Shrink to slightly smaller than the final size
  3. Grow to the final size

These three steps will be separate animations chained together using UIView’s setAnimationDidStopSelector: and setAnimationDelegate: methods.

[objc collapse="false" wraplines="false"] - (void)animateIn {
self.endFrame = self.frame;
CGFloat scale = 0.001f;
self.transform = CGAffineTransformMake(scale, 0.0f, 0.0f, scale, [self xTransformForScale:scale], [self yTransformForScale:scale]);
[UIView beginAnimations:@"animateIn" context:nil];
[UIView setAnimationCurve:UIViewAnimationCurveEaseOut];
[UIView setAnimationDuration:0.075];
[UIView setAnimationDidStopSelector:@selector(animateInStepTwo)];
[UIView setAnimationDelegate:self];

scale = 1.1;
self.transform = CGAffineTransformMake(scale, 0.0f, 0.0f, scale, [self xTransformForScale:scale], [self yTransformForScale:scale]);

[UIView commitAnimations];
}

- (void)animateInStepTwo {
[UIView beginAnimations:@"animateInStepTwo" context:nil];
[UIView setAnimationCurve:UIViewAnimationCurveEaseInOut];
[UIView setAnimationDuration:0.1];
[UIView setAnimationDidStopSelector:@selector(animateInStepThree)];
[UIView setAnimationDelegate:self];

CGFloat scale = 0.95;
self.transform = CGAffineTransformMake(scale, 0.0f, 0.0f, scale, [self xTransformForScale:scale], [self yTransformForScale:scale]);

[UIView commitAnimations];
}

- (void)animateInStepThree {
[UIView beginAnimations:@"animateInStepThree" context:nil];
[UIView setAnimationCurve:UIViewAnimationCurveEaseInOut];
[UIView setAnimationDuration:0.075];

CGFloat scale = 1.0;
self.transform = CGAffineTransformMake(scale, 0.0f, 0.0f, scale, [self xTransformForScale:scale], [self yTransformForScale:scale]);

[UIView commitAnimations];
}
[/objc]

 

Shifting the Map

When the parent annotation is near the edge of the map, the map needs to be shifted so that the parent annotation and the callout remain a certain distance away from the edge of the view. To do this we need to calculate the distance to the edge of the view, the number of degrees latitude and longitude per pixel, and then set the new center point for the map. This adjustment should be made when didMoveToSuperview is called.

[objc collapse="false" wraplines="false"] - (void)adjustMapRegionIfNeeded {
//Longitude
CGFloat xPixelShift = 0;
if ([self relativeParentXPosition] < 38) {
xPixelShift = 38 – [self relativeParentXPosition];
} else if ([self relativeParentXPosition] > self.frame.size.width – 38) {
xPixelShift = (self.frame.size.width – 38) – [self relativeParentXPosition];
}

//Latitude
CGPoint mapViewOriginRelativeToParent = [self.mapView convertPoint:self.mapView.frame.origin toView:self.parentAnnotationView];
CGFloat yPixelShift = 0;
CGFloat pixelsFromTopOfMapView = -(mapViewOriginRelativeToParent.y + self.frame.size.height – CalloutMapAnnotationViewBottomShadowBufferSize);
CGFloat pixelsFromBottomOfMapView = self.mapView.frame.size.height + mapViewOriginRelativeToParent.y – self.parentAnnotationView.frame.size.height;
if (pixelsFromTopOfMapView < 7) {
yPixelShift = 7 – pixelsFromTopOfMapView;
} else if (pixelsFromBottomOfMapView < 10) {
yPixelShift = -(10 – pixelsFromBottomOfMapView);
}

//Calculate new center point, if needed
if (xPixelShift || yPixelShift) {
CGFloat pixelsPerDegreeLongitude = self.mapView.frame.size.width / self.mapView.region.span.longitudeDelta;
CGFloat pixelsPerDegreeLatitude = self.mapView.frame.size.height / self.mapView.region.span.latitudeDelta;

CLLocationDegrees longitudinalShift = -(xPixelShift / pixelsPerDegreeLongitude);
CLLocationDegrees latitudinalShift = yPixelShift / pixelsPerDegreeLatitude;

CLLocationCoordinate2D newCenterCoordinate = {self.mapView.region.center.latitude + latitudinalShift,
self.mapView.region.center.longitude + longitudinalShift};

[self.mapView setCenterCoordinate:newCenterCoordinate animated:YES];

//fix for now
self.frame = CGRectMake(self.frame.origin.x – xPixelShift,
self.frame.origin.y – yPixelShift,
self.frame.size.width,
self.frame.size.height);
//fix for later (after zoom or other action that resets the frame)
self.centerOffset = CGPointMake(self.centerOffset.x – xPixelShift, self.centerOffset.y);
}
}
[/objc]

 

Conclusion

It takes a bit of work to replicate the iOS map annotation callout, but it is worth the effort if you need a larger space for content. You may download the full source code to see a working example.

52 Comments

  1. Mike says:

    First, I’m amazed that no-one else has commented – this is extremely useful code, and I thank you for making it available.
    I’ve implemented it in a project that has multiple instances of the custom callout. It works fine in the 4.1 simulator, and works for the most part on a 3.1.3 iPhone.
    I appreciate your comment that getting select/deselect events in 3.x requires manual notifications. On the 3.1.3 device, I am not getting the accessory tap events – is that also something that needs a manual notification? Any ideas on how to do that?
    Thanks.
    -Mike

  2. Roy Remington says:

    I love this custom View. One Problem I want to make this much smaller so that it doesn’t cover the entire width of the Map View. Is there an easy way to do this? I tried changing values in your coding only to get some pretty bad results.

    The thing I’m looking to do is make it have a yellow background which I managed and add a Name and two buttons, which I have also done. The width is just big for my liking.

    Any help on reducing the width would be greatly appreciated! Thanks

  3. Roy Remington says:

    Oh, Never mind. Disregard my previous comment. I managed to figure it out. Had to change the X Origin and then had it automatically zoom into the annotation when it was selected to resolve the problem.

  4. Mike says:

    To Roy Remington, That’s exactly the problem I need to solve – could you post some code that shows how you changed the X origin and what you mean by “automatically zoom into the annotation”?
    Thanks!
    -Mike

  5. Xiaoyu Chen says:

    Your code is the best map callout solution I have ever seen on the net. thanks so much.

  6. Sun says:

    First of all, thank you very much for this great tutorial. I am new to iphone programming, and I would like to add multiple custom annotation view to my map.

    When I try to edit this tutorial to add multiple custom annotation view, only the last annotation become custom annotation and the rest of them become normal annotation.

    How can i add multiple custom annotation view?

  7. Mohamed Adel says:

    Thanks a lot for this great code.
    But I have a problem in it. How can I change the width of the callout?.
    I try to do that but I got a strange behavior (the callout moved from its position???)

    Any help would be appreciated :)

    Thanks in advance.
    Mohamed

  8. Martin says:

    Hi, this code has really put my project forward! Thank you so much.
    One problem I’ve encountered is that if I have several annotations, only the first annotations will display a Custom Callout. How do you do that so if the user clicks on a second annotation following the first one, the custom callout will also be displayed?

    Again, thank you so much for your help, this is awesome!

  9. Great mapping code by the way. The best I’ve seen on the net and in some of the books I have. I encountered a similar problem as Martin on how to have multiple annotations with the CustomCallout. Currently it only works for the first annotation and not the others. Any help would be appreciated. Thanks.

  10. Gavin says:

    Caution – didSelectAnnotationView is only available from iOS 4.0 onwards. Ideally there would be another method for selecting the annotation, probably by handling the touches in the view manually. Still researching…

  11. Jonathan says:

    Great work on this. Pretty much everyone who asks about this on Stack Overflow gets sent here.

    I have a question… why has Apple made this so hard to do?

    How about setCalloutHeight: 100… and you’re done?

  12. aArmaan says:

    Hi,
    Its really nice code.But I have one problem. I have added button in Calloutmap annotation view but button seems less clickable.Button click method not called. It’s called hardly when I keep pressing upto 2 or 3 seconds. Any help will be appreciated.
    Thnaks

  13. [...] 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. [...]

  14. Thanks for the code. Looks nice and the description is very thorough.
    I need to develop an annotation-like bubbles to use without MapKit. I think I will use your ideas for this as well as some of the ideas from replies in this post – http://stackoverflow.com/questions/1619245/using-mkannotationview-without-mkmapview

    Unfortunately the code provided by one of the repliers is not quite usable and doesn’t have a triangle below the view

  15. Daniel Phillips says:

    This is a good solution, but I can’t help but feel it’s an overkill for me unfortunately, I am only trying to replace the grey callout graphic which is drawn to my custom graphic I’ve made in Photoshop.

    I tried this with your code but had no success, I then tried just overriding drawRect of MKAnnotationView for myself, but no luck with the name and subtitle of the annotation.

    If you could suggest a way forward, all I really need to do is replace the graphic, maybe reduce the height slightly too… (to match my graphic)

  16. John Pope says:

    James,
    Can you please commit to github?

    I think you need these properties to be assign (weak references) not retain in CalloutMapAnnotationView.
    @property (nonatomic, retain) MKAnnotationView *parentAnnotationView;
    @property (nonatomic, retain) MKMapView *mapView;

  17. victor says:

    This code is fabulous, thank you James,

    But, did anyone managed to change the width of the callout?

    If y change the width in – (void)prepareFrameSize :

    frame.size = CGSizeMake(285, height);
    instead of:
    frame.size = CGSizeMake(self.mapView.frame.size.width, height);

    … the callout is placed wrong. I can solve this by changing in – (void)prepareOffset :

    CGFloat xOffset = 0;
    instead of:
    CGFloat xOffset = (self.mapView.frame.size.width / 2) –
    (parentOrigin.x + self.offsetFromParent.x);

    But then the triangle under the bubble places wrong.

    Can anyone help me.

  18. kocisky says:

    Great tutorial, works like a charm, tks!!!

    i was playing a little bit and i’ve tried with a moving annotation but there is a little issue, the callout doesn’t stick to the annotation, do you have any hints for implementing this behavior?

  19. Max says:

    I’m speechless at such an elegant solution. I was freaking out about wether I would be able to handle the code because it’s a little out of my league but it turns out implementation was a piece of cake. Still smoothing out some minor glitches but overall a great piece of code to work with. Thanks!

  20. Shawn says:

    Thanks, this helps a lot! A perplexing bug I have found is if you initially zoom in on the map, then pan the custom pin to the very edge and click on it — it will correctly animate away from the edge but the callout will not be shown. (If you then pan the map some more it will usually pop up).

  21. kevin says:

    This is fantastic, and exactly what I needed for a project I’m working on. The only difference between my project and this example is that CoreLocation updates are constantly changing the coordinates of the pin, so the pin annotation moves around. I can’t figure out how to get the callout annotation to “lock” to the pin, and move around with it onscreen. Any ideas? http://stackoverflow.com/q/6392931/607876

  22. Parag Dulam says:

    This is an awesome tutorial for custom callouts

  23. For those still developing for iOS3.1.3., you have to manually call [mapView:didDeselectAnnotationView] and [mapView:didSelectAnnotationView].

    Easiest is to add KVO to the “selected” property of the basic annotation view:

    [annotationView addObserver:self forKeyPath:@"selected" options:NSKeyValueObservingOptionNew context:@"AnnotationSelected"];

    then observe changes with:

    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{
    if([(NSString*)context isEqualToString:@"AnnotationSelected"]){
    BOOL isSelected = [[change valueForKey:@"new"] boolValue];
    if (isSelected) {
    if (self.selectedAnnotationView) {
    [self mapView:self.mapView didDeselectAnnotationView:self.selectedAnnotationView];
    }
    [self mapView:self.mapView didSelectAnnotationView:object];
    }
    }
    }

  24. S.Philip says:

    @James,

    Great work..
    Thank you very much for making it available.. :-)

  25. [...] I am wanting to do is to create a custom callout bubble in MKMapView, just as it is explained in http://blog.asolutions.com/2010/09/building-custom-map-annotation-callouts-part-1/, but it seems that there are some bugs in that otherwise nicely done application. For example, when [...]

  26. Dean says:

    Fantastic. Thank you for this.

  27. First of all I’d like to thank you James for you excellent solution. This is the only one I’ve seen that comes close to Apple’s own callout implementation.

    I’m using your solution in one of our projects and encountered the following bug: if you zoom using a pinch gesture and then select the annotation that is supposed to show custom callout map region gets adjusted properly, however callout is not displayed. This has something to do with map view removing CalloutMapAnnotationView from its superview after adjusting center coordinate (in adjustMapRegionIfNeeded). I’ve posted this question with more details in Apple devforums (https://devforums.apple.com/thread/114429?tstart=0) and Stack Overflow (http://stackoverflow.com/questions/6858569/custom-annotation-view-is-being-removed-from-its-superview-after-setting-map-view) but with no replies yet.

    Did you manage to work around that issue?

  28. Also I have a couple of questions regarding CalloutMapAnnotationView source code:

    - Is centerOffset ivar really not used anywhere during drawing the callout view? If it is, what was the purpose of it?

    - Why you’re updating callout view frame in adjustMapRegionIfNeeded? Isn’t it enough to just move map view’s center coordinate and have annotation views shifted with it?

    I’d be grateful if you could provide some thoughts on this.

  29. Benjamin says:

    Thanks for this great tutorial. I’m still unclear on how to have the callout’s width match the width of the contentView. Other people have seemed to have solved it, but with only their descriptions to go on I’m lost. Thanks in advance for any help you can offer with this.

  30. Tony says:

    How would I change the black bubble itself? To another color?

  31. Tony says:

    um, I worked that out.

    Is there a way to make the callout have a flexible width, according to content… so a single word would have a narrow bubble?

  32. Tony says:

    Did anyone have a solution for changing the width?

  33. Venkat says:

    Hi,

    Thanks for the tutorial. How do I get the accessory button for the text display callout instead of the picture display?

    Thanks,
    Venkat

  34. Jacob Jennings says:

    I created a project based on the drawRect code in this article which takes an interface builder-based approach to custom MKMapView callouts. The view for the callout is loaded from a xib, and the callout resizes based on the supplied view. The project is available here:

    http://www.megaupload.com/?d=PJQFKAD0

    I created this project in response to a question on Stack Overflow here:

    http://stackoverflow.com/questions/6392931/mkannotationview-lock-custom-annotation-view-to-pin-on-location-updates/7363862#7363862

  35. Alan Kennedy says:

    Trying this on the iOS 5.1 emulator i came across a small “bug”, which might not be related to the implementation itself but to the emulator, or god knows what.

    If the CalloutView is closed by taping ON IT, and nowhere else, the regionDidChangeAnimated on the corresponding map delegate, won’t get called anymore when panning.

    Nobody came across this?

  36. Steven Kuck says:

    This was immensely helpful in my understanding of iOS maps. However, I found an anomaly in line 12 of adjustMapRegionIfNecessary.

    CGPoint mapViewOriginRelativeToParent = [self.mapView convertPoint:self.mapView.frame.origin toView:self.parentAnnotationView];

    If the mapView is not at the top of the view that contains it, this line will double the offset from the top. Instead of “self.mapView.frame.origin” one ought to use a CGPoint of {0,0}.

    It’s translating an object into the mapView based on the offset of mapView from it’s parent.

  37. Bob says:

    Well this solution worked great for my application. I have multiple custom annotations, all displaying their rightful images/text/etc with callout buttons…and everything works beautifully….in iOS 5.1.

    I installed my application on an iPhone running iOS6 and this get very ugly. The map sometimes doesnt adjust when a callout is expanded, so the callout ends up 80% off the screen. Additionally, sometimes the arrow underneath the callout thats supposed to hover over the pin gets drawn at the edge of the screen nowhere near the actual pin (callout). The fact that this code works flawlessly on 5.1 leads me to believe that Apple has some done something on their end that is screwing this up. I’m on a mission though to pinpoint exactly where in the code this issue exists.

  38. Dragos says:

    I’ve been successfully using this solution in my own app for a while now, but in ios 6 with the new maps app it is broken, the triangle is no longer displaying properly on first map view display and as well some time after, the view ends up behind the pins and it also changes size and text ends up outside it. Do you have any idea what the issue is? I’ve been debugging and the weird thing is that the rect size changes if you click from pin to pin without clicking on map first.

  39. Adamthulla K says:

    The above customized annotation view is working fine in iOS versions prior to iOS 6. But in iOS 6 actually the annotation view is not getting displayed properly as the connector arrow is getting misplaced when we clicked first time and after that it is working fine. This issue is only with iOS 6.

    Any help regarding the same is very much helpful to me.

    Thanks in Advance.

  40. FS says:

    I’ve figured out how to change the width of the callout:

    In the prepareFrameSize method, where you have: frame.size = CGSizeMake(self.mapView.frame.size.width, height); you just replace self.mapView.frame.size.width with whatever width you want. Then, you go to the prepareOffset method and make the same replacement on the xOffset declaration. Hope this helps!

    And thanks for the code, it really helped me a lot!

  41. Luka says:

    Hi!

    I have the same problem with arrow being displaced when clicking on annotation near left/right edge of the screen. As observed before, this is happening on ios6 and not of ios5.

    Has any of you find any solution?

  42. Brian L says:

    I have managed to fix the iOS 6 bug where the triangle pointing to the pin is off set with the default set up.
    in the drawRect method, after setting the CGFloat parentX add:
    if (parentX self.frame.size.width – 70)
    parentX = self.frame.size.width – 70;

    And thanks for this guide.

  43. Brian L says:

    correction
    if (parentX self.frame.size.width – 70)
    parentX = self.frame.size.width – 70;

  44. Hi James. Thank a lot for this tutorial! I found it very useful in building custom map annotations. I compiled a list of top resource on map annotations with your post included. Hope its helpful for other developers. Check it out/ feel free to share.
    http://www.verious.com/board/Giancarlo-Leonio/map-annotation-for-ios

  45. lakshmi reddy says:

    Hi, thanks for providing good tutorial its helping a lot, i am facing one issue here in ios 6 the triangle in annotation is not properly placed at first time , can u please help me. its very urgent to me

    • james.rantanen says:

      I had a chance to look into the iOS 6 issue. There are two changes required for proper positioning. I plan to make these changes and transition the project to ARC and will then post updated code.

      In CustomMapAnnotationExampleViewController.m set the selected annotation view before adding the annotation to the map view.
      self.selectedAnnotationView = view;
      [self.mapView addAnnotation:self.calloutAnnotation];

      In CalloutMapAnnotationView.m you need to store the xPixelShift that was calculated in -adjustMapRegionIfNeeded and use it in -drawRect: to adjust the parentX value
      float osVersion = [[[UIDevice currentDevice] systemVersion] floatValue];
      if (osVersion >= 6.0) {
      parentX += self.xPixelShift;
      }

      • lakshmireddy says:

        Thanq james really great job, can you please provide me the code once you done, because am struck with where to write your code
        float osVersion = [[[UIDevice currentDevice] systemVersion] floatValue];
        if (osVersion >= 6.0) {
        parentX += self.xPixelShift;
        }

        am able to get triangle in proper place at first time by your code
        self.selectedAnnotationView = view;
        [self.mapView addAnnotation:self.calloutAnnotation];

        thanks……….

  46. lakshmireddy says:

    Hi, can you please provide me the code for multiple annotations thanks in advance

Leave a comment