#import "UIView+MOPFATToast.h" #import #import // Positions NSString * CSToastPositionTop = @"CSToastPositionTop"; NSString * CSToastPositionCenter = @"CSToastPositionCenter"; NSString * CSToastPositionBottom = @"CSToastPositionBottom"; // Keys for values associated with toast views static const NSString * CSToastTimerKey = @"CSToastTimerKey"; static const NSString * CSToastDurationKey = @"CSToastDurationKey"; static const NSString * CSToastPositionKey = @"CSToastPositionKey"; static const NSString * CSToastCompletionKey = @"CSToastCompletionKey"; // Keys for values associated with self static const NSString * CSToastActiveKey = @"CSToastActiveKey"; static const NSString * CSToastActivityViewKey = @"CSToastActivityViewKey"; static const NSString * CSToastQueueKey = @"CSToastQueueKey"; @interface UIView (ToastPrivate) /** These private methods are being prefixed with "cs_" to reduce the likelihood of non-obvious naming conflicts with other UIView methods. @discussion Should the public API also use the cs_ prefix? Technically it should, but it results in code that is less legible. The current public method names seem unlikely to cause conflicts so I think we should favor the cleaner API for now. */ - (void)cs_showToast:(UIView *)toast duration:(NSTimeInterval)duration position:(id)position; - (void)cs_hideToast:(UIView *)toast; - (void)cs_hideToast:(UIView *)toast fromTap:(BOOL)fromTap; - (void)cs_toastTimerDidFinish:(NSTimer *)timer; - (void)cs_handleToastTapped:(UITapGestureRecognizer *)recognizer; - (CGPoint)cs_centerPointForPosition:(id)position withToast:(UIView *)toast; - (NSMutableArray *)cs_toastQueue; @end @implementation UIView (MOPFATToast) #pragma mark - Make Toast Methods - (void)fatMakeToast:(NSString *)message { [self fatMakeToast:message duration:[CSToastManager defaultDuration] position:[CSToastManager defaultPosition] style:nil]; } - (void)fatMakeToast:(NSString *)message duration:(NSTimeInterval)duration position:(id)position { [self fatMakeToast:message duration:duration position:position style:nil]; } - (void)fatMakeToast:(NSString *)message duration:(NSTimeInterval)duration position:(id)position style:(CSToastStyle *)style { UIView *toast = [self fatToastViewForMessage:message title:nil image:nil style:style]; [self fatShowToast:toast duration:duration position:position completion:nil]; } - (void)fatMakeToast:(NSString *)message duration:(NSTimeInterval)duration position:(id)position title:(NSString *)title image:(UIImage *)image style:(CSToastStyle *)style completion:(void(^)(BOOL didTap))completion { UIView *toast = [self fatToastViewForMessage:message title:title image:image style:style]; [self fatShowToast:toast duration:duration position:position completion:completion]; } #pragma mark - Show Toast Methods - (void)fatShowToast:(UIView *)toast { [self fatShowToast:toast duration:[CSToastManager defaultDuration] position:[CSToastManager defaultPosition] completion:nil]; } - (void)fatShowToast:(UIView *)toast duration:(NSTimeInterval)duration position:(id)position completion:(void(^)(BOOL didTap))completion { // sanity if (toast == nil) return; // store the completion block on the toast view objc_setAssociatedObject(toast, &CSToastCompletionKey, completion, OBJC_ASSOCIATION_RETAIN_NONATOMIC); if ([CSToastManager isQueueEnabled] && [self.cs_activeToasts count] > 0) { // we're about to queue this toast view so we need to store the duration and position as well objc_setAssociatedObject(toast, &CSToastDurationKey, @(duration), OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(toast, &CSToastPositionKey, position, OBJC_ASSOCIATION_RETAIN_NONATOMIC); // enqueue [self.cs_toastQueue addObject:toast]; } else { // present [self cs_showToast:toast duration:duration position:position]; } } #pragma mark - Hide Toast Methods - (void)fatHideToast { [self fatHideToast:[[self cs_activeToasts] firstObject]]; } - (void)fatHideToast:(UIView *)toast { // sanity if (!toast || ![[self cs_activeToasts] containsObject:toast]) return; [self cs_hideToast:toast]; } - (void)fatHideAllToasts { [self fatHideAllToasts:NO clearQueue:YES]; } - (void)fatHideAllToasts:(BOOL)includeActivity clearQueue:(BOOL)clearQueue { if (clearQueue) { [self fatClearToastQueue]; } for (UIView *toast in [self cs_activeToasts]) { [self fatHideToast:toast]; } if (includeActivity) { [self fatHideToastActivity]; } } - (void)fatClearToastQueue { [[self cs_toastQueue] removeAllObjects]; } #pragma mark - Private Show/Hide Methods - (void)cs_showToast:(UIView *)toast duration:(NSTimeInterval)duration position:(id)position { toast.center = [self cs_centerPointForPosition:position withToast:toast]; toast.alpha = 0.0; if ([CSToastManager isTapToDismissEnabled]) { UITapGestureRecognizer *recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(cs_handleToastTapped:)]; [toast addGestureRecognizer:recognizer]; toast.userInteractionEnabled = YES; toast.exclusiveTouch = YES; } [[self cs_activeToasts] addObject:toast]; [self addSubview:toast]; [UIView animateWithDuration:[[CSToastManager sharedStyle] fadeDuration] delay:0.0 options:(UIViewAnimationOptionCurveEaseOut | UIViewAnimationOptionAllowUserInteraction) animations:^{ toast.alpha = 1.0; } completion:^(BOOL finished) { NSTimer *timer = [NSTimer timerWithTimeInterval:duration target:self selector:@selector(cs_toastTimerDidFinish:) userInfo:toast repeats:NO]; [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; objc_setAssociatedObject(toast, &CSToastTimerKey, timer, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }]; } - (void)cs_hideToast:(UIView *)toast { [self cs_hideToast:toast fromTap:NO]; } - (void)cs_hideToast:(UIView *)toast fromTap:(BOOL)fromTap { NSTimer *timer = (NSTimer *)objc_getAssociatedObject(toast, &CSToastTimerKey); [timer invalidate]; [UIView animateWithDuration:[[CSToastManager sharedStyle] fadeDuration] delay:0.0 options:(UIViewAnimationOptionCurveEaseIn | UIViewAnimationOptionBeginFromCurrentState) animations:^{ toast.alpha = 0.0; } completion:^(BOOL finished) { [toast removeFromSuperview]; // remove [[self cs_activeToasts] removeObject:toast]; // execute the completion block, if necessary void (^completion)(BOOL didTap) = objc_getAssociatedObject(toast, &CSToastCompletionKey); if (completion) { completion(fromTap); } if ([self.cs_toastQueue count] > 0) { // dequeue UIView *nextToast = [[self cs_toastQueue] firstObject]; [[self cs_toastQueue] removeObjectAtIndex:0]; // present the next toast NSTimeInterval duration = [objc_getAssociatedObject(nextToast, &CSToastDurationKey) doubleValue]; id position = objc_getAssociatedObject(nextToast, &CSToastPositionKey); [self cs_showToast:nextToast duration:duration position:position]; } }]; } #pragma mark - View Construction - (UIView *)fatToastViewForMessage:(NSString *)message title:(NSString *)title image:(UIImage *)image style:(CSToastStyle *)style { // sanity if (message == nil && title == nil && image == nil) return nil; // default to the shared style if (style == nil) { style = [CSToastManager sharedStyle]; } // dynamically build a toast view with any combination of message, title, & image UILabel *messageLabel = nil; UILabel *titleLabel = nil; UIImageView *imageView = nil; UIView *wrapperView = [[UIView alloc] init]; wrapperView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); wrapperView.layer.cornerRadius = style.cornerRadius; if (style.displayShadow) { wrapperView.layer.shadowColor = style.shadowColor.CGColor; wrapperView.layer.shadowOpacity = style.shadowOpacity; wrapperView.layer.shadowRadius = style.shadowRadius; wrapperView.layer.shadowOffset = style.shadowOffset; } wrapperView.backgroundColor = style.backgroundColor; if(image != nil) { imageView = [[UIImageView alloc] initWithImage:image]; imageView.contentMode = UIViewContentModeScaleAspectFit; imageView.frame = CGRectMake(style.horizontalPadding, style.verticalPadding, style.imageSize.width, style.imageSize.height); } CGRect imageRect = CGRectZero; if(imageView != nil) { imageRect.origin.x = style.horizontalPadding; imageRect.origin.y = style.verticalPadding; imageRect.size.width = imageView.bounds.size.width; imageRect.size.height = imageView.bounds.size.height; } if (title != nil) { titleLabel = [[UILabel alloc] init]; titleLabel.numberOfLines = style.titleNumberOfLines; titleLabel.font = style.titleFont; titleLabel.textAlignment = style.titleAlignment; titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; titleLabel.textColor = style.titleColor; titleLabel.backgroundColor = [UIColor clearColor]; titleLabel.alpha = 1.0; titleLabel.text = title; // size the title label according to the length of the text CGSize maxSizeTitle = CGSizeMake((self.bounds.size.width * style.maxWidthPercentage) - imageRect.size.width, self.bounds.size.height * style.maxHeightPercentage); CGSize expectedSizeTitle = [titleLabel sizeThatFits:maxSizeTitle]; // UILabel can return a size larger than the max size when the number of lines is 1 expectedSizeTitle = CGSizeMake(MIN(maxSizeTitle.width, expectedSizeTitle.width), MIN(maxSizeTitle.height, expectedSizeTitle.height)); titleLabel.frame = CGRectMake(0.0, 0.0, expectedSizeTitle.width, expectedSizeTitle.height); } if (message != nil) { messageLabel = [[UILabel alloc] init]; messageLabel.numberOfLines = style.messageNumberOfLines; messageLabel.font = style.messageFont; messageLabel.textAlignment = style.messageAlignment; messageLabel.lineBreakMode = NSLineBreakByTruncatingTail; messageLabel.textColor = style.messageColor; messageLabel.backgroundColor = [UIColor clearColor]; messageLabel.alpha = 1.0; messageLabel.text = message; CGSize maxSizeMessage = CGSizeMake((self.bounds.size.width * style.maxWidthPercentage) - imageRect.size.width, self.bounds.size.height * style.maxHeightPercentage); CGSize expectedSizeMessage = [messageLabel sizeThatFits:maxSizeMessage]; // UILabel can return a size larger than the max size when the number of lines is 1 expectedSizeMessage = CGSizeMake(MIN(maxSizeMessage.width, expectedSizeMessage.width), MIN(maxSizeMessage.height, expectedSizeMessage.height)); messageLabel.frame = CGRectMake(0.0, 0.0, expectedSizeMessage.width, expectedSizeMessage.height); } CGRect titleRect = CGRectZero; if(titleLabel != nil) { titleRect.origin.x = imageRect.origin.x + imageRect.size.width + style.horizontalPadding; titleRect.origin.y = style.verticalPadding; titleRect.size.width = titleLabel.bounds.size.width; titleRect.size.height = titleLabel.bounds.size.height; } CGRect messageRect = CGRectZero; if(messageLabel != nil) { messageRect.origin.x = imageRect.origin.x + imageRect.size.width + style.horizontalPadding; messageRect.origin.y = titleRect.origin.y + titleRect.size.height + style.verticalPadding; messageRect.size.width = messageLabel.bounds.size.width; messageRect.size.height = messageLabel.bounds.size.height; } CGFloat longerWidth = MAX(titleRect.size.width, messageRect.size.width); CGFloat longerX = MAX(titleRect.origin.x, messageRect.origin.x); // Wrapper width uses the longerWidth or the image width, whatever is larger. Same logic applies to the wrapper height. CGFloat wrapperWidth = MAX((imageRect.size.width + (style.horizontalPadding * 2.0)), (longerX + longerWidth + style.horizontalPadding)); CGFloat wrapperHeight = MAX((messageRect.origin.y + messageRect.size.height + style.verticalPadding), (imageRect.size.height + (style.verticalPadding * 2.0))); wrapperView.frame = CGRectMake(0.0, 0.0, wrapperWidth, wrapperHeight); if(titleLabel != nil) { titleLabel.frame = titleRect; [wrapperView addSubview:titleLabel]; } if(messageLabel != nil) { messageLabel.frame = messageRect; [wrapperView addSubview:messageLabel]; } if(imageView != nil) { [wrapperView addSubview:imageView]; } return wrapperView; } #pragma mark - Storage - (NSMutableArray *)cs_activeToasts { NSMutableArray *cs_activeToasts = objc_getAssociatedObject(self, &CSToastActiveKey); if (cs_activeToasts == nil) { cs_activeToasts = [[NSMutableArray alloc] init]; objc_setAssociatedObject(self, &CSToastActiveKey, cs_activeToasts, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } return cs_activeToasts; } - (NSMutableArray *)cs_toastQueue { NSMutableArray *cs_toastQueue = objc_getAssociatedObject(self, &CSToastQueueKey); if (cs_toastQueue == nil) { cs_toastQueue = [[NSMutableArray alloc] init]; objc_setAssociatedObject(self, &CSToastQueueKey, cs_toastQueue, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } return cs_toastQueue; } #pragma mark - Events - (void)cs_toastTimerDidFinish:(NSTimer *)timer { [self cs_hideToast:(UIView *)timer.userInfo]; } - (void)cs_handleToastTapped:(UITapGestureRecognizer *)recognizer { UIView *toast = recognizer.view; NSTimer *timer = (NSTimer *)objc_getAssociatedObject(toast, &CSToastTimerKey); [timer invalidate]; [self cs_hideToast:toast fromTap:YES]; } #pragma mark - Activity Methods - (void)fatMakeToastActivity:(id)position { // sanity UIView *existingActivityView = (UIView *)objc_getAssociatedObject(self, &CSToastActivityViewKey); if (existingActivityView != nil) return; CSToastStyle *style = [CSToastManager sharedStyle]; UIView *activityView = [[UIView alloc] initWithFrame:CGRectMake(0.0, 0.0, style.activitySize.width, style.activitySize.height)]; activityView.center = [self cs_centerPointForPosition:position withToast:activityView]; activityView.backgroundColor = style.backgroundColor; activityView.alpha = 0.0; activityView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); activityView.layer.cornerRadius = style.cornerRadius; if (style.displayShadow) { activityView.layer.shadowColor = style.shadowColor.CGColor; activityView.layer.shadowOpacity = style.shadowOpacity; activityView.layer.shadowRadius = style.shadowRadius; activityView.layer.shadowOffset = style.shadowOffset; } UIActivityIndicatorView *activityIndicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; activityIndicatorView.center = CGPointMake(activityView.bounds.size.width / 2, activityView.bounds.size.height / 2); [activityView addSubview:activityIndicatorView]; [activityIndicatorView startAnimating]; // associate the activity view with self objc_setAssociatedObject (self, &CSToastActivityViewKey, activityView, OBJC_ASSOCIATION_RETAIN_NONATOMIC); [self addSubview:activityView]; [UIView animateWithDuration:style.fadeDuration delay:0.0 options:UIViewAnimationOptionCurveEaseOut animations:^{ activityView.alpha = 1.0; } completion:nil]; } - (void)fatHideToastActivity { UIView *existingActivityView = (UIView *)objc_getAssociatedObject(self, &CSToastActivityViewKey); if (existingActivityView != nil) { [UIView animateWithDuration:[[CSToastManager sharedStyle] fadeDuration] delay:0.0 options:(UIViewAnimationOptionCurveEaseIn | UIViewAnimationOptionBeginFromCurrentState) animations:^{ existingActivityView.alpha = 0.0; } completion:^(BOOL finished) { [existingActivityView removeFromSuperview]; objc_setAssociatedObject (self, &CSToastActivityViewKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }]; } } #pragma mark - Helpers - (CGPoint)cs_centerPointForPosition:(id)point withToast:(UIView *)toast { CSToastStyle *style = [CSToastManager sharedStyle]; UIEdgeInsets safeInsets = UIEdgeInsetsZero; if (@available(iOS 11.0, *)) { safeInsets = self.safeAreaInsets; } CGFloat topPadding = style.verticalPadding + safeInsets.top; CGFloat bottomPadding = style.verticalPadding + safeInsets.bottom; if([point isKindOfClass:[NSString class]]) { if([point caseInsensitiveCompare:CSToastPositionTop] == NSOrderedSame) { return CGPointMake(self.bounds.size.width / 2.0, (toast.frame.size.height / 2.0) + topPadding); } else if([point caseInsensitiveCompare:CSToastPositionCenter] == NSOrderedSame) { return CGPointMake(self.bounds.size.width / 2.0, self.bounds.size.height / 2.0); } } else if ([point isKindOfClass:[NSValue class]]) { return [point CGPointValue]; } // default to bottom return CGPointMake(self.bounds.size.width / 2.0, (self.bounds.size.height - (toast.frame.size.height / 2.0)) - bottomPadding); } @end @implementation CSToastStyle #pragma mark - Constructors - (instancetype)initWithDefaultStyle { self = [super init]; if (self) { self.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.8]; self.titleColor = [UIColor whiteColor]; self.messageColor = [UIColor whiteColor]; self.maxWidthPercentage = 0.8; self.maxHeightPercentage = 0.8; self.horizontalPadding = 10.0; self.verticalPadding = 10.0; self.cornerRadius = 10.0; self.titleFont = [UIFont boldSystemFontOfSize:16.0]; self.messageFont = [UIFont systemFontOfSize:16.0]; self.titleAlignment = NSTextAlignmentLeft; self.messageAlignment = NSTextAlignmentLeft; self.titleNumberOfLines = 0; self.messageNumberOfLines = 0; self.displayShadow = NO; self.shadowOpacity = 0.8; self.shadowRadius = 6.0; self.shadowOffset = CGSizeMake(4.0, 4.0); self.imageSize = CGSizeMake(80.0, 80.0); self.activitySize = CGSizeMake(100.0, 100.0); self.fadeDuration = 0.2; } return self; } - (void)setMaxWidthPercentage:(CGFloat)maxWidthPercentage { _maxWidthPercentage = MAX(MIN(maxWidthPercentage, 1.0), 0.0); } - (void)setMaxHeightPercentage:(CGFloat)maxHeightPercentage { _maxHeightPercentage = MAX(MIN(maxHeightPercentage, 1.0), 0.0); } - (instancetype)init NS_UNAVAILABLE { return nil; } @end @interface CSToastManager () @property (strong, nonatomic) CSToastStyle *sharedStyle; @property (assign, nonatomic, getter=isTapToDismissEnabled) BOOL tapToDismissEnabled; @property (assign, nonatomic, getter=isQueueEnabled) BOOL queueEnabled; @property (assign, nonatomic) NSTimeInterval defaultDuration; @property (strong, nonatomic) id defaultPosition; @end @implementation CSToastManager #pragma mark - Constructors + (instancetype)sharedManager { static CSToastManager *_sharedManager = nil; static dispatch_once_t oncePredicate; dispatch_once(&oncePredicate, ^{ _sharedManager = [[self alloc] init]; }); return _sharedManager; } - (instancetype)init { self = [super init]; if (self) { self.sharedStyle = [[CSToastStyle alloc] initWithDefaultStyle]; self.tapToDismissEnabled = YES; self.queueEnabled = NO; self.defaultDuration = 3.0; self.defaultPosition = CSToastPositionBottom; } return self; } #pragma mark - Singleton Methods + (void)setSharedStyle:(CSToastStyle *)sharedStyle { [[self sharedManager] setSharedStyle:sharedStyle]; } + (CSToastStyle *)sharedStyle { return [[self sharedManager] sharedStyle]; } + (void)setTapToDismissEnabled:(BOOL)tapToDismissEnabled { [[self sharedManager] setTapToDismissEnabled:tapToDismissEnabled]; } + (BOOL)isTapToDismissEnabled { return [[self sharedManager] isTapToDismissEnabled]; } + (void)setQueueEnabled:(BOOL)queueEnabled { [[self sharedManager] setQueueEnabled:queueEnabled]; } + (BOOL)isQueueEnabled { return [[self sharedManager] isQueueEnabled]; } + (void)setDefaultDuration:(NSTimeInterval)duration { [[self sharedManager] setDefaultDuration:duration]; } + (NSTimeInterval)defaultDuration { return [[self sharedManager] defaultDuration]; } + (void)setDefaultPosition:(id)position { if ([position isKindOfClass:[NSString class]] || [position isKindOfClass:[NSValue class]]) { [[self sharedManager] setDefaultPosition:position]; } } + (id)defaultPosition { return [[self sharedManager] defaultPosition]; } @end