Wednesday, March 9, 2011

Building Custom Map Annotation Callouts – Part 1

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.



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.

?
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
- (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)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;
}
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.

?
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
- (void)setAnnotation:(id )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);
}


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:

?
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
- (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;
}
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.

?
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (void)setAnnotation:(id )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;
}


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.

?
1
2
3
4
5
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];


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:

?
1
2
3
4
5
6
7
8
9
- (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;
}


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.

Grow from very small to slightly larger than the final size
Shrink to slightly smaller than the final size
Grow to the final size
These three steps will be separate animations chained together using UIView’s setAnimationDidStopSelector: and setAnimationDelegate: methods.

?
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
- (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];
}


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.


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


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.