1#import "SMCalloutView.h"
2
3//
4// UIView frame helpers - we do a lot of UIView frame fiddling in this class; these functions help keep things readable.
5//
6
7@interface UIView (SMFrameAdditions)
8@property (nonatomic, assign) CGPoint frameOrigin;
9@property (nonatomic, assign) CGSize frameSize;
10@property (nonatomic, assign) CGFloat frameX, frameY, frameWidth, frameHeight; // normal rect properties
11@property (nonatomic, assign) CGFloat frameLeft, frameTop, frameRight, frameBottom; // these will stretch/shrink the rect
12@end
13
14//
15// Callout View.
16//
17
18#define CALLOUT_DEFAULT_CONTAINER_HEIGHT 44 // height of just the main portion without arrow
19#define CALLOUT_SUB_DEFAULT_CONTAINER_HEIGHT 52 // height of just the main portion without arrow (when subtitle is present)
20#define CALLOUT_MIN_WIDTH 61 // minimum width of system callout
21#define TITLE_HMARGIN 12 // the title/subtitle view's normal horizontal margin from the edges of our callout view or from the accessories
22#define TITLE_TOP 11 // the top of the title view when no subtitle is present
23#define TITLE_SUB_TOP 4 // the top of the title view when a subtitle IS present
24#define TITLE_HEIGHT 21 // title height, fixed
25#define SUBTITLE_TOP 28 // the top of the subtitle, when present
26#define SUBTITLE_HEIGHT 15 // subtitle height, fixed
27#define BETWEEN_ACCESSORIES_MARGIN 7 // margin between accessories when no title/subtitle is present
28#define TOP_ANCHOR_MARGIN 13 // all the above measurements assume a bottom anchor! if we're pointing "up" we'll need to add this top margin to everything.
29#define COMFORTABLE_MARGIN 10 // when we try to reposition content to be visible, we'll consider this margin around your target rect
30
31NSTimeInterval const kSMCalloutViewRepositionDelayForUIScrollView = 1.0/3.0;
32
33@interface SMCalloutView ()
34@property (nonatomic, strong) UIButton *containerView; // for masking and interaction
35@property (nonatomic, strong) UILabel *titleLabel, *subtitleLabel;
36@property (nonatomic, assign) SMCalloutArrowDirection currentArrowDirection;
37@property (nonatomic, assign) BOOL popupCancelled;
38@end
39
40@implementation SMCalloutView
41
42+ (SMCalloutView *)platformCalloutView {
43
44    // if you haven't compiled SMClassicCalloutView into your app, then we can't possibly create an instance of it!
45    if (!NSClassFromString(@"SMClassicCalloutView"))
46        return [SMCalloutView new];
47
48    // ok we have both - so choose the best one based on current platform
49    if (floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_6_1)
50        return [SMCalloutView new]; // iOS 7+
51    else
52        return [NSClassFromString(@"SMClassicCalloutView") new];
53}
54
55- (id)initWithFrame:(CGRect)frame {
56    if (self = [super initWithFrame:frame]) {
57        self.permittedArrowDirection = SMCalloutArrowDirectionDown;
58        self.presentAnimation = SMCalloutAnimationBounce;
59        self.dismissAnimation = SMCalloutAnimationFade;
60        self.backgroundColor = [UIColor clearColor];
61        self.containerView = [UIButton new];
62        self.containerView.isAccessibilityElement = NO;
63        self.isAccessibilityElement = NO;
64        self.contentViewInset = UIEdgeInsetsMake(12, 12, 12, 12);
65
66        [self.containerView addTarget:self action:@selector(highlightIfNecessary) forControlEvents:UIControlEventTouchDown | UIControlEventTouchDragInside];
67        [self.containerView addTarget:self action:@selector(unhighlightIfNecessary) forControlEvents:UIControlEventTouchDragOutside | UIControlEventTouchCancel | UIControlEventTouchUpOutside | UIControlEventTouchUpInside];
68        [self.containerView addTarget:self action:@selector(calloutClicked) forControlEvents:UIControlEventTouchUpInside];
69    }
70    return self;
71}
72
73- (BOOL)supportsHighlighting {
74    if (![self.delegate respondsToSelector:@selector(calloutViewClicked:)])
75        return NO;
76    if ([self.delegate respondsToSelector:@selector(calloutViewShouldHighlight:)])
77        return [self.delegate calloutViewShouldHighlight:self];
78    return YES;
79}
80
81- (void)highlightIfNecessary { if (self.supportsHighlighting) self.backgroundView.highlighted = YES; }
82- (void)unhighlightIfNecessary { if (self.supportsHighlighting) self.backgroundView.highlighted = NO; }
83
84- (void)calloutClicked {
85    if ([self.delegate respondsToSelector:@selector(calloutViewClicked:)])
86        [self.delegate calloutViewClicked:self];
87}
88
89- (UIView *)titleViewOrDefault {
90    if (self.titleView)
91        // if you have a custom title view defined, return that.
92        return self.titleView;
93    else {
94        if (!self.titleLabel) {
95            // create a default titleView
96            self.titleLabel = [UILabel new];
97            self.titleLabel.frameHeight = TITLE_HEIGHT;
98            self.titleLabel.opaque = NO;
99            self.titleLabel.backgroundColor = [UIColor clearColor];
100            self.titleLabel.font = [UIFont systemFontOfSize:17];
101            self.titleLabel.textColor = [UIColor blackColor];
102        }
103        return self.titleLabel;
104    }
105}
106
107- (UIView *)subtitleViewOrDefault {
108    if (self.subtitleView)
109        // if you have a custom subtitle view defined, return that.
110        return self.subtitleView;
111    else {
112        if (!self.subtitleLabel) {
113            // create a default subtitleView
114            self.subtitleLabel = [UILabel new];
115            self.subtitleLabel.frameHeight = SUBTITLE_HEIGHT;
116            self.subtitleLabel.opaque = NO;
117            self.subtitleLabel.backgroundColor = [UIColor clearColor];
118            self.subtitleLabel.font = [UIFont systemFontOfSize:12];
119            self.subtitleLabel.textColor = [UIColor blackColor];
120        }
121        return self.subtitleLabel;
122    }
123}
124
125- (SMCalloutBackgroundView *)backgroundView {
126    // create our default background on first access only if it's nil, since you might have set your own background anyway.
127    return _backgroundView ? _backgroundView : (_backgroundView = [self defaultBackgroundView]);
128}
129
130- (SMCalloutBackgroundView *)defaultBackgroundView {
131    return [SMCalloutMaskedBackgroundView new];
132}
133
134- (void)rebuildSubviews {
135    // remove and re-add our appropriate subviews in the appropriate order
136    [self.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
137    [self.containerView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
138    [self setNeedsDisplay];
139
140    [self addSubview:self.backgroundView];
141    [self addSubview:self.containerView];
142
143    if (self.contentView) {
144        [self.containerView addSubview:self.contentView];
145    }
146    else {
147        if (self.titleViewOrDefault) [self.containerView addSubview:self.titleViewOrDefault];
148        if (self.subtitleViewOrDefault) [self.containerView addSubview:self.subtitleViewOrDefault];
149    }
150    if (self.leftAccessoryView) [self.containerView addSubview:self.leftAccessoryView];
151    if (self.rightAccessoryView) [self.containerView addSubview:self.rightAccessoryView];
152}
153
154// Accessory margins. Accessories are centered vertically when shorter
155// than the callout, otherwise they grow from the upper corner.
156
157- (CGFloat)leftAccessoryVerticalMargin {
158    if (self.leftAccessoryView.frameHeight < self.calloutContainerHeight)
159        return roundf((self.calloutContainerHeight - self.leftAccessoryView.frameHeight) / 2);
160    else
161        return 0;
162}
163
164- (CGFloat)leftAccessoryHorizontalMargin {
165    return fminf(self.leftAccessoryVerticalMargin, TITLE_HMARGIN);
166}
167
168- (CGFloat)rightAccessoryVerticalMargin {
169    if (self.rightAccessoryView.frameHeight < self.calloutContainerHeight)
170        return roundf((self.calloutContainerHeight - self.rightAccessoryView.frameHeight) / 2);
171    else
172        return 0;
173}
174
175- (CGFloat)rightAccessoryHorizontalMargin {
176    return fminf(self.rightAccessoryVerticalMargin, TITLE_HMARGIN);
177}
178
179- (CGFloat)innerContentMarginLeft {
180    if (self.leftAccessoryView)
181        return self.leftAccessoryHorizontalMargin + self.leftAccessoryView.frameWidth + TITLE_HMARGIN;
182    else
183        return self.contentViewInset.left;
184}
185
186- (CGFloat)innerContentMarginRight {
187    if (self.rightAccessoryView)
188        return self.rightAccessoryHorizontalMargin + self.rightAccessoryView.frameWidth + TITLE_HMARGIN;
189    else
190        return self.contentViewInset.right;
191}
192
193- (CGFloat)calloutHeight {
194    return self.calloutContainerHeight + self.backgroundView.anchorHeight;
195}
196
197- (CGFloat)calloutContainerHeight {
198    if (self.contentView)
199        return self.contentView.frameHeight + self.contentViewInset.bottom + self.contentViewInset.top;
200    else if (self.subtitleView || self.subtitle.length > 0)
201        return CALLOUT_SUB_DEFAULT_CONTAINER_HEIGHT;
202    else
203        return CALLOUT_DEFAULT_CONTAINER_HEIGHT;
204}
205
206- (CGSize)sizeThatFits:(CGSize)size {
207
208    // calculate how much non-negotiable space we need to reserve for margin and accessories
209    CGFloat margin = self.innerContentMarginLeft + self.innerContentMarginRight;
210
211    // how much room is left for text?
212    CGFloat availableWidthForText = size.width - margin - 1;
213
214    // no room for text? then we'll have to squeeze into the given size somehow.
215    if (availableWidthForText < 0)
216        availableWidthForText = 0;
217
218    CGSize preferredTitleSize = [self.titleViewOrDefault sizeThatFits:CGSizeMake(availableWidthForText, TITLE_HEIGHT)];
219    CGSize preferredSubtitleSize = [self.subtitleViewOrDefault sizeThatFits:CGSizeMake(availableWidthForText, SUBTITLE_HEIGHT)];
220
221    // total width we'd like
222    CGFloat preferredWidth;
223
224    if (self.contentView) {
225
226        // if we have a content view, then take our preferred size directly from that
227        preferredWidth = self.contentView.frameWidth + margin;
228    }
229    else if (preferredTitleSize.width >= 0.000001 || preferredSubtitleSize.width >= 0.000001) {
230
231        // if we have a title or subtitle, then our assumed margins are valid, and we can apply them
232        preferredWidth = fmaxf(preferredTitleSize.width, preferredSubtitleSize.width) + margin;
233    }
234    else {
235        // ok we have no title or subtitle to speak of. In this case, the system callout would actually not display
236        // at all! But we can handle it.
237        preferredWidth = self.leftAccessoryView.frameWidth + self.rightAccessoryView.frameWidth + self.leftAccessoryHorizontalMargin + self.rightAccessoryHorizontalMargin;
238
239        if (self.leftAccessoryView && self.rightAccessoryView)
240            preferredWidth += BETWEEN_ACCESSORIES_MARGIN;
241    }
242
243    // ensure we're big enough to fit our graphics!
244    preferredWidth = fmaxf(preferredWidth, CALLOUT_MIN_WIDTH);
245
246    // ask to be smaller if we have space, otherwise we'll fit into what we have by truncating the title/subtitle.
247    return CGSizeMake(fminf(preferredWidth, size.width), self.calloutHeight);
248}
249
250- (CGSize)offsetToContainRect:(CGRect)innerRect inRect:(CGRect)outerRect {
251    CGFloat nudgeRight = fmaxf(0, CGRectGetMinX(outerRect) - CGRectGetMinX(innerRect));
252    CGFloat nudgeLeft = fminf(0, CGRectGetMaxX(outerRect) - CGRectGetMaxX(innerRect));
253    CGFloat nudgeTop = fmaxf(0, CGRectGetMinY(outerRect) - CGRectGetMinY(innerRect));
254    CGFloat nudgeBottom = fminf(0, CGRectGetMaxY(outerRect) - CGRectGetMaxY(innerRect));
255    return CGSizeMake(nudgeLeft ? nudgeLeft : nudgeRight, nudgeTop ? nudgeTop : nudgeBottom);
256}
257
258- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated {
259    [self presentCalloutFromRect:rect inLayer:view.layer ofView:view constrainedToLayer:constrainedView.layer animated:animated];
260}
261
262- (void)presentCalloutFromRect:(CGRect)rect inLayer:(CALayer *)layer constrainedToLayer:(CALayer *)constrainedLayer animated:(BOOL)animated {
263    [self presentCalloutFromRect:rect inLayer:layer ofView:nil constrainedToLayer:constrainedLayer animated:animated];
264}
265
266// this private method handles both CALayer and UIView parents depending on what's passed.
267- (void)presentCalloutFromRect:(CGRect)rect inLayer:(CALayer *)layer ofView:(UIView *)view constrainedToLayer:(CALayer *)constrainedLayer animated:(BOOL)animated {
268
269    // Sanity check: dismiss this callout immediately if it's displayed somewhere
270    if (self.layer.superlayer) [self dismissCalloutAnimated:NO];
271
272    // cancel any presenting animation that may be in progress
273    [self.layer removeAnimationForKey:@"present"];
274
275    // figure out the constrained view's rect in our popup view's coordinate system
276    CGRect constrainedRect = [constrainedLayer convertRect:constrainedLayer.bounds toLayer:layer];
277
278    // apply our edge constraints
279    constrainedRect = UIEdgeInsetsInsetRect(constrainedRect, self.constrainedInsets);
280
281    constrainedRect = CGRectInset(constrainedRect, COMFORTABLE_MARGIN, COMFORTABLE_MARGIN);
282
283    // form our subviews based on our content set so far
284    [self rebuildSubviews];
285
286    // apply title/subtitle (if present
287    self.titleLabel.text = self.title;
288    self.subtitleLabel.text = self.subtitle;
289
290    // size the callout to fit the width constraint as best as possible
291    self.frameSize = [self sizeThatFits:CGSizeMake(constrainedRect.size.width, self.calloutHeight)];
292
293    // how much room do we have in the constraint box, both above and below our target rect?
294    CGFloat topSpace = CGRectGetMinY(rect) - CGRectGetMinY(constrainedRect);
295    CGFloat bottomSpace = CGRectGetMaxY(constrainedRect) - CGRectGetMaxY(rect);
296
297    // we prefer to point our arrow down.
298    SMCalloutArrowDirection bestDirection = SMCalloutArrowDirectionDown;
299
300    // we'll point it up though if that's the only option you gave us.
301    if (self.permittedArrowDirection == SMCalloutArrowDirectionUp)
302        bestDirection = SMCalloutArrowDirectionUp;
303
304    // or, if we don't have enough space on the top and have more space on the bottom, and you
305    // gave us a choice, then pointing up is the better option.
306    if (self.permittedArrowDirection == SMCalloutArrowDirectionAny && topSpace < self.calloutHeight && bottomSpace > topSpace)
307        bestDirection = SMCalloutArrowDirectionUp;
308
309    self.currentArrowDirection = bestDirection;
310
311    // we want to point directly at the horizontal center of the given rect. calculate our "anchor point" in terms of our
312    // target view's coordinate system. make sure to offset the anchor point as requested if necessary.
313    CGFloat anchorX = self.calloutOffset.x + CGRectGetMidX(rect);
314    CGFloat anchorY = self.calloutOffset.y + (bestDirection == SMCalloutArrowDirectionDown ? CGRectGetMinY(rect) : CGRectGetMaxY(rect));
315
316    // we prefer to sit centered directly above our anchor
317    CGFloat calloutX = roundf(anchorX - self.frameWidth / 2);
318
319    // but not if it's going to get too close to the edge of our constraints
320    if (calloutX < constrainedRect.origin.x)
321        calloutX = constrainedRect.origin.x;
322
323    if (calloutX > constrainedRect.origin.x+constrainedRect.size.width-self.frameWidth)
324        calloutX = constrainedRect.origin.x+constrainedRect.size.width-self.frameWidth;
325
326    // what's the farthest to the left and right that we could point to, given our background image constraints?
327    CGFloat minPointX = calloutX + self.backgroundView.anchorMargin;
328    CGFloat maxPointX = calloutX + self.frameWidth - self.backgroundView.anchorMargin;
329
330    // we may need to scoot over to the left or right to point at the correct spot
331    CGFloat adjustX = 0;
332    if (anchorX < minPointX) adjustX = anchorX - minPointX;
333    if (anchorX > maxPointX) adjustX = anchorX - maxPointX;
334
335    // add the callout to the given layer (or view if possible, to receive touch events)
336    if (view)
337        [view addSubview:self];
338    else
339        [layer addSublayer:self.layer];
340
341    CGPoint calloutOrigin = {
342            .x = calloutX + adjustX,
343            .y = bestDirection == SMCalloutArrowDirectionDown ? (anchorY - self.calloutHeight) : anchorY
344    };
345
346    self.frameOrigin = calloutOrigin;
347
348    // now set the *actual* anchor point for our layer so that our "popup" animation starts from this point.
349    CGPoint anchorPoint = [layer convertPoint:CGPointMake(anchorX, anchorY) toLayer:self.layer];
350
351    // pass on the anchor point to our background view so it knows where to draw the arrow
352    self.backgroundView.arrowPoint = anchorPoint;
353
354    // adjust it to unit coordinates for the actual layer.anchorPoint property
355    anchorPoint.x /= self.frameWidth;
356    anchorPoint.y /= self.frameHeight;
357    self.layer.anchorPoint = anchorPoint;
358
359    // setting the anchor point moves the view a bit, so we need to reset
360    self.frameOrigin = calloutOrigin;
361
362    // make sure our frame is not on half-pixels or else we may be blurry!
363    CGFloat scale = [UIScreen mainScreen].scale;
364    self.frameX = floorf(self.frameX*scale)/scale;
365    self.frameY = floorf(self.frameY*scale)/scale;
366
367    // layout now so we can immediately start animating to the final position if needed
368    [self setNeedsLayout];
369    [self layoutIfNeeded];
370
371    // if we're outside the bounds of our constraint rect, we'll give our delegate an opportunity to shift us into position.
372    // consider both our size and the size of our target rect (which we'll assume to be the size of the content you want to scroll into view.
373    CGRect contentRect = CGRectUnion(self.frame, rect);
374    CGSize offset = [self offsetToContainRect:contentRect inRect:constrainedRect];
375
376    NSTimeInterval delay = 0;
377    self.popupCancelled = NO; // reset this before calling our delegate below
378
379    if ([self.delegate respondsToSelector:@selector(calloutView:delayForRepositionWithSize:)] && !CGSizeEqualToSize(offset, CGSizeZero))
380        delay = [self.delegate calloutView:(id)self delayForRepositionWithSize:offset];
381
382    // there's a chance that user code in the delegate method may have called -dismissCalloutAnimated to cancel things; if that
383    // happened then we need to bail!
384    if (self.popupCancelled) return;
385
386    // now we want to mask our contents to our background view (if requested) to match the iOS 7 style
387    self.containerView.layer.mask = self.backgroundView.contentMask;
388
389    // if we need to delay, we don't want to be visible while we're delaying, so hide us in preparation for our popup
390    self.hidden = YES;
391
392    // create the appropriate animation, even if we're not animated
393    CAAnimation *animation = [self animationWithType:self.presentAnimation presenting:YES];
394
395    // nuke the duration if no animation requested - we'll still need to "run" the animation to get delays and callbacks
396    if (!animated)
397        animation.duration = 0.0000001; // can't be zero or the animation won't "run"
398
399    animation.beginTime = CACurrentMediaTime() + delay;
400    animation.delegate = self;
401
402    [self.layer addAnimation:animation forKey:@"present"];
403}
404
405- (void)animationDidStart:(CAAnimation *)anim {
406    BOOL presenting = [[anim valueForKey:@"presenting"] boolValue];
407
408    if (presenting) {
409        if ([_delegate respondsToSelector:@selector(calloutViewWillAppear:)])
410            [_delegate calloutViewWillAppear:(id)self];
411
412        // ok, animation is on, let's make ourselves visible!
413        self.hidden = NO;
414    }
415    else if (!presenting) {
416        if ([_delegate respondsToSelector:@selector(calloutViewWillDisappear:)])
417            [_delegate calloutViewWillDisappear:(id)self];
418    }
419}
420
421- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)finished {
422    BOOL presenting = [[anim valueForKey:@"presenting"] boolValue];
423
424    if (presenting && finished) {
425        if ([_delegate respondsToSelector:@selector(calloutViewDidAppear:)])
426            [_delegate calloutViewDidAppear:(id)self];
427    }
428    else if (!presenting && finished) {
429
430        [self removeFromParent];
431        [self.layer removeAnimationForKey:@"dismiss"];
432
433        if ([_delegate respondsToSelector:@selector(calloutViewDidDisappear:)])
434            [_delegate calloutViewDidDisappear:(id)self];
435    }
436}
437
438- (void)dismissCalloutAnimated:(BOOL)animated {
439
440    // cancel all animations that may be in progress
441    [self.layer removeAnimationForKey:@"present"];
442    [self.layer removeAnimationForKey:@"dismiss"];
443
444    self.popupCancelled = YES;
445
446    if (animated) {
447        CAAnimation *animation = [self animationWithType:self.dismissAnimation presenting:NO];
448        animation.delegate = self;
449        [self.layer addAnimation:animation forKey:@"dismiss"];
450    }
451    else {
452        [self removeFromParent];
453    }
454}
455
456- (void)removeFromParent {
457    if (self.superview)
458        [self removeFromSuperview];
459    else {
460        // removing a layer from a superlayer causes an implicit fade-out animation that we wish to disable.
461        [CATransaction begin];
462        [CATransaction setDisableActions:YES];
463        [self.layer removeFromSuperlayer];
464        [CATransaction commit];
465    }
466}
467
468- (CAAnimation *)animationWithType:(SMCalloutAnimation)type presenting:(BOOL)presenting {
469    CAAnimation *animation = nil;
470
471    if (type == SMCalloutAnimationBounce) {
472
473        CABasicAnimation *fade = [CABasicAnimation animationWithKeyPath:@"opacity"];
474        fade.duration = 0.23;
475        fade.fromValue = presenting ? @0.0 : @1.0;
476        fade.toValue = presenting ? @1.0 : @0.0;
477        fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
478
479        CABasicAnimation *bounce = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
480        bounce.duration = 0.23;
481        bounce.fromValue = presenting ? @0.7 : @1.0;
482        bounce.toValue = presenting ? @1.0 : @0.7;
483        bounce.timingFunction = [CAMediaTimingFunction functionWithControlPoints:0.59367:0.12066:0.18878:1.5814];
484
485        CAAnimationGroup *group = [CAAnimationGroup animation];
486        group.animations = @[fade, bounce];
487        group.duration = 0.23;
488
489        animation = group;
490    }
491    else if (type == SMCalloutAnimationFade) {
492        CABasicAnimation *fade = [CABasicAnimation animationWithKeyPath:@"opacity"];
493        fade.duration = 1.0/3.0;
494        fade.fromValue = presenting ? @0.0 : @1.0;
495        fade.toValue = presenting ? @1.0 : @0.0;
496        animation = fade;
497    }
498    else if (type == SMCalloutAnimationStretch) {
499        CABasicAnimation *stretch = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
500        stretch.duration = 0.1;
501        stretch.fromValue = presenting ? @0.0 : @1.0;
502        stretch.toValue = presenting ? @1.0 : @0.0;
503        animation = stretch;
504    }
505
506    // CAAnimation is KVC compliant, so we can store whether we're presenting for lookup in our delegate methods
507    [animation setValue:@(presenting) forKey:@"presenting"];
508
509    animation.fillMode = kCAFillModeForwards;
510    animation.removedOnCompletion = NO;
511    return animation;
512}
513
514- (void)layoutSubviews {
515
516    self.containerView.frame = self.bounds;
517    self.backgroundView.frame = self.bounds;
518
519    // if we're pointing up, we'll need to push almost everything down a bit
520    CGFloat dy = self.currentArrowDirection == SMCalloutArrowDirectionUp ? TOP_ANCHOR_MARGIN : 0;
521
522    self.titleViewOrDefault.frameX = self.innerContentMarginLeft;
523    self.titleViewOrDefault.frameY = (self.subtitleView || self.subtitle.length ? TITLE_SUB_TOP : TITLE_TOP) + dy;
524    self.titleViewOrDefault.frameWidth = self.frameWidth - self.innerContentMarginLeft - self.innerContentMarginRight;
525
526    self.subtitleViewOrDefault.frameX = self.titleViewOrDefault.frameX;
527    self.subtitleViewOrDefault.frameY = SUBTITLE_TOP + dy;
528    self.subtitleViewOrDefault.frameWidth = self.titleViewOrDefault.frameWidth;
529
530    self.leftAccessoryView.frameX = self.leftAccessoryHorizontalMargin;
531    self.leftAccessoryView.frameY = self.leftAccessoryVerticalMargin + dy;
532
533    self.rightAccessoryView.frameX = self.frameWidth - self.rightAccessoryHorizontalMargin - self.rightAccessoryView.frameWidth;
534    self.rightAccessoryView.frameY = self.rightAccessoryVerticalMargin + dy;
535
536    if (self.contentView) {
537        self.contentView.frameX = self.innerContentMarginLeft;
538        self.contentView.frameY = self.contentViewInset.top + dy;
539    }
540}
541
542#pragma mark - Accessibility
543
544- (NSInteger)accessibilityElementCount {
545    return (!!self.leftAccessoryView + !!self.titleViewOrDefault +
546            !!self.subtitleViewOrDefault + !!self.rightAccessoryView);
547}
548
549- (id)accessibilityElementAtIndex:(NSInteger)index {
550    if (index == 0) {
551        return self.leftAccessoryView ? self.leftAccessoryView : self.titleViewOrDefault;
552    }
553    if (index == 1) {
554        return self.leftAccessoryView ? self.titleViewOrDefault : self.subtitleViewOrDefault;
555    }
556    if (index == 2) {
557        return self.leftAccessoryView ? self.subtitleViewOrDefault : self.rightAccessoryView;
558    }
559    if (index == 3) {
560        return self.leftAccessoryView ? self.rightAccessoryView : nil;
561    }
562    return nil;
563}
564
565- (NSInteger)indexOfAccessibilityElement:(id)element {
566    if (element == nil) return NSNotFound;
567    if (element == self.leftAccessoryView) return 0;
568    if (element == self.titleViewOrDefault) {
569        return self.leftAccessoryView ? 1 : 0;
570    }
571    if (element == self.subtitleViewOrDefault) {
572        return self.leftAccessoryView ? 2 : 1;
573    }
574    if (element == self.rightAccessoryView) {
575        return self.leftAccessoryView ? 3 : 2;
576    }
577    return NSNotFound;
578}
579
580@end
581
582// import this known "private API" from SMCalloutBackgroundView
583@interface SMCalloutBackgroundView (EmbeddedImages)
584+ (UIImage *)embeddedImageNamed:(NSString *)name;
585@end
586
587//
588// Callout Background View.
589//
590
591@interface SMCalloutMaskedBackgroundView ()
592@property (nonatomic, strong) UIView *containerView, *containerBorderView, *arrowView;
593@property (nonatomic, strong) UIImageView *arrowImageView, *arrowHighlightedImageView, *arrowBorderView;
594@end
595
596static UIImage *blackArrowImage = nil, *whiteArrowImage = nil, *grayArrowImage = nil;
597
598@implementation SMCalloutMaskedBackgroundView
599
600- (id)initWithFrame:(CGRect)frame {
601    if (self = [super initWithFrame:frame]) {
602
603        // Here we're mimicking the very particular (and odd) structure of the system callout view.
604        // The hierarchy and view/layer values were discovered by inspecting map kit using Reveal.app
605
606        self.containerView = [UIView new];
607        self.containerView.backgroundColor = [UIColor whiteColor];
608        self.containerView.alpha = 0.96;
609        self.containerView.layer.cornerRadius = 8;
610        self.containerView.layer.shadowRadius = 30;
611        self.containerView.layer.shadowOpacity = 0.1;
612
613        self.containerBorderView = [UIView new];
614        self.containerBorderView.layer.borderColor = [UIColor colorWithWhite:0 alpha:0.1].CGColor;
615        self.containerBorderView.layer.borderWidth = 0.5;
616        self.containerBorderView.layer.cornerRadius = 8.5;
617
618        if (!blackArrowImage) {
619            blackArrowImage = [SMCalloutBackgroundView embeddedImageNamed:@"CalloutArrow"];
620            whiteArrowImage = [self image:blackArrowImage withColor:[UIColor whiteColor]];
621            grayArrowImage = [self image:blackArrowImage withColor:[UIColor colorWithWhite:0.85 alpha:1]];
622        }
623
624        self.anchorHeight = 13;
625        self.anchorMargin = 27;
626
627        self.arrowView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, blackArrowImage.size.width, blackArrowImage.size.height)];
628        self.arrowView.alpha = 0.96;
629        self.arrowImageView = [[UIImageView alloc] initWithImage:whiteArrowImage];
630        self.arrowHighlightedImageView = [[UIImageView alloc] initWithImage:grayArrowImage];
631        self.arrowHighlightedImageView.hidden = YES;
632        self.arrowBorderView = [[UIImageView alloc] initWithImage:blackArrowImage];
633        self.arrowBorderView.alpha = 0.1;
634        self.arrowBorderView.frameY = 0.5;
635
636        [self addSubview:self.containerView];
637        [self.containerView addSubview:self.containerBorderView];
638        [self addSubview:self.arrowView];
639        [self.arrowView addSubview:self.arrowBorderView];
640        [self.arrowView addSubview:self.arrowImageView];
641        [self.arrowView addSubview:self.arrowHighlightedImageView];
642    }
643    return self;
644}
645
646// Make sure we relayout our images when our arrow point changes!
647- (void)setArrowPoint:(CGPoint)arrowPoint {
648    [super setArrowPoint:arrowPoint];
649    [self setNeedsLayout];
650}
651
652- (void)setHighlighted:(BOOL)highlighted {
653    [super setHighlighted:highlighted];
654    self.containerView.backgroundColor = highlighted ? [UIColor colorWithWhite:0.85 alpha:1] : [UIColor whiteColor];
655    self.arrowImageView.hidden = highlighted;
656    self.arrowHighlightedImageView.hidden = !highlighted;
657}
658
659- (UIImage *)image:(UIImage *)image withColor:(UIColor *)color {
660
661    UIGraphicsBeginImageContextWithOptions(image.size, NO, 0);
662    CGRect imageRect = (CGRect){.size=image.size};
663    CGContextRef c = UIGraphicsGetCurrentContext();
664    CGContextTranslateCTM(c, 0, image.size.height);
665    CGContextScaleCTM(c, 1, -1);
666    CGContextClipToMask(c, imageRect, image.CGImage);
667    [color setFill];
668    CGContextFillRect(c, imageRect);
669    UIImage *whiteImage = UIGraphicsGetImageFromCurrentImageContext();
670    UIGraphicsEndImageContext();
671    return whiteImage;
672}
673
674- (void)layoutSubviews {
675
676    BOOL pointingUp = self.arrowPoint.y < self.frameHeight/2;
677
678    // if we're pointing up, we'll need to push almost everything down a bit
679    CGFloat dy = pointingUp ? TOP_ANCHOR_MARGIN : 0;
680
681    self.containerView.frame = CGRectMake(0, dy, self.frameWidth, self.frameHeight - self.arrowView.frameHeight + 0.5);
682    self.containerBorderView.frame = CGRectInset(self.containerView.bounds, -0.5, -0.5);
683
684    self.arrowView.frameX = roundf(self.arrowPoint.x - self.arrowView.frameWidth / 2);
685
686    if (pointingUp) {
687        self.arrowView.frameY = 1;
688        self.arrowView.transform = CGAffineTransformMakeRotation(M_PI);
689    }
690    else {
691        self.arrowView.frameY = self.containerView.frameHeight - 0.5;
692        self.arrowView.transform = CGAffineTransformIdentity;
693    }
694}
695
696- (CALayer *)contentMask {
697
698    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, 0);
699
700    [self.layer renderInContext:UIGraphicsGetCurrentContext()];
701
702    UIImage *maskImage = UIGraphicsGetImageFromCurrentImageContext();
703    UIGraphicsEndImageContext();
704
705    CALayer *layer = [CALayer layer];
706    layer.frame = self.bounds;
707    layer.contents = (id)maskImage.CGImage;
708    return layer;
709}
710
711@end
712
713@implementation SMCalloutBackgroundView
714
715+ (NSData *)dataWithBase64EncodedString:(NSString *)string {
716    //
717    //  NSData+Base64.m
718    //
719    //  Version 1.0.2
720    //
721    //  Created by Nick Lockwood on 12/01/2012.
722    //  Copyright (C) 2012 Charcoal Design
723    //
724    //  Distributed under the permissive zlib License
725    //  Get the latest version from here:
726    //
727    //  https://github.com/nicklockwood/Base64
728    //
729    //  This software is provided 'as-is', without any express or implied
730    //  warranty.  In no event will the authors be held liable for any damages
731    //  arising from the use of this software.
732    //
733    //  Permission is granted to anyone to use this software for any purpose,
734    //  including commercial applications, and to alter it and redistribute it
735    //  freely, subject to the following restrictions:
736    //
737    //  1. The origin of this software must not be misrepresented; you must not
738    //  claim that you wrote the original software. If you use this software
739    //  in a product, an acknowledgment in the product documentation would be
740    //  appreciated but is not required.
741    //
742    //  2. Altered source versions must be plainly marked as such, and must not be
743    //  misrepresented as being the original software.
744    //
745    //  3. This notice may not be removed or altered from any source distribution.
746    //
747    const char lookup[] = {
748            99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99,
749            99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99,
750            99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 62, 99, 99, 99, 63,
751            52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 99, 99, 99, 99, 99, 99,
752            99,  0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14,
753            15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 99, 99, 99, 99, 99,
754            99, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
755            41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 99, 99, 99, 99, 99
756    };
757
758    NSData *inputData = [string dataUsingEncoding:NSASCIIStringEncoding allowLossyConversion:YES];
759    long long inputLength = [inputData length];
760    const unsigned char *inputBytes = [inputData bytes];
761
762    long long maxOutputLength = (inputLength / 4 + 1) * 3;
763    NSMutableData *outputData = [NSMutableData dataWithLength:(NSUInteger)maxOutputLength];
764    unsigned char *outputBytes = (unsigned char *)[outputData mutableBytes];
765
766    int accumulator = 0;
767    long long outputLength = 0;
768    unsigned char accumulated[] = {0, 0, 0, 0};
769    for (long long i = 0; i < inputLength; i++) {
770        unsigned char decoded = lookup[inputBytes[i] & 0x7F];
771        if (decoded != 99) {
772            accumulated[accumulator] = decoded;
773            if (accumulator == 3) {
774                outputBytes[outputLength++] = (accumulated[0] << 2) | (accumulated[1] >> 4);
775                outputBytes[outputLength++] = (accumulated[1] << 4) | (accumulated[2] >> 2);
776                outputBytes[outputLength++] = (accumulated[2] << 6) | accumulated[3];
777            }
778            accumulator = (accumulator + 1) % 4;
779        }
780    }
781
782    //handle left-over data
783    if (accumulator > 0) outputBytes[outputLength] = (accumulated[0] << 2) | (accumulated[1] >> 4);
784    if (accumulator > 1) outputBytes[++outputLength] = (accumulated[1] << 4) | (accumulated[2] >> 2);
785    if (accumulator > 2) outputLength++;
786
787    //truncate data to match actual output length
788    outputData.length = (NSUInteger)outputLength;
789    return outputLength? outputData: nil;
790}
791
792+ (UIImage *)embeddedImageNamed:(NSString *)name {
793    CGFloat screenScale = [UIScreen mainScreen].scale;
794    if (screenScale > 1.0) {
795        name = [name stringByAppendingString:@"_2x"];
796        screenScale = 2.0;
797    }
798
799    SEL selector = NSSelectorFromString(name);
800
801    if (![(id)self respondsToSelector:selector]) {
802        NSLog(@"Could not find an embedded image. Ensure that you've added a class-level method named +%@", name);
803        return nil;
804    }
805
806    // We need to hush the compiler here - but we know what we're doing!
807#pragma clang diagnostic push
808#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
809    NSString *base64String = [(id)self performSelector:selector];
810#pragma clang diagnostic pop
811
812    UIImage *rawImage = [UIImage imageWithData:[self dataWithBase64EncodedString:base64String]];
813    return [UIImage imageWithCGImage:rawImage.CGImage scale:screenScale orientation:UIImageOrientationUp];
814}
815
816+ (NSString *)CalloutArrow { return @"iVBORw0KGgoAAAANSUhEUgAAACcAAAANCAYAAAAqlHdlAAAAHGlET1QAAAACAAAAAAAAAAcAAAAoAAAABwAAAAYAAADJEgYpIwAAAJVJREFUOBFiYIAAdn5+fkFOTkE5Dg5eW05O3lJOTr6zQPyfDhhoD28pxF5BOZA7gE5ih7oLN8XJyR8MdNwrGjkQaC5/MG7biZDh4OBXBDruLpUdeBdkLhHWE1bCzs6nAnTcUyo58DnIPMK2kqAC6DALIP5JoQNB+i1IsJZ4pcBEm0iJ40D6ibeNDJVAx00k04ETSbUOAAAA//+SwicfAAAAe0lEQVRjYCAdMHNy8u7l5OT7Tzzm3Qu0hpl0q8jQwcPDIwp02B0iHXeHl5dXhAxryNfCzc2tC3TcJwIO/ARSR74tFOjk4uL1BzruHw4H/gPJU2A85Vq5uPjTgY77g+bAPyBxyk2nggkcHPxOnJz8B4AOfAGiQXwqGMsAACGK1kPPMHNBAAAAAElFTkSuQmCC"; }
817
818+ (NSString *)CalloutArrow_2x { return @"iVBORw0KGgoAAAANSUhEUgAAAE4AAAAaCAYAAAAZtWr8AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAHGlET1QAAAACAAAAAAAAAA0AAAAoAAAADQAAAA0AAAFMRh0LGwAAARhJREFUWAnclbENwjAQRZ0mih2fDYgsQEVDxQZMgKjpWYAJkBANI8AGDIEoM0WkzBDRAf8klB44g0OkU1zE3/+9RIpS7VVY730/y/woTWlsjJ9iPcN9pbXfY85auyvm/qcDNmb0e2Z+sk/ZBTthN0oVttX12mJIWeaWEFf+kbySmZQa0msu3nzaGJprTXV3BVLNDG/if7bNOTeAvFP35NGJu39GL7Abb27bFXncVQBZLgJf3jp+ebSWIxZMgrxdvPJoJ4gqHpXgV36ITR46HUGaiNMKB6YQd4lI3gV8qTBjmDhrbQFxVQTyKu4ShjJQap7nE4hrfiiv4Q6B8MLGat1bQNztB/JwZm8Rli5wujFu821xfGZgLPUAAAD//4wvm4gAAAD7SURBVOWXMQ6CMBiFgaFpi6VyBEedXJy4hMQTeBSvRDgJEySegI3EQWOivkZnqUB/k0LyL7R9L++D9G+DwP0TCZGUqCdRlYgUuY9F4JCmqQa0hgBcY7wIItFZMLZYS5l0ruAZbXhs6BIROgmhcoB7OIAHTZUTRqG3wp9xmhqc0aRPQu8YAlwxIbwCEUL6GH9wfDcLXY2HpyvvmkHf9+BcrwCuHQGvNRp9Pl6OY0PPAO42AB7WqMxLKLahpFR7gLv/AA9zPe+gtvAMCIC7WMC7CqEPtrqzmBfHyy3A1V/g1Th27GYBY0BIxrk6Ap65254/VZp30GID9JwteQEZrVMWXqGn8gAAAABJRU5ErkJggg=="; }
819
820@end
821
822//
823// Our UIView frame helpers implementation
824//
825
826@implementation UIView (SMFrameAdditions)
827
828- (CGPoint)frameOrigin { return self.frame.origin; }
829- (void)setFrameOrigin:(CGPoint)origin { self.frame = (CGRect){ .origin=origin, .size=self.frame.size }; }
830
831- (CGFloat)frameX { return self.frame.origin.x; }
832- (void)setFrameX:(CGFloat)x { self.frame = (CGRect){ .origin.x=x, .origin.y=self.frame.origin.y, .size=self.frame.size }; }
833
834- (CGFloat)frameY { return self.frame.origin.y; }
835- (void)setFrameY:(CGFloat)y { self.frame = (CGRect){ .origin.x=self.frame.origin.x, .origin.y=y, .size=self.frame.size }; }
836
837- (CGSize)frameSize { return self.frame.size; }
838- (void)setFrameSize:(CGSize)size { self.frame = (CGRect){ .origin=self.frame.origin, .size=size }; }
839
840- (CGFloat)frameWidth { return self.frame.size.width; }
841- (void)setFrameWidth:(CGFloat)width { self.frame = (CGRect){ .origin=self.frame.origin, .size.width=width, .size.height=self.frame.size.height }; }
842
843- (CGFloat)frameHeight { return self.frame.size.height; }
844- (void)setFrameHeight:(CGFloat)height { self.frame = (CGRect){ .origin=self.frame.origin, .size.width=self.frame.size.width, .size.height=height }; }
845
846- (CGFloat)frameLeft { return self.frame.origin.x; }
847- (void)setFrameLeft:(CGFloat)left { self.frame = (CGRect){ .origin.x=left, .origin.y=self.frame.origin.y, .size.width=fmaxf(self.frame.origin.x+self.frame.size.width-left,0), .size.height=self.frame.size.height }; }
848
849- (CGFloat)frameTop { return self.frame.origin.y; }
850- (void)setFrameTop:(CGFloat)top { self.frame = (CGRect){ .origin.x=self.frame.origin.x, .origin.y=top, .size.width=self.frame.size.width, .size.height=fmaxf(self.frame.origin.y+self.frame.size.height-top,0) }; }
851
852- (CGFloat)frameRight { return self.frame.origin.x + self.frame.size.width; }
853- (void)setFrameRight:(CGFloat)right { self.frame = (CGRect){ .origin=self.frame.origin, .size.width=fmaxf(right-self.frame.origin.x,0), .size.height=self.frame.size.height }; }
854
855- (CGFloat)frameBottom { return self.frame.origin.y + self.frame.size.height; }
856- (void)setFrameBottom:(CGFloat)bottom { self.frame = (CGRect){ .origin=self.frame.origin, .size.width=self.frame.size.width, .size.height=fmaxf(bottom-self.frame.origin.y,0) }; }
857
858@end