Skip to content

Instantly share code, notes, and snippets.

@JoeMatt
Last active February 21, 2023 16:57
Show Gist options
  • Save JoeMatt/f560690b34ecc6d1575e928b4849df47 to your computer and use it in GitHub Desktop.
Save JoeMatt/f560690b34ecc6d1575e928b4849df47 to your computer and use it in GitHub Desktop.
ChatGPT conversion of ObjC to Swift
#if __has_include(<UIKit/UIKit.h>)
#import <UIKit/UIKit.h>
#import <QuartzCore/QuartzCore.h>
/*
SMCalloutView
-------------
Created by Nick Farina ([email protected])
Version 2.1.5
*/
/// options for which directions the callout is allowed to "point" in.
typedef NS_OPTIONS(NSUInteger, SMCalloutArrowDirection) {
SMCalloutArrowDirectionUp = 1 << 0,
SMCalloutArrowDirectionDown = 1 << 1,
SMCalloutArrowDirectionAny = SMCalloutArrowDirectionUp | SMCalloutArrowDirectionDown
};
/// options for the callout present/dismiss animation
typedef NS_ENUM(NSInteger, SMCalloutAnimation) {
/// the "bounce" animation we all know and love from @c UIAlertView
SMCalloutAnimationBounce,
/// a simple fade in or out
SMCalloutAnimationFade,
/// grow or shrink linearly, like in the iPad Calendar app
SMCalloutAnimationStretch
};
NS_ASSUME_NONNULL_BEGIN
/// when delaying our popup in order to scroll content into view, you can use this amount to match the
/// animation duration of UIScrollView when using @c -setContentOffset:animated.
extern NSTimeInterval const kSMCalloutViewRepositionDelayForUIScrollView;
@protocol SMCalloutViewDelegate;
@class SMCalloutBackgroundView;
//
// Callout view.
//
// iOS 10+ expects CAAnimationDelegate to be set explicitly.
#if __IPHONE_OS_VERSION_MAX_ALLOWED < 100000
@interface SMCalloutView : UIView
#else
@interface SMCalloutView : UIView <CAAnimationDelegate>
#endif
@property (nonatomic, weak, nullable) id<SMCalloutViewDelegate> delegate;
/// title/titleView relationship mimics UINavigationBar.
@property (nonatomic, copy, nullable) NSString *title;
@property (nonatomic, copy, nullable) NSString *subtitle;
/// Left accessory view for the call out
@property (nonatomic, strong, nullable) UIView *leftAccessoryView;
/// Right accessoty view for the call out
@property (nonatomic, strong, nullable) UIView *rightAccessoryView;
/// Default @c SMCalloutArrowDirectionDown
@property (nonatomic, assign) SMCalloutArrowDirection permittedArrowDirection;
/// The current arrow direction
@property (nonatomic, readonly) SMCalloutArrowDirection currentArrowDirection;
/// if the @c UIView you're constraining to has portions that are overlapped by nav bar, tab bar, etc. you'll need to tell us those insets.
@property (nonatomic, assign) UIEdgeInsets constrainedInsets;
/// default is @c SMCalloutMaskedBackgroundView, or @c SMCalloutDrawnBackgroundView when using @c SMClassicCalloutView
@property (nonatomic, strong) SMCalloutBackgroundView *backgroundView;
/**
@brief Custom title view.
@disucssion Keep in mind that @c SMCalloutView calls @c -sizeThatFits on titleView/subtitleView if defined, so your view
may be resized as a result of that (especially if you're using @c UILabel/UITextField). You may want to subclass and override @c -sizeThatFits, or just wrap your view in a "generic" @c UIView if you do not want it to be auto-sized.
@warning If this is set, the respective @c title property will be ignored.
*/
@property (nonatomic, strong, nullable) UIView *titleView;
/**
@brief Custom subtitle view.
@discussion Keep in mind that @c SMCalloutView calls @c -sizeThatFits on subtitleView if defined, so your view
may be resized as a result of that (especially if you're using @c UILabel/UITextField). You may want to subclass and override @c -sizeThatFits, or just wrap your view in a "generic" @c UIView if you do not want it to be auto-sized.
@warning If this is set, the respective @c subtitle property will be ignored.
*/
@property (nonatomic, strong, nullable) UIView *subtitleView;
/// Custom "content" view that can be any width/height. If this is set, title/subtitle/titleView/subtitleView are all ignored.
@property (nonatomic, retain, nullable) UIView *contentView;
/// Custom content view margin
@property (nonatomic, assign) UIEdgeInsets contentViewInset;
/// calloutOffset is the offset in screen points from the top-middle of the target view, where the anchor of the callout should be shown.
@property (nonatomic, assign) CGPoint calloutOffset;
/// default SMCalloutAnimationBounce, SMCalloutAnimationFade respectively
@property (nonatomic, assign) SMCalloutAnimation presentAnimation, dismissAnimation;
/// Returns a new instance of SMCalloutView if running on iOS 7 or better, otherwise a new instance of SMClassicCalloutView if available.
+ (SMCalloutView *)platformCalloutView;
/**
@brief Presents a callout view by adding it to "inView" and pointing at the given rect of inView's bounds.
@discussion Constrains the callout to the bounds of the given view. Optionally scrolls the given rect into view (plus margins)
if @c -delegate is set and responds to @c -delayForRepositionWithSize.
@param rect @c CGRect to present the view from
@param view view to 'constrain' the @c constrainedView to
@param constrainedView @c UIView to be constrainted in @c view
@param animated @c BOOL if presentation should be animated
*/
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated;
/**
@brief Present a callout layer in the `layer` and pointing at the given rect of the `layer` bounds
@discussion Same as the view-based presentation, but inserts the callout into a CALayer hierarchy instead.
@note Be aware that you'll have to direct your own touches to any accessory views, since CALayer doesn't relay touch events.
@param rect @c CGRect to present the view from
@param layer layer to 'constrain' the @c constrainedLayer to
@param constrainedLayer @c CALayer to be constrained in @c layer
@param animated @c BOOL if presentation should be animated
*/
- (void)presentCalloutFromRect:(CGRect)rect inLayer:(CALayer *)layer constrainedToLayer:(CALayer *)constrainedLayer animated:(BOOL)animated;
/**
Dismiss the callout view
@param animated @c BOOL if dismissal should be animated
*/
- (void)dismissCalloutAnimated:(BOOL)animated;
/// For subclassers. You can override this method to provide your own custom animation for presenting/dismissing the callout.
- (CAAnimation *)animationWithType:(SMCalloutAnimation)type presenting:(BOOL)presenting;
@end
//
// Background view - default draws the iOS 7 system background style (translucent white with rounded arrow).
//
/// Abstract base class
@interface SMCalloutBackgroundView : UIView
/// indicates where the tip of the arrow should be drawn, as a pixel offset
@property (nonatomic, assign) CGPoint arrowPoint;
/// will be set by the callout when the callout is in a highlighted state
@property (nonatomic, assign) BOOL highlighted;
/// returns an optional layer whose contents should mask the callout view's contents (not honored by @c SMClassicCalloutView )
@property (nonatomic, assign) CALayer *contentMask;
/// height of the callout "arrow"
@property (nonatomic, assign) CGFloat anchorHeight;
/// the smallest possible distance from the edge of our control to the "tip" of the anchor, from either left or right
@property (nonatomic, assign) CGFloat anchorMargin;
@end
/// Default for iOS 7, this reproduces the "masked" behavior of the iOS 7-style callout view.
/// Accessories are masked by the shape of the callout (including the arrow itself).
@interface SMCalloutMaskedBackgroundView : SMCalloutBackgroundView
@end
//
// Delegate methods
//
@protocol SMCalloutViewDelegate <NSObject>
@optional
/// Controls whether the callout "highlights" when pressed. default YES. You must also respond to @c -calloutViewClicked below.
/// Not honored by @c SMClassicCalloutView.
- (BOOL)calloutViewShouldHighlight:(SMCalloutView *)calloutView;
/// Called when the callout view is clicked. Not honored by @c SMClassicCalloutView.
- (void)calloutViewClicked:(SMCalloutView *)calloutView;
/**
Called when the callout view detects that it will be outside the constrained view when it appears,
or if the target rect was already outside the constrained view. You can implement this selector
to respond to this situation by repositioning your content first in order to make everything visible.
The @c CGSize passed is the calculated offset necessary to make everything visible (plus a nice margin).
It expects you to return the amount of time you need to reposition things so the popup can be delayed.
Typically you would return @c kSMCalloutViewRepositionDelayForUIScrollView if you're repositioning by calling @c [UIScrollView @c setContentOffset:animated:].
@param calloutView the @c SMCalloutView to reposition
@param offset caluclated offset necessary to make everything visible
@returns @c NSTimeInterval to delay the repositioning
*/
- (NSTimeInterval)calloutView:(SMCalloutView *)calloutView delayForRepositionWithSize:(CGSize)offset;
/// Called before the callout view appears on screen, or before the appearance animation will start.
- (void)calloutViewWillAppear:(SMCalloutView *)calloutView;
/// Called after the callout view appears on screen, or after the appearance animation is complete.
- (void)calloutViewDidAppear:(SMCalloutView *)calloutView;
/// Called before the callout view is removed from the screen, or before the disappearance animation is complete.
- (void)calloutViewWillDisappear:(SMCalloutView *)calloutView;
/// Called after the callout view is removed from the screen, or after the disappearance animation is complete.
- (void)calloutViewDidDisappear:(SMCalloutView *)calloutView;
NS_ASSUME_NONNULL_END
@end
#endif // UIKit
#if __has_include(<UIKit/UIKit.h>)
#import "SMCalloutView.h"
//
// UIView frame helpers - we do a lot of UIView frame fiddling in this class; these functions help keep things readable.
//
@interface UIView (SMFrameAdditions)
@property (nonatomic, assign) CGPoint frameOrigin;
@property (nonatomic, assign) CGSize frameSize;
@property (nonatomic, assign) CGFloat frameX, frameY, frameWidth, frameHeight; // normal rect properties
@property (nonatomic, assign) CGFloat frameLeft, frameTop, frameRight, frameBottom; // these will stretch/shrink the rect
@end
//
// Callout View.
//
#define CALLOUT_DEFAULT_CONTAINER_HEIGHT 44 // height of just the main portion without arrow
#define CALLOUT_SUB_DEFAULT_CONTAINER_HEIGHT 52 // height of just the main portion without arrow (when subtitle is present)
#define CALLOUT_MIN_WIDTH 61 // minimum width of system callout
#define TITLE_HMARGIN 12 // the title/subtitle view's normal horizontal margin from the edges of our callout view or from the accessories
#define TITLE_TOP 11 // the top of the title view when no subtitle is present
#define TITLE_SUB_TOP 4 // the top of the title view when a subtitle IS present
#define TITLE_HEIGHT 21 // title height, fixed
#define SUBTITLE_TOP 28 // the top of the subtitle, when present
#define SUBTITLE_HEIGHT 15 // subtitle height, fixed
#define BETWEEN_ACCESSORIES_MARGIN 7 // margin between accessories when no title/subtitle is present
#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.
#define COMFORTABLE_MARGIN 10 // when we try to reposition content to be visible, we'll consider this margin around your target rect
NSTimeInterval const kSMCalloutViewRepositionDelayForUIScrollView = 1.0/3.0;
@interface SMCalloutView ()
@property (nonatomic, strong) UIButton *containerView; // for masking and interaction
@property (nonatomic, strong) UILabel *titleLabel, *subtitleLabel;
@property (nonatomic, assign) SMCalloutArrowDirection currentArrowDirection;
@property (nonatomic, assign) BOOL popupCancelled;
@end
@implementation SMCalloutView
+ (SMCalloutView *)platformCalloutView {
// if you haven't compiled SMClassicCalloutView into your app, then we can't possibly create an instance of it!
if (!NSClassFromString(@"SMClassicCalloutView"))
return [SMCalloutView new];
// ok we have both - so choose the best one based on current platform
if (floor(NSFoundationVersionNumber) > NSFoundationVersionNumber_iOS_6_1)
return [SMCalloutView new]; // iOS 7+
else
return [NSClassFromString(@"SMClassicCalloutView") new];
}
- (id)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.permittedArrowDirection = SMCalloutArrowDirectionDown;
self.presentAnimation = SMCalloutAnimationBounce;
self.dismissAnimation = SMCalloutAnimationFade;
self.backgroundColor = [UIColor clearColor];
self.containerView = [UIButton new];
self.containerView.isAccessibilityElement = NO;
self.isAccessibilityElement = NO;
self.contentViewInset = UIEdgeInsetsMake(12, 12, 12, 12);
[self.containerView addTarget:self action:@selector(highlightIfNecessary) forControlEvents:UIControlEventTouchDown | UIControlEventTouchDragInside];
[self.containerView addTarget:self action:@selector(unhighlightIfNecessary) forControlEvents:UIControlEventTouchDragOutside | UIControlEventTouchCancel | UIControlEventTouchUpOutside | UIControlEventTouchUpInside];
[self.containerView addTarget:self action:@selector(calloutClicked) forControlEvents:UIControlEventTouchUpInside];
}
return self;
}
- (BOOL)supportsHighlighting {
if (![self.delegate respondsToSelector:@selector(calloutViewClicked:)])
return NO;
if ([self.delegate respondsToSelector:@selector(calloutViewShouldHighlight:)])
return [self.delegate calloutViewShouldHighlight:self];
return YES;
}
- (void)highlightIfNecessary { if (self.supportsHighlighting) self.backgroundView.highlighted = YES; }
- (void)unhighlightIfNecessary { if (self.supportsHighlighting) self.backgroundView.highlighted = NO; }
- (void)calloutClicked {
if ([self.delegate respondsToSelector:@selector(calloutViewClicked:)])
[self.delegate calloutViewClicked:self];
}
- (UIView *)titleViewOrDefault {
if (self.titleView)
// if you have a custom title view defined, return that.
return self.titleView;
else {
if (!self.titleLabel) {
// create a default titleView
self.titleLabel = [UILabel new];
self.titleLabel.frameHeight = TITLE_HEIGHT;
self.titleLabel.opaque = NO;
self.titleLabel.backgroundColor = [UIColor clearColor];
self.titleLabel.font = [UIFont systemFontOfSize:17];
self.titleLabel.textColor = [UIColor blackColor];
}
return self.titleLabel;
}
}
- (UIView *)subtitleViewOrDefault {
if (self.subtitleView)
// if you have a custom subtitle view defined, return that.
return self.subtitleView;
else {
if (!self.subtitleLabel) {
// create a default subtitleView
self.subtitleLabel = [UILabel new];
self.subtitleLabel.frameHeight = SUBTITLE_HEIGHT;
self.subtitleLabel.opaque = NO;
self.subtitleLabel.backgroundColor = [UIColor clearColor];
self.subtitleLabel.font = [UIFont systemFontOfSize:12];
self.subtitleLabel.textColor = [UIColor blackColor];
}
return self.subtitleLabel;
}
}
- (SMCalloutBackgroundView *)backgroundView {
// create our default background on first access only if it's nil, since you might have set your own background anyway.
return _backgroundView ? _backgroundView : (_backgroundView = [self defaultBackgroundView]);
}
- (SMCalloutBackgroundView *)defaultBackgroundView {
return [SMCalloutMaskedBackgroundView new];
}
- (void)rebuildSubviews {
// remove and re-add our appropriate subviews in the appropriate order
[self.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
[self.containerView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
[self setNeedsDisplay];
[self addSubview:self.backgroundView];
[self addSubview:self.containerView];
if (self.contentView) {
[self.containerView addSubview:self.contentView];
}
else {
if (self.titleViewOrDefault) [self.containerView addSubview:self.titleViewOrDefault];
if (self.subtitleViewOrDefault) [self.containerView addSubview:self.subtitleViewOrDefault];
}
if (self.leftAccessoryView) [self.containerView addSubview:self.leftAccessoryView];
if (self.rightAccessoryView) [self.containerView addSubview:self.rightAccessoryView];
}
// Accessory margins. Accessories are centered vertically when shorter
// than the callout, otherwise they grow from the upper corner.
- (CGFloat)leftAccessoryVerticalMargin {
if (self.leftAccessoryView.frameHeight < self.calloutContainerHeight)
return roundf((self.calloutContainerHeight - self.leftAccessoryView.frameHeight) / 2);
else
return 0;
}
'leftAccessoryVerticalMargin' 'leftAccessoryHorizontalMargin' 'rightAccessoryVerticalMargin' 'rightAccessoryHorizontalMargin' 'innerContentMarginLeft' 'innerContentMarginRight' 'calloutContainerHeight' 'calloutHeight'
- (CGFloat)leftAccessoryHorizontalMargin {
return fminf(self.leftAccessoryVerticalMargin, TITLE_HMARGIN);
}
- (CGFloat)rightAccessoryVerticalMargin {
if (self.rightAccessoryView.frameHeight < self.calloutContainerHeight)
return roundf((self.calloutContainerHeight - self.rightAccessoryView.frameHeight) / 2);
else
return 0;
}
- (CGFloat)rightAccessoryHorizontalMargin {
return fminf(self.rightAccessoryVerticalMargin, TITLE_HMARGIN);
}
- (CGFloat)innerContentMarginLeft {
if (self.leftAccessoryView)
return self.leftAccessoryHorizontalMargin + self.leftAccessoryView.frameWidth + TITLE_HMARGIN;
else
return self.contentViewInset.left;
}
- (CGFloat)innerContentMarginRight {
if (self.rightAccessoryView)
return self.rightAccessoryHorizontalMargin + self.rightAccessoryView.frameWidth + TITLE_HMARGIN;
else
return self.contentViewInset.right;
}
- (CGFloat)calloutHeight {
return self.calloutContainerHeight + self.backgroundView.anchorHeight;
}
- (CGFloat)calloutContainerHeight {
if (self.contentView)
return self.contentView.frameHeight + self.contentViewInset.bottom + self.contentViewInset.top;
else if (self.subtitleView || self.subtitle.length > 0)
return CALLOUT_SUB_DEFAULT_CONTAINER_HEIGHT;
else
return CALLOUT_DEFAULT_CONTAINER_HEIGHT;
}
- (CGSize)sizeThatFits:(CGSize)size {
// calculate how much non-negotiable space we need to reserve for margin and accessories
CGFloat margin = self.innerContentMarginLeft + self.innerContentMarginRight;
// how much room is left for text?
CGFloat availableWidthForText = size.width - margin - 1;
// no room for text? then we'll have to squeeze into the given size somehow.
if (availableWidthForText < 0)
availableWidthForText = 0;
CGSize preferredTitleSize = [self.titleViewOrDefault sizeThatFits:CGSizeMake(availableWidthForText, TITLE_HEIGHT)];
CGSize preferredSubtitleSize = [self.subtitleViewOrDefault sizeThatFits:CGSizeMake(availableWidthForText, SUBTITLE_HEIGHT)];
// total width we'd like
CGFloat preferredWidth;
if (self.contentView) {
// if we have a content view, then take our preferred size directly from that
preferredWidth = self.contentView.frameWidth + margin;
}
else if (preferredTitleSize.width >= 0.000001 || preferredSubtitleSize.width >= 0.000001) {
// if we have a title or subtitle, then our assumed margins are valid, and we can apply them
preferredWidth = fmaxf(preferredTitleSize.width, preferredSubtitleSize.width) + margin;
}
else {
// ok we have no title or subtitle to speak of. In this case, the system callout would actually not display
// at all! But we can handle it.
preferredWidth = self.leftAccessoryView.frameWidth + self.rightAccessoryView.frameWidth + self.leftAccessoryHorizontalMargin + self.rightAccessoryHorizontalMargin;
if (self.leftAccessoryView && self.rightAccessoryView)
preferredWidth += BETWEEN_ACCESSORIES_MplatformCalloutViewARGIN;
}
// ensure we're big enough to fit our graphics!
preferredWidth = fmaxf(preferredWidth, CALLOUT_MIN_WIDTH);
// ask to be smaller if we have space, otherwise we'll fit into what we have by truncating the title/subtitle.
return CGSizeMake(fminf(preferredWidth, size.width), self.calloutHeight);
}
- (CGSize)offsetToContainRect:(CGRect)innerRect inRect:(CGRect)outerRect {
CGFloat nudgeRight = fmaxf(0, CGRectGetMinX(outerRect) - CGRectGetMinX(innerRect));
CGFloat nudgeLeft = fminf(0, CGRectGetMaxX(outerRect) - CGRectGetMaxX(innerRect));
CGFloat nudgeTop = fmaxf(0, CGRectGetMinY(outerRect) - CGRectGetMinY(innerRect));
CGFloat nudgeBottom = fminf(0, CGRectGetMaxY(outerRect) - CGRectGetMaxY(innerRect));
return CGSizeMake(nudgeLeft ? nudgeLeft : nudgeRight, nudgeTop ? nudgeTop : nudgeBottom);
}
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated {
[self presentCalloutFromRect:rect inLayer:view.layer ofView:view constrainedToLayer:constrainedView.layer animated:animated];
}
- (void)presentCalloutFromRect:(CGRect)rect inLayer:(CALayer *)layer constrainedToLayer:(CALayer *)constrainedLayer animated:(BOOL)animated {
[self presentCalloutFromRect:rect inLayer:layer ofView:nil constrainedToLayer:constrainedLayer animated:animated];
}
// this private method handles both CALayer and UIView parents depending on what's passed.
- (void)presentCalloutFromRect:(CGRect)rect inLayer:(CALayer *)layer ofView:(UIView *)view constrainedToLayer:(CALayer *)constrainedLayer animated:(BOOL)animated {
// Sanity check: dismiss this callout immediately if it's displayed somewhere
if (self.layer.superlayer) [self dismissCalloutAnimated:NO];
// cancel all animations that may be in progress
[self.layer removeAnimationForKey:@"present"];
[self.layer removeAnimationForKey:@"dismiss"];
// figure out the constrained view's rect in our popup view's coordinate system
CGRect constrainedRect = [constrainedLayer convertRect:constrainedLayer.bounds toLayer:layer];
// apply our edge constraints
constrainedRect = UIEdgeInsetsInsetRect(constrainedRect, self.constrainedInsets);
constrainedRect = CGRectInset(constrainedRect, COMFORTABLE_MARGIN, COMFORTABLE_MARGIN);
// form our subviews based on our content set so far
[self rebuildSubviews];
// apply title/subtitle (if present
self.titleLabel.text = self.title;
self.subtitleLabel.text = self.subtitle;
// size the callout to fit the width constraint as best as possible
self.frameSize = [self sizeThatFits:CGSizeMake(constrainedRect.size.width, self.calloutHeight)];
// how much room do we have in the constraint box, both above and below our target rect?
CGFloat topSpace = CGRectGetMinY(rect) - CGRectGetMinY(constrainedRect);
CGFloat bottomSpace = CGRectGetMaxY(constrainedRect) - CGRectGetMaxY(rect);
// we prefer to point our arrow down.
SMCalloutArrowDirection bestDirection = SMCalloutArrowDirectionDown;
// we'll point it up though if that's the only option you gave us.
if (self.permittedArrowDirection == SMCalloutArrowDirectionUp)
bestDirection = SMCalloutArrowDirectionUp;
// or, if we don't have enough space on the top and have more space on the bottom, and you
// gave us a choice, then pointing up is the better option.
if (self.permittedArrowDirection == SMCalloutArrowDirectionAny && topSpace < self.calloutHeight && bottomSpace > topSpace)
bestDirection = SMCalloutArrowDirectionUp;
self.currentArrowDirection = bestDirection;
// we want to point directly at the horizontal center of the given rect. calculate our "anchor point" in terms of our
// target view's coordinate system. make sure to offset the anchor point as requested if necessary.
CGFloat anchorX = self.calloutOffset.x + CGRectGetMidX(rect);
CGFloat anchorY = self.calloutOffset.y + (bestDirection == SMCalloutArrowDirectionDown ? CGRectGetMinY(rect) : CGRectGetMaxY(rect));
// we prefer to sit centered directly above our anchor
CGFloat calloutX = roundf(anchorX - self.frameWidth / 2);
// but not if it's going to get too close to the edge of our constraints
if (calloutX < constrainedRect.origin.x)
calloutX = constrainedRect.origin.x;
if (calloutX > constrainedRect.origin.x+constrainedRect.size.width-self.frameWidth)
calloutX = constrainedRect.origin.x+constrainedRect.size.width-self.frameWidth;
// what's the farthest to the left and right that we could point to, given our background image constraints?
CGFloat minPointX = calloutX + self.backgroundView.anchorMargin;
CGFloat maxPointX = calloutX + self.frameWidth - self.backgroundView.anchorMargin;
// we may need to scoot over to the left or right to point at the correct spot
CGFloat adjustX = 0;
if (anchorX < minPointX) adjustX = anchorX - minPointX;
if (anchorX > maxPointX) adjustX = anchorX - maxPointX;
// add the callout to the given layer (or view if possible, to receive touch events)
if (view)
[view addSubview:self];
else
[layer addSublayer:self.layer];
CGPoint calloutOrigin = {
.x = calloutX + adjustX,
.y = bestDirection == SMCalloutArrowDirectionDown ? (anchorY - self.calloutHeight) : anchorY
};
self.frameOrigin = calloutOrigin;
// now set the *actual* anchor point for our layer so that our "popup" animation starts from this point.
CGPoint anchorPoint = [layer convertPoint:CGPointMake(anchorX, anchorY) toLayer:self.layer];
// pass on the anchor point to our background view so it knows where to draw the arrow
self.backgroundView.arrowPoint = anchorPoint;
// adjust it to unit coordinates for the actual layer.anchorPoint property
anchorPoint.x /= self.frameWidth;
anchorPoint.y /= self.frameHeight;
self.layer.anchorPoint = anchorPoint;
// setting the anchor point moves the view a bit, so we need to reset
self.frameOrigin = calloutOrigin;
// make sure our frame is not on half-pixels or else we may be blurry!
CGFloat scale = [UIScreen mainScreen].scale;
self.frameX = floorf(self.frameX*scale)/scale;
self.frameY = floorf(self.frameY*scale)/scale;
// layout now so we can immediately start animating to the final position if needed
[self setNeedsLayout];
[self layoutIfNeeded];
// if we're outside the bounds of our constraint rect, we'll give our delegate an opportunity to shift us into position.
// 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.
CGRect contentRect = CGRectUnion(self.frame, rect);
CGSize offset = [self offsetToContainRect:contentRect inRect:constrainedRect];
NSTimeInterval delay = 0;
self.popupCancelled = NO; // reset this before calling our delegate below
if ([self.delegate respondsToSelector:@selector(calloutView:delayForRepositionWithSize:)] && !CGSizeEqualToSize(offset, CGSizeZero))
delay = [self.delegate calloutView:(id)self delayForRepositionWithSize:offset];
// there's a chance that user code in the delegate method may have called -dismissCalloutAnimated to cancel things; if that
// happened then we need to bail!
if (self.popupCancelled) return;
// now we want to mask our contents to our background view (if requested) to match the iOS 7 style
self.containerView.layer.mask = self.backgroundView.contentMask;
// if we need to delay, we don't want to be visible while we're delaying, so hide us in preparation for our popup
self.hidden = YES;
// create the appropriate animation, even if we're not animated
CAAnimation *animation = [self animationWithType:self.presentAnimation presenting:YES];
// nuke the duration if no animation requested - we'll still need to "run" the animation to get delays and callbacks
if (!animated)
animation.duration = 0.0000001; // can't be zero or the animation won't "run"
animation.beginTime = CACurrentMediaTime() + delay;
animation.delegate = self;
[self.layer addAnimation:animation forKey:@"present"];
}
- (void)animationDidStart:(CAAnimation *)anim {
BOOL presenting = [[anim valueForKey:@"presenting"] boolValue];
if (presenting) {
if ([_delegate respondsToSelector:@selector(calloutViewWillAppear:)])
[_delegate calloutViewWillAppear:(id)self];
// ok, animation is on, let's make ourselves visible!
self.hidden = NO;
}
else if (!presenting) {
if ([_delegate respondsToSelector:@selector(calloutViewWillDisappear:)])
[_delegate calloutViewWillDisappear:(id)self];
}
}
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)finished {
BOOL presenting = [[anim valueForKey:@"presenting"] boolValue];
if (presenting && finished) {
if ([_delegate respondsToSelector:@selector(calloutViewDidAppear:)])
[_delegate calloutViewDidAppear:(id)self];
}
else if (!presenting && finished) {
[self removeFromParent];
[self.layer removeAnimationForKey:@"dismiss"];
if ([_delegate respondsToSelector:@selector(calloutViewDidDisappear:)])
[_delegate calloutViewDidDisappear:(id)self];
}
}
- (void)dismissCalloutAnimated:(BOOL)animated {
// cancel all animations that may be in progress
[self.layer removeAnimationForKey:@"present"];
[self.layer removeAnimationForKey:@"dismiss"];
self.popupCancelled = YES;
if (animated) {
CAAnimation *animation = [self animationWithType:self.dismissAnimation presenting:NO];
animation.delegate = self;
[self.layer addAnimation:animation forKey:@"dismiss"];
}
else {
[self removeFromParent];
}
}
- (void)removeFromParent {
if (self.superview)
[self removeFromSuperview];
else {
// removing a layer from a superlayer causes an implicit fade-out animation that we wish to disable.
[CATransaction begin];
[CATransaction setDisableActions:YES];
[self.layer removeFromSuperlayer];
[CATransaction commit];
}
}
- (CAAnimation *)animationWithType:(SMCalloutAnimation)type presenting:(BOOL)presenting {
CAAnimation *animation = nil;
if (type == SMCalloutAnimationBounce) {
CABasicAnimation *fade = [CABasicAnimation animationWithKeyPath:@"opacity"];
fade.duration = 0.23;
fade.fromValue = presenting ? @0.0 : @1.0;
fade.toValue = presenting ? @1.0 : @0.0;
fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
CABasicAnimation *bounce = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
bounce.duration = 0.23;
bounce.fromValue = presenting ? @0.7 : @1.0;
bounce.toValue = presenting ? @1.0 : @0.7;
bounce.timingFunction = [CAMediaTimingFunction functionWithControlPoints:0.59367:0.12066:0.18878:1.5814];
CAAnimationGroup *group = [CAAnimationGroup animation];
group.animations = @[fade, bounce];
group.duration = 0.23;
animation = group;
}
else if (type == SMCalloutAnimationFade) {
CABasicAnimation *fade = [CABasicAnimation animationWithKeyPath:@"opacity"];
fade.duration = 1.0/3.0;
fade.fromValue = presenting ? @0.0 : @1.0;
fade.toValue = presenting ? @1.0 : @0.0;
animation = fade;
}
else if (type == SMCalloutAnimationStretch) {
CABasicAnimation *stretch = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
stretch.duration = 0.1;
stretch.fromValue = presenting ? @0.0 : @1.0;
stretch.toValue = presenting ? @1.0 : @0.0;
animation = stretch;
}
// CAAnimation is KVC compliant, so we can store whether we're presenting for lookup in our delegate methods
[animation setValue:@(presenting) forKey:@"presenting"];
animation.fillMode = kCAFillModeForwards;
animation.removedOnCompletion = NO;
return animation;
}
- (void)layoutSubviews {
self.containerView.frame = self.bounds;
self.backgroundView.frame = self.bounds;
// if we're pointing up, we'll need to push almost everything down a bit
CGFloat dy = self.currentArrowDirection == SMCalloutArrowDirectionUp ? TOP_ANCHOR_MARGIN : 0;
self.titleViewOrDefault.frameX = self.innerContentMarginLeft;
self.titleViewOrDefault.frameY = (self.subtitleView || self.subtitle.length ? TITLE_SUB_TOP : TITLE_TOP) + dy;
self.titleViewOrDefault.frameWidth = self.frameWidth - self.innerContentMarginLeft - self.innerContentMarginRight;
self.subtitleViewOrDefault.frameX = self.titleViewOrDefault.frameX;
self.subtitleViewOrDefault.frameY = SUBTITLE_TOP + dy;
self.subtitleViewOrDefault.frameWidth = self.titleViewOrDefault.frameWidth;
self.leftAccessoryView.frameX = self.leftAccessoryHorizontalMargin;
self.leftAccessoryView.frameY = self.leftAccessoryVerticalMargin + dy;
self.rightAccessoryView.frameX = self.frameWidth - self.rightAccessoryHorizontalMargin - self.rightAccessoryView.frameWidth;
self.rightAccessoryView.frameY = self.rightAccessoryVerticalMargin + dy;
if (self.contentView) {
self.contentView.frameX = self.innerContentMarginLeft;
self.contentView.frameY = self.contentViewInset.top + dy;
}
}
#pragma mark - Accessibility
- (NSInteger)accessibilityElementCount {
return (!!self.leftAccessoryView + !!self.titleViewOrDefault +
!!self.subtitleViewOrDefault + !!self.rightAccessoryView);
}
- (id)accessibilityElementAtIndex:(NSInteger)index {
if (index == 0) {
return self.leftAccessoryView ? self.leftAccessoryView : self.titleViewOrDefault;
}
if (index == 1) {
return self.leftAccessoryView ? self.titleViewOrDefault : self.subtitleViewOrDefault;
}
if (index == 2) {
return self.leftAccessoryView ? self.subtitleViewOrDefault : self.rightAccessoryView;
}
if (index == 3) {
return self.leftAccessoryView ? self.rightAccessoryView : nil;
}
return nil;
}
- (NSInteger)indexOfAccessibilityElement:(id)element {
if (element == nil) return NSNotFound;
if (element == self.leftAccessoryView) return 0;
if (element == self.titleViewOrDefault) {
return self.leftAccessoryView ? 1 : 0;
}
if (element == self.subtitleViewOrDefault) {
return self.leftAccessoryView ? 2 : 1;
}
if (element == self.rightAccessoryView) {
return self.leftAccessoryView ? 3 : 2;
}
return NSNotFound;
}
@end
// import this known "private API" from SMCalloutBackgroundView
@interface SMCalloutBackgroundView (EmbeddedImages)
+ (UIImage *)embeddedImageNamed:(NSString *)name;
@end
//
// Callout Background View.
//
@interface SMCalloutMaskedBackgroundView ()
@property (nonatomic, strong) UIView *containerView, *containerBorderView, *arrowView;
@property (nonatomic, strong) UIImageView *arrowImageView, *arrowHighlightedImageView, *arrowBorderView;
@end
static UIImage *blackArrowImage = nil, *whiteArrowImage = nil, *grayArrowImage = nil;
@implementation SMCalloutMaskedBackgroundView
- (id)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
// Here we're mimicking the very particular (and odd) structure of the system callout view.
// The hierarchy and view/layer values were discovered by inspecting map kit using Reveal.app
self.containerView = [UIView new];
self.containerView.backgroundColor = [UIColor whiteColor];
self.containerView.alpha = 0.96;
self.containerView.layer.cornerRadius = 8;
self.containerView.layer.shadowRadius = 30;
self.containerView.layer.shadowOpacity = 0.1;
self.containerBorderView = [UIView new];
self.containerBorderView.layer.borderColor = [UIColor colorWithWhite:0 alpha:0.1].CGColor;
self.containerBorderView.layer.borderWidth = 0.5;
self.containerBorderView.layer.cornerRadius = 8.5;
if (!blackArrowImage) {
blackArrowImage = [SMCalloutBackgroundView embeddedImageNamed:@"CalloutArrow"];
whiteArrowImage = [self image:blackArrowImage withColor:[UIColor whiteColor]];
grayArrowImage = [self image:blackArrowImage withColor:[UIColor colorWithWhite:0.85 alpha:1]];
}
self.anchorHeight = 13;
self.anchorMargin = 27;
self.arrowView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, blackArrowImage.size.width, blackArrowImage.size.height)];
self.arrowView.alpha = 0.96;
self.arrowImageView = [[UIImageView alloc] initWithImage:whiteArrowImage];
self.arrowHighlightedImageView = [[UIImageView alloc] initWithImage:grayArrowImage];
self.arrowHighlightedImageView.hidden = YES;
self.arrowBorderView = [[UIImageView alloc] initWithImage:blackArrowImage];
self.arrowBorderView.alpha = 0.1;
self.arrowBorderView.frameY = 0.5;
[self addSubview:self.containerView];
[self.containerView addSubview:self.containerBorderView];
[self addSubview:self.arrowView];
[self.arrowView addSubview:self.arrowBorderView];
[self.arrowView addSubview:self.arrowImageView];
[self.arrowView addSubview:self.arrowHighlightedImageView];
}
return self;
}
// Make sure we relayout our images when our arrow point changes!
- (void)setArrowPoint:(CGPoint)arrowPoint {
[super setArrowPoint:arrowPoint];
[self setNeedsLayout];
}
- (void)setHighlighted:(BOOL)highlighted {
[super setHighlighted:highlighted];
self.containerView.backgroundColor = highlighted ? [UIColor colorWithWhite:0.85 alpha:1] : [UIColor whiteColor];
self.arrowImageView.hidden = highlighted;
self.arrowHighlightedImageView.hidden = !highlighted;
}
- (UIImage *)image:(UIImage *)image withColor:(UIColor *)color {
UIGraphicsBeginImageContextWithOptions(image.size, NO, 0);
CGRect imageRect = (CGRect){.size=image.size};
CGContextRef c = UIGraphicsGetCurrentContext();
CGContextTranslateCTM(c, 0, image.size.height);
CGContextScaleCTM(c, 1, -1);
CGContextClipToMask(c, imageRect, image.CGImage);
[color setFill];
CGContextFillRect(c, imageRect);
UIImage *whiteImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return whiteImage;
}
- (void)layoutSubviews {
BOOL pointingUp = self.arrowPoint.y < self.frameHeight/2;
// if we're pointing up, we'll need to push almost everything down a bit
CGFloat dy = pointingUp ? TOP_ANCHOR_MARGIN : 0;
self.containerView.frame = CGRectMake(0, dy, self.frameWidth, self.frameHeight - self.arrowView.frameHeight + 0.5);
self.containerBorderView.frame = CGRectInset(self.containerView.bounds, -0.5, -0.5);
self.arrowView.frameX = roundf(self.arrowPoint.x - self.arrowView.frameWidth / 2);
if (pointingUp) {
self.arrowView.frameY = 1;
self.arrowView.transform = CGAffineTransformMakeRotation(M_PI);
}
else {
self.arrowView.frameY = self.containerView.frameHeight - 0.5;
self.arrowView.transform = CGAffineTransformIdentity;
}
}
- (CALayer *)contentMask {
UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, 0);
[self.layer renderInContext:UIGraphicsGetCurrentContext()];
UIImage *maskImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
CALayer *layer = [CALayer layer];
layer.frame = self.bounds;
layer.contents = (id)maskImage.CGImage;
return layer;
}
@end
@implementation SMCalloutBackgroundView
+ (NSData *)dataWithBase64EncodedString:(NSString *)string {
//
// NSData+Base64.m
//
// Version 1.0.2
//
// Created by Nick Lockwood on 12/01/2012.
// Copyright (C) 2012 Charcoal Design
//
// Distributed under the permissive zlib License
// Get the latest version from here:
//
// https://github.com/nicklockwood/Base64
//
// This software is provided 'as-is', without any express or implied
// warranty. In no event will the authors be held liable for any damages
// arising from the use of this software.
//
// Permission is granted to anyone to use this software for any purpose,
// including commercial applications, and to alter it and redistribute it
// freely, subject to the following restrictions:
//
// 1. The origin of this software must not be misrepresented; you must not
// claim that you wrote the original software. If you use this software
// in a product, an acknowledgment in the product documentation would be
// appreciated but is not required.
//
// 2. Altered source versions must be plainly marked as such, and must not be
// misrepresented as being the original software.
//
// 3. This notice may not be removed or altered from any source distribution.
//
const char lookup[] = {
99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 62, 99, 99, 99, 63,
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 99, 99, 99, 99, 99, 99,
99, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 99, 99, 99, 99, 99,
99, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 99, 99, 99, 99, 99
};
NSData *inputData = [string dataUsingEncoding:NSASCIIStringEncoding allowLossyConversion:YES];
long long inputLength = [inputData length];
const unsigned char *inputBytes = [inputData bytes];
long long maxOutputLength = (inputLength / 4 + 1) * 3;
NSMutableData *outputData = [NSMutableData dataWithLength:(NSUInteger)maxOutputLength];
unsigned char *outputBytes = (unsigned char *)[outputData mutableBytes];
int accumulator = 0;
long long outputLength = 0;
unsigned char accumulated[] = {0, 0, 0, 0};
for (long long i = 0; i < inputLength; i++) {
unsigned char decoded = lookup[inputBytes[i] & 0x7F];
if (decoded != 99) {
accumulated[accumulator] = decoded;
if (accumulator == 3) {
outputBytes[outputLength++] = (accumulated[0] << 2) | (accumulated[1] >> 4);
outputBytes[outputLength++] = (accumulated[1] << 4) | (accumulated[2] >> 2);
outputBytes[outputLength++] = (accumulated[2] << 6) | accumulated[3];
}
accumulator = (accumulator + 1) % 4;
}
}
//handle left-over data
if (accumulator > 0) outputBytes[outputLength] = (accumulated[0] << 2) | (accumulated[1] >> 4);
if (accumulator > 1) outputBytes[++outputLength] = (accumulated[1] << 4) | (accumulated[2] >> 2);
if (accumulator > 2) outputLength++;
//truncate data to match actual output length
outputData.length = (NSUInteger)outputLength;
return outputLength? outputData: nil;
}
+ (UIImage *)embeddedImageNamed:(NSString *)name {
CGFloat screenScale = [UIScreen mainScreen].scale;
if (screenScale > 1.0) {
name = [name stringByAppendingString:@"_2x"];
screenScale = 2.0;
}
SEL selector = NSSelectorFromString(name);
if (![(id)self respondsToSelector:selector]) {
NSLog(@"Could not find an embedded image. Ensure that you've added a class-level method named +%@", name);
return nil;
}
// We need to hush the compiler here - but we know what we're doing!
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
NSString *base64String = [(id)self performSelector:selector];
#pragma clang diagnostic pop
UIImage *rawImage = [UIImage imageWithData:[self dataWithBase64EncodedString:base64String]];
return [UIImage imageWithCGImage:rawImage.CGImage scale:screenScale orientation:UIImageOrientationUp];
}
+ (NSString *)CalloutArrow { return @"iVBORw0KGgoAAAANSUhEUgAAACcAAAANCAYAAAAqlHdlAAAAHGlET1QAAAACAAAAAAAAAAcAAAAoAAAABwAAAAYAAADJEgYpIwAAAJVJREFUOBFiYIAAdn5+fkFOTkE5Dg5eW05O3lJOTr6zQPyfDhhoD28pxF5BOZA7gE5ih7oLN8XJyR8MdNwrGjkQaC5/MG7biZDh4OBXBDruLpUdeBdkLhHWE1bCzs6nAnTcUyo58DnIPMK2kqAC6DALIP5JoQNB+i1IsJZ4pcBEm0iJ40D6ibeNDJVAx00k04ETSbUOAAAA//+SwicfAAAAe0lEQVRjYCAdMHNy8u7l5OT7Tzzm3Qu0hpl0q8jQwcPDIwp02B0iHXeHl5dXhAxryNfCzc2tC3TcJwIO/ARSR74tFOjk4uL1BzruHw4H/gPJU2A85Vq5uPjTgY77g+bAPyBxyk2nggkcHPxOnJz8B4AOfAGiQXwqGMsAACGK1kPPMHNBAAAAAElFTkSuQmCC"; }
+ (NSString *)CalloutArrow_2x { return @"iVBORw0KGgoAAAANSUhEUgAAAE4AAAAaCAYAAAAZtWr8AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAHGlET1QAAAACAAAAAAAAAA0AAAAoAAAADQAAAA0AAAFMRh0LGwAAARhJREFUWAnclbENwjAQRZ0mih2fDYgsQEVDxQZMgKjpWYAJkBANI8AGDIEoM0WkzBDRAf8klB44g0OkU1zE3/+9RIpS7VVY730/y/woTWlsjJ9iPcN9pbXfY85auyvm/qcDNmb0e2Z+sk/ZBTthN0oVttX12mJIWeaWEFf+kbySmZQa0msu3nzaGJprTXV3BVLNDG/if7bNOTeAvFP35NGJu39GL7Abb27bFXncVQBZLgJf3jp+ebSWIxZMgrxdvPJoJ4gqHpXgV36ITR46HUGaiNMKB6YQd4lI3gV8qTBjmDhrbQFxVQTyKu4ShjJQap7nE4hrfiiv4Q6B8MLGat1bQNztB/JwZm8Rli5wujFu821xfGZgLPUAAAD//4wvm4gAAAD7SURBVOWXMQ6CMBiFgaFpi6VyBEedXJy4hMQTeBSvRDgJEySegI3EQWOivkZnqUB/k0LyL7R9L++D9G+DwP0TCZGUqCdRlYgUuY9F4JCmqQa0hgBcY7wIItFZMLZYS5l0ruAZbXhs6BIROgmhcoB7OIAHTZUTRqG3wp9xmhqc0aRPQu8YAlwxIbwCEUL6GH9wfDcLXY2HpyvvmkHf9+BcrwCuHQGvNRp9Pl6OY0PPAO42AB7WqMxLKLahpFR7gLv/AA9zPe+gtvAMCIC7WMC7CqEPtrqzmBfHyy3A1V/g1Th27GYBY0BIxrk6Ap65254/VZp30GID9JwteQEZrVMWXqGn8gAAAABJRU5ErkJggg=="; }
@end
//
// Our UIView frame helpers implementation
//
@implementation UIView (SMFrameAdditions)
- (CGPoint)frameOrigin { return self.frame.origin; }
- (void)setFrameOrigin:(CGPoint)origin { self.frame = (CGRect){ .origin=origin, .size=self.frame.size }; }
- (CGFloat)frameX { return self.frame.origin.x; }
- (void)setFrameX:(CGFloat)x { self.frame = (CGRect){ .origin.x=x, .origin.y=self.frame.origin.y, .size=self.frame.size }; }
- (CGFloat)frameY { return self.frame.origin.y; }
- (void)setFrameY:(CGFloat)y { self.frame = (CGRect){ .origin.x=self.frame.origin.x, .origin.y=y, .size=self.frame.size }; }
- (CGSize)frameSize { return self.frame.size; }
- (void)setFrameSize:(CGSize)size { self.frame = (CGRect){ .origin=self.frame.origin, .size=size }; }
- (CGFloat)frameWidth { return self.frame.size.width; }
- (void)setFrameWidth:(CGFloat)width { self.frame = (CGRect){ .origin=self.frame.origin, .size.width=width, .size.height=self.frame.size.height }; }
- (CGFloat)frameHeight { return self.frame.size.height; }
- (void)setFrameHeight:(CGFloat)height { self.frame = (CGRect){ .origin=self.frame.origin, .size.width=self.frame.size.width, .size.height=height }; }
- (CGFloat)frameLeft { return self.frame.origin.x; }
- (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 }; }
- (CGFloat)frameTop { return self.frame.origin.y; }
- (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) }; }
- (CGFloat)frameRight { return self.frame.origin.x + self.frame.size.width; }
- (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 }; }
- (CGFloat)frameBottom { return self.frame.origin.y + self.frame.size.height; }
- (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) }; }
@end
#endif //UIKit
//
// SMCalloutView.swift
// Deltroid
//
// Created by Joseph Mattiello and ChatGPT on 2/21/23.
// Copyright © 2023 Joseph Mattiello. All rights reserved.
//
import Foundation
import UIKit
import MapKit
let SMCalloutViewSubtitleViewTag = 1001
let SMCalloutViewTitleViewTag = 1000
let SMCalloutViewBackgroundViewTag = 1002
let arrowHeightFactor: CGFloat = 0.4
let borderWidth: CGFloat = 1
let contentInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
let CalloutMapViewControllerHorizontalPadding: CGFloat = 15
@objc protocol SMCalloutViewDelegate: AnyObject {
@objc optional func calloutViewClicked(_ calloutView: SMCalloutView)
@objc optional func calloutViewClickedSubtitle(_ calloutView: SMCalloutView)
@objc optional func calloutViewDidDismiss(_ calloutView: SMCalloutView)
}
enum SMCalloutArrowDirection: UInt {
case up = 0
case down
case any
}
class SMCalloutView: UIView {
var contentView: UIView?
var title: String?
var subtitle: String?
weak var delegate: SMCalloutViewDelegate?
var calloutOffset: CGPoint = .zero
var calloutAnchorPoint: CGPoint = .zero
var contentViewInset: UIEdgeInsets = .zero
var titleFont: UIFont = .boldSystemFont(ofSize: 16)
var subtitleFont: UIFont = .systemFont(ofSize: 14)
var titleColor: UIColor = .black
var subtitleColor: UIColor = .gray
var animationDuration: TimeInterval = 0.15
var arrowWidth: CGFloat = 28
var permittedArrowDirection: SMCalloutArrowDirection = .any
lazy var constrainedInsets: UIEdgeInsets = {
let topInset = min(contentInsets.top, arrowHeight() + borderWidth)
let leftInset = min(contentInsets.left, arrowWidth + borderWidth)
let bottomInset = min(contentInsets.bottom, borderWidth)
let rightInset = min(contentInsets.right, borderWidth)
return UIEdgeInsets(top: topInset, left: leftInset, bottom: bottomInset, right: rightInset)
}()
private let backgroundLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillColor = UIColor.white.cgColor
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowRadius = 2
layer.shadowOpacity = 0.5
return layer
}()
var titleView: UIView? {
get {
return contentView?.viewWithTag(SMCalloutViewTitleViewTag)
}
set {
if let existingTitleView = titleView {
existingTitleView.removeFromSuperview()
}
if let newTitleView = newValue {
newTitleView.tag = SMCalloutViewTitleViewTag
contentView?.addSubview(newTitleView)
}
setNeedsLayout()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
layer.addSublayer(backgroundLayer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
guard let contentView = contentView else { return false }
let convertedPoint = convert(point, to: contentView)
return contentView.bounds.contains(convertedPoint)
}
class func platformCalloutView() -> SMCalloutView {
// if #available(iOS 9.0, *) {
// return MKAnnotationView.calloutView()
// } else {
return SMCalloutView()
// }
}
func maximumWidth() -> CGFloat {
return UIScreen.main.bounds.width - (contentInsets.left + contentInsets.right + 2 * borderWidth + 2 * CalloutMapViewControllerHorizontalPadding)
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let width = min(size.width, maximumWidth())
let containerSize = CGSize(width: width - contentInsets.left - contentInsets.right, height: CGFloat.greatestFiniteMagnitude)
let containerHeight = calloutContainerHeight()
let calloutHeight = calloutHeight()
let finalSize = CGSize(width: width, height: calloutHeight)
return finalSize
}
func presentCallout(from rect: CGRect, in view: UIView, constrainedTo constrainedRect: CGRect, animated: Bool) {
guard let contentView = contentView else { return }
view.addSubview(self)
let edgePadding = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
let arrowSize = CGSize(width: 10, height: 5)
let calloutSize = contentView.frame.size
let anchorPoint = CGPoint(x: rect.minX + calloutAnchorPoint.x, y: rect.minY + calloutAnchorPoint.y)
let calloutOrigin = origin(for: anchorPoint, calloutSize: calloutSize, arrowSize: arrowSize, edgePadding: edgePadding, constrainedRect: constrainedRect)
let arrowPoint = CGPoint(x: anchorPoint.x - calloutOrigin.x + arrowSize.width / 2, y: anchorPoint.y - calloutOrigin.y)
let path = pathFor(calloutSize: calloutSize, arrowSize: arrowSize, arrowPoint: arrowPoint, cornerRadius: 6)
backgroundLayer.path = path.cgPath
backgroundLayer.frame = CGRect(origin: calloutOrigin, size: calloutSize)
contentView.frame = CGRect(origin: calloutOrigin, size: calloutSize).inset(by: contentViewInset)
addSubview(contentView)
if animated {
contentView.alpha = 0
contentView.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
UIView.animate(withDuration: animationDuration, delay: 0, options: [.curveEaseOut], animations: {
contentView.alpha = 1
contentView.transform = .identity
}, completion: nil)
}
}
func dismissCallout(animated: Bool) {
guard let contentView = contentView else { return }
if animated {
UIView.animate(withDuration: animationDuration, delay: 0, options: [.curveEaseOut], animations: {
contentView.alpha = 0
contentView.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
}, completion: { [weak self] finished in
contentView.removeFromSuperview()
self?.removeFromSuperview()
})
} else {
contentView.removeFromSuperview()
removeFromSuperview()
}
delegate?.calloutViewDidDismiss?(self)
self.contentView = nil
}
}
extension SMCalloutView {
func supportsHighlighting() -> Bool {
guard let delegate = delegate else { return false }
return delegate.calloutViewClicked != nil || delegate.calloutViewClickedSubtitle != nil
}
func highlightIfNecessary() {
if supportsHighlighting() {
backgroundColor = UIColor(white: 1, alpha: 0.85)
}
}
func unhighlightIfNecessary() {
if supportsHighlighting() {
backgroundColor = UIColor.white
}
}
@objc func calloutClicked() {
guard let delegate = delegate else { return }
if delegate.calloutViewClicked != nil {
delegate.calloutViewClicked!(self)
} else if delegate.calloutViewClickedSubtitle != nil {
delegate.calloutViewClickedSubtitle!(self)
}
}
}
extension SMCalloutView {
func titleViewOrDefault() -> UIView {
if let titleView = contentView?.viewWithTag(SMCalloutViewTitleViewTag) {
return titleView
} else {
let titleView = UILabel()
titleView.font = titleFont
titleView.textColor = titleColor
titleView.textAlignment = .center
titleView.numberOfLines = 0
titleView.tag = SMCalloutViewTitleViewTag
return titleView
}
}
func subtitleViewOrDefault() -> UIView? {
if let subtitle = subtitle {
if let subtitleView = contentView?.viewWithTag(SMCalloutViewSubtitleViewTag) as? UILabel {
subtitleView.text = subtitle
return subtitleView
} else {
let subtitleView = UILabel()
subtitleView.font = subtitleFont
subtitleView.textColor = subtitleColor
subtitleView.textAlignment = .center
subtitleView.numberOfLines = 0
subtitleView.text = subtitle
subtitleView.tag = SMCalloutViewSubtitleViewTag
return subtitleView
}
} else {
return nil
}
}
func backgroundView() -> UIView {
if let backgroundView = contentView?.viewWithTag(SMCalloutViewBackgroundViewTag) {
return backgroundView
} else {
let backgroundView = defaultBackgroundView()
backgroundView.tag = SMCalloutViewBackgroundViewTag
return backgroundView
}
}
func defaultBackgroundView() -> UIView {
let view = UIView()
view.backgroundColor = UIColor.white
return view
}
func rebuildSubviews() {
guard let contentView = contentView else { return }
let titleView = titleViewOrDefault()
titleView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(titleView)
let subtitleView = subtitleViewOrDefault()
subtitleView?.translatesAutoresizingMaskIntoConstraints = false
if let subtitleView = subtitleView {
contentView.addSubview(subtitleView)
}
let backgroundView = self.backgroundView()
backgroundView.translatesAutoresizingMaskIntoConstraints = false
contentView.insertSubview(backgroundView, at: 0)
let views = ["titleView": titleView]
let constraints = [
NSLayoutConstraint.constraints(withVisualFormat: "H:|-8-[titleView]-8-|", options: [], metrics: nil, views: views),
NSLayoutConstraint.constraints(withVisualFormat: "V:|-6-[titleView]", options: [], metrics: nil, views: views)
].flatMap { $0 }
contentView.addConstraints(constraints)
if let subtitleView = subtitleView {
let subtitleViews = ["subtitleView": subtitleView, "titleView": titleView]
let subtitleConstraints = [
NSLayoutConstraint.constraints(withVisualFormat: "H:|-8-[subtitleView]-8-|", options: [], metrics: nil, views: subtitleViews),
NSLayoutConstraint.constraints(withVisualFormat: "V:[titleView]-0-[subtitleView]", options: [], metrics: nil, views: subtitleViews)
].flatMap { $0 }
contentView.addConstraints(subtitleConstraints)
}
let backgroundViews = ["backgroundView": backgroundView, "contentView": contentView]
let backgroundConstraints = [
NSLayoutConstraint.constraints(withVisualFormat: "H:|[backgroundView]|", options: [], metrics: nil, views: backgroundViews),
NSLayoutConstraint.constraints(withVisualFormat: "V:|[backgroundView]|", options: [], metrics: nil, views: backgroundViews)
].flatMap { $0 }
contentView.addConstraints(backgroundConstraints)
}
}
extension SMCalloutView {
func leftAccessoryVerticalMargin() -> CGFloat {
return 20
}
func leftAccessoryHorizontalMargin() -> CGFloat {
return 15
}
func rightAccessoryVerticalMargin() -> CGFloat {
return 20
}
func rightAccessoryHorizontalMargin() -> CGFloat {
return 15
}
func innerContentMarginLeft() -> CGFloat {
return 8
}
func innerContentMarginRight() -> CGFloat {
return 8
}
func calloutContainerHeight() -> CGFloat {
var height: CGFloat = 0
if let titleView = contentView?.viewWithTag(SMCalloutViewTitleViewTag) {
height += titleView.frame.size.height
}
if let subtitleView = contentView?.viewWithTag(SMCalloutViewSubtitleViewTag) {
height += subtitleView.frame.size.height
}
height += innerContentMarginTop() + innerContentMarginBottom()
return height
}
func calloutHeight() -> CGFloat {
return calloutContainerHeight() + calloutOffset.y + arrowHeight()
}
func innerContentMarginTop() -> CGFloat {
return titleViewOrDefault().frame.size.height
}
func innerContentMarginBottom() -> CGFloat {
return subtitleViewOrDefault()?.frame.size.height ?? 0
}
func arrowHeight() -> CGFloat {
return arrowHeightFactor * arrowWidth
}
}
/*
The pathFor(calloutSize:arrowSize:arrowPoint:cornerRadius:) method takes four arguments:
calloutSize: The size of the callout view.
arrowSize: The size of the callout arrow.
arrowPoint: The point on the callout view where the arrow should be positioned.
cornerRadius: The radius of the corners of the callout view.
The method first creates a rectangle that represents the callout view, excluding the area occupied by the arrow. It also creates a rectangle that represents the arrow.
The method then creates a UIBezierPath object and uses it to draw the path for the callout view. The path is drawn in a counterclockwise direction, starting from the top left corner.
*/
func pathFor(calloutSize: CGSize, arrowSize: CGSize, arrowPoint: CGPoint, cornerRadius: CGFloat) -> UIBezierPath {
let calloutRect = CGRect(x: 0, y: 0, width: calloutSize.width, height: calloutSize.height - arrowSize.height)
let arrowRect = CGRect(x: arrowPoint.x - arrowSize.width / 2, y: arrowPoint.y, width: arrowSize.width, height: arrowSize.height)
let path = UIBezierPath()
// Top left corner
let topLeft = CGPoint(x: calloutRect.minX, y: calloutRect.minY + cornerRadius)
path.move(to: topLeft)
path.addArc(withCenter: CGPoint(x: topLeft.x + cornerRadius, y: topLeft.y), radius: cornerRadius, startAngle: .pi, endAngle: 3 * .pi / 2, clockwise: true)
// Top side
let topRight = CGPoint(x: calloutRect.maxX - cornerRadius, y: calloutRect.minY)
path.addLine(to: topRight)
// Top right corner
path.addArc(withCenter: CGPoint(x: topRight.x, y: topRight.y + cornerRadius), radius: cornerRadius, startAngle: 3 * .pi / 2, endAngle: 0, clockwise: true)
// Right side
let bottomRight = CGPoint(x: calloutRect.maxX, y: calloutRect.maxY - cornerRadius)
path.addLine(to: bottomRight)
// Bottom right corner
path.addArc(withCenter: CGPoint(x: bottomRight.x - cornerRadius, y: bottomRight.y), radius: cornerRadius, startAngle: 0, endAngle: .pi / 2, clockwise: true)
// Bottom side
let bottomLeft = CGPoint(x: calloutRect.minX + cornerRadius, y: calloutRect.maxY)
path.addLine(to: bottomLeft)
// Bottom left corner
path.addArc(withCenter: CGPoint(x: bottomLeft.x, y: bottomLeft.y - cornerRadius), radius: cornerRadius, startAngle: .pi / 2, endAngle: .pi, clockwise: true)
// Left side
path.addLine(to: topLeft)
path.close()
// Arrow
path.move(to: CGPoint(x: arrowRect.minX, y: arrowRect.maxY))
path.addLine(to: CGPoint(x: arrowRect.midX, y: arrowRect.minY))
path.addLine(to: CGPoint(x: arrowRect.maxX, y: arrowRect.maxY))
path.close()
return path
}
func origin(for point: CGPoint, calloutSize: CGSize, arrowSize: CGSize, edgePadding: UIEdgeInsets, constrainedRect: CGRect) -> CGPoint {
let offset: CGFloat = 8
var origin = CGPoint.zero
// Calculate the callout frame
let calloutFrame = CGRect(x: point.x - calloutSize.width / 2, y: point.y - calloutSize.height - arrowSize.height - offset, width: calloutSize.width, height: calloutSize.height)
// Adjust the callout frame for edge padding and constraints
if calloutFrame.maxX > constrainedRect.maxX - edgePadding.right {
origin.x = constrainedRect.maxX - calloutSize.width - edgePadding.right
} else if calloutFrame.minX < constrainedRect.minX + edgePadding.left {
origin.x = constrainedRect.minX + edgePadding.left
} else {
origin.x = calloutFrame.minX
}
if calloutFrame.minY < constrainedRect.minY + edgePadding.top {
origin.y = point.y + arrowSize.height + offset
} else {
origin.y = calloutFrame.minY
}
return origin
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment