blob: e1f04747a0da34251642a0016bfde6c4c470e044 [file]
/*
Copyright 2016-present the Material Components for iOS authors. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
#import <QuartzCore/QuartzCore.h>
#import "MDCSnackbarManager.h"
#import "MDCSnackbarMessage.h"
#import "MDCSnackbarMessageView.h"
#import "MaterialAnimationTiming.h"
#import "MaterialTypography.h"
#import "private/MaterialSnackbarStrings.h"
#import "private/MaterialSnackbarStrings_table.h"
#import "private/MDCSnackbarMessageViewInternal.h"
#import "private/MDCSnackbarOverlayView.h"
NSString *const MDCSnackbarMessageTitleAutomationIdentifier =
@"MDCSnackbarMessageTitleAutomationIdentifier";
// The Bundle for string resources.
static NSString *const kMaterialSnackbarBundle = @"MaterialSnackbar.bundle";
static inline UIColor *MDCRGBAColor(uint8_t r, uint8_t g, uint8_t b, float a) {
return [UIColor colorWithRed:(r) / 255.0f green:(g) / 255.0f blue:(b) / 255.0f alpha:(a)];
}
/**
The thickness of the Snackbar border.
*/
static const CGFloat kBorderWidth = 0;
/**
Shadow coloring.
*/
static const CGFloat kShadowAlpha = 0.24f;
static const CGSize kShadowOffset = (CGSize){0.0, 2.0};
static const CGSize kLegacyShadowOffset = (CGSize){0.0, 1.0};
static const CGFloat kShadowSpread = 4.0f;
static const CGFloat kLegacyShadowSpread = 1.0f;
/**
The radius of the corners.
*/
static const CGFloat kCornerRadius = 4;
static const CGFloat kLegacyCornerRadius = 0;
/**
Padding between the edges of the Snackbar and any content.
*/
static UIEdgeInsets kContentMargin = (UIEdgeInsets){16.0, 16.0, 16.0, 8.0};
static UIEdgeInsets kLegacyContentMargin = (UIEdgeInsets){18.0, 24.0, 18.0, 24.0};
/**
Padding between the image and the main title.
*/
static const CGFloat kTitleImagePadding = 8.0f;
/**
Padding between the main title and the first button.
*/
static const CGFloat kTitleButtonPadding = 8.0f;
/**
Padding on the edges of the buttons.
*/
static const CGFloat kLegacyButtonPadding = 5.0f;
static const CGFloat kButtonPadding = 8.0f;
/**
Minimum padding for the vertical padding of the buttons to the Snackbar
*/
static const CGFloat kMinVerticalButtonPadding = 6.0f;
/**
The width of the Snackbar.
*/
static const CGFloat kMinimumViewWidth_iPad = 288.0f;
static const CGFloat kMaximumViewWidth_iPad = 568.0f;
static const CGFloat kMinimumViewWidth_iPhone = 320.0f;
static const CGFloat kMaximumViewWidth_iPhone = 320.0f;
/**
The minimum height of the Snackbar.
*/
static const CGFloat kMinimumHeight = 48.0f;
/**
Each button will have a tag indexed starting from this value.
*/
static const NSInteger kButtonTagStart = 20000;
/**
The ink radius of the action button.
*/
static const CGFloat kButtonInkRadius = 64.0f;
static const MDCFontTextStyle kMessageTextStyle = MDCFontTextStyleBody1;
static const MDCFontTextStyle kButtonTextStyle = MDCFontTextStyleButton;
#if defined(__IPHONE_10_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0)
@interface MDCSnackbarMessageView () <CAAnimationDelegate>
@end
#endif
@interface MDCSnackbarMessageView ()
/**
Holds the icon for the image.
*/
@property(nonatomic, strong) UIImageView *imageView;
/**
Holds the button views.
*/
@property(nonatomic, strong) NSArray *buttons;
/**
Holds the text label for the main message.
*/
@property(nonatomic, strong) UILabel *label;
/**
The constraints managing this view.
*/
@property(nonatomic, strong) NSArray *viewConstraints;
/**
The view containing all of the visual content. Inset by @c kBorderWidth from the view.
*/
@property(nonatomic, strong) UIControl *containerView;
/**
The view containing all of the buttons.
*/
@property(nonatomic, strong) UIView *buttonView;
/**
The view containing the title and image views.
*/
@property(nonatomic, strong) UIView *contentView;
/**
Holds onto the dismissal handler, called when the Snackbar should dismiss due to user interaction.
*/
@property(nonatomic, copy) MDCSnackbarMessageDismissHandler dismissalHandler;
@end
@interface MDCSnackbarMessageViewButton : MDCFlatButton
@end
@implementation MDCSnackbarMessageViewButton
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
self.inkMaxRippleRadius = kButtonInkRadius;
self.inkColor = [UIColor colorWithWhite:1 alpha:0.06f];
self.inkStyle = MDCInkStyleUnbounded;
CGFloat buttonContentPadding =
MDCSnackbarMessage.usesLegacySnackbar ? kLegacyButtonPadding : kButtonPadding;
[self setTranslatesAutoresizingMaskIntoConstraints:NO];
self.tag = kButtonTagStart;
// Style the text in the button.
self.titleLabel.numberOfLines = 1;
self.contentHorizontalAlignment = UIControlContentHorizontalAlignmentRight;
self.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter;
self.contentEdgeInsets = UIEdgeInsetsMake(buttonContentPadding,
buttonContentPadding,
buttonContentPadding,
buttonContentPadding);
// Make sure the button doesn't get too compressed.
[self setContentCompressionResistancePriority:UILayoutPriorityRequired
forAxis:UILayoutConstraintAxisHorizontal];
[self setContentHuggingPriority:UILayoutPriorityDefaultHigh
forAxis:UILayoutConstraintAxisHorizontal];
}
return self;
}
@end
@implementation MDCSnackbarMessageView {
UIFont *_messageFont;
UIFont *_buttonFont;
// Holds the instances of MDCButton
NSMutableArray<MDCButton *> *_actionButtons;
NSMutableDictionary<NSNumber *, UIColor *> *_buttonTitleColors;
BOOL _mdc_adjustsFontForContentSizeCategory;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
_snackbarMessageViewShadowColor =
MDCSnackbarManager.snackbarMessageViewShadowColor ?: UIColor.blackColor;
_snackbarMessageViewBackgroundColor =
MDCSnackbarManager.snackbarMessageViewBackgroundColor ?: MDCRGBAColor(0x32, 0x32, 0x32, 1);
_messageTextColor =
MDCSnackbarManager.messageTextColor ?: UIColor.whiteColor;
_buttonTitleColors = [NSMutableDictionary dictionary];
_buttonTitleColors[@(UIControlStateNormal)] =
[MDCSnackbarManager buttonTitleColorForState:UIControlStateNormal] ?:
MDCRGBAColor(0xFF, 0xFF, 0xFF, 0.6f);
_buttonTitleColors[@(UIControlStateHighlighted)] =
[MDCSnackbarManager buttonTitleColorForState:UIControlStateHighlighted] ?:
UIColor.whiteColor;
_mdc_adjustsFontForContentSizeCategory =
MDCSnackbarManager.mdc_adjustsFontForContentSizeCategory;
_messageFont = MDCSnackbarManager.messageFont;
_buttonFont = MDCSnackbarManager.buttonFont;
}
return self;
}
- (instancetype)initWithMessage:(MDCSnackbarMessage *)message
dismissHandler:(MDCSnackbarMessageDismissHandler)handler {
self = [super init];
if (self) {
_message = message;
_dismissalHandler = [handler copy];
self.backgroundColor = _snackbarMessageViewBackgroundColor;
self.layer.shadowColor = _snackbarMessageViewShadowColor.CGColor;
self.layer.shadowOpacity = kShadowAlpha;
if (MDCSnackbarMessage.usesLegacySnackbar) {
self.layer.cornerRadius = kLegacyCornerRadius;
self.layer.shadowOffset = kLegacyShadowOffset;
self.layer.shadowRadius = kLegacyShadowSpread;
} else {
self.layer.cornerRadius = kCornerRadius;
self.layer.shadowOffset = kShadowOffset;
self.layer.shadowRadius = kShadowSpread;
}
_anchoredToScreenBottom = YES;
// Borders are drawn inside of the bounds of a layer. Because our border is translucent, we need
// to have a view with transparent background and border only (@c self). Inside will be a
// content view that has the dark grey color.
_containerView = [[UIControl alloc] init];
[self addSubview:_containerView];
[_containerView setTranslatesAutoresizingMaskIntoConstraints:NO];
_containerView.backgroundColor = [UIColor clearColor];
_containerView.layer.cornerRadius =
MDCSnackbarMessage.usesLegacySnackbar ? kLegacyCornerRadius : kCornerRadius;
_containerView.layer.masksToBounds = YES;
// Listen for taps on the background of the view.
[_containerView addTarget:self
action:@selector(handleBackgroundTapped:)
forControlEvents:UIControlEventTouchUpInside];
if (MDCSnackbarMessage.usesLegacySnackbar) {
UISwipeGestureRecognizer *swipeRightGesture =
[[UISwipeGestureRecognizer alloc] initWithTarget:self
action:@selector(handleBackgroundSwipedRight:)];
[swipeRightGesture setDirection:UISwipeGestureRecognizerDirectionRight];
[_containerView addGestureRecognizer:swipeRightGesture];
UISwipeGestureRecognizer *swipeLeftGesture =
[[UISwipeGestureRecognizer alloc] initWithTarget:self
action:@selector(handleBackgroundSwipedLeft:)];
[swipeRightGesture setDirection:UISwipeGestureRecognizerDirectionLeft];
[_containerView addGestureRecognizer:swipeLeftGesture];
}
_contentView = [[UIView alloc] init];
[_contentView setTranslatesAutoresizingMaskIntoConstraints:NO];
[_containerView addSubview:_contentView];
_contentView.userInteractionEnabled = NO;
_buttonView = [[UIView alloc] init];
[_buttonView setTranslatesAutoresizingMaskIntoConstraints:NO];
[_containerView addSubview:_buttonView];
_actionButtons = [[NSMutableArray alloc] init];
// Set up the title label.
_label = [[UILabel alloc] initWithFrame:CGRectZero];
[_contentView addSubview:_label];
// TODO(#2709): Migrate to a single source of truth for fonts
// If we are using the default (system) font loader, retrieve the
// font from the UIFont standardFont API.
[self updateMessageFont];
NSMutableAttributedString *messageString = [message.attributedText mutableCopy];
if (!_messageFont && !_mdc_adjustsFontForContentSizeCategory) {
// Find any of the bold attributes in the string, and set the proper font for those ranges.
// Use NSAttributedStringEnumerationLongestEffectiveRangeNotRequired as opposed to 0, otherwise
// it will only work if bold text is in the end.
[messageString
enumerateAttribute:MDCSnackbarMessageBoldAttributeName
inRange:NSMakeRange(0, messageString.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:^(id value, NSRange range, __unused BOOL *stop) {
UIFont *font = [MDCTypography body1Font];
if ([value boolValue]) {
font = [MDCTypography body2Font];
}
[messageString setAttributes:@{ NSFontAttributeName : font } range:range];
}];
}
// Apply 'global' attributes along the whole string.
_label.backgroundColor = [UIColor clearColor];
_label.textAlignment = NSTextAlignmentNatural;
_label.adjustsFontSizeToFitWidth = YES;
_label.attributedText = messageString;
_label.numberOfLines = 0;
[_label setTranslatesAutoresizingMaskIntoConstraints:NO];
[_label setContentCompressionResistancePriority:UILayoutPriorityDefaultHigh
forAxis:UILayoutConstraintAxisHorizontal];
[_label setContentHuggingPriority:UILayoutPriorityDefaultLow
forAxis:UILayoutConstraintAxisHorizontal];
NSString *accessibilityHintKey =
kMaterialSnackbarStringTable[kStr_MaterialSnackbarMessageViewTitleA11yHint];
NSString *accessibilityHint =
NSLocalizedStringFromTableInBundle(accessibilityHintKey,
kMaterialSnackbarStringsTableName,
[[self class] bundle],
@"Dismissal accessibility hint for Snackbar");
// For VoiceOver purposes, the label is the primary 'button' for dismissing the Snackbar, so
// we'll make sure the label looks like a button.
_label.accessibilityTraits = UIAccessibilityTraitButton;
_label.accessibilityIdentifier = MDCSnackbarMessageTitleAutomationIdentifier;
_label.accessibilityHint = accessibilityHint;
// If an accessibility label was set on the message model object, use that instead of the text
// in the label.
if ([message.accessibilityLabel length]) {
_label.accessibilityLabel = message.accessibilityLabel;
}
_label.textColor = _messageTextColor;
[self initializeMDCSnackbarMessageViewButtons:message];
}
return self;
}
- (void)initializeMDCSnackbarMessageViewButtons:(MDCSnackbarMessage *)message {
// Figure out how much horizontal space the main text needs, in order to decide if the buttons
// are laid out horizontally or vertically.
__block CGFloat availableTextWidth = UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad
? kMaximumViewWidth_iPad
: kMaximumViewWidth_iPhone;
// Take into account the content padding.
availableTextWidth -= (self.safeContentMargin.left + self.safeContentMargin.right);
// If there are buttons, account for the padding between the title and the buttons.
if (message.action) {
availableTextWidth -= kTitleButtonPadding;
}
// Add buttons to the view. We'll use this opportunity to determine how much space a button will
// need, to inform the layout direction.
NSMutableArray *actions = [NSMutableArray array];
if (message.action) {
UIView *buttonView = [[UIView alloc] init];
[buttonView setTranslatesAutoresizingMaskIntoConstraints:NO];
[_buttonView addSubview:buttonView];
MDCButton *button = [[MDCSnackbarMessageViewButton alloc] init];
[button setTitleColor:_buttonTitleColors[@(UIControlStateNormal)]
forState:UIControlStateNormal];
[button setTitleColor:_buttonTitleColors[@(UIControlStateHighlighted)]
forState:UIControlStateHighlighted];
// TODO: Eventually remove this if statement, buttonTextColor is deprecated.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if (message.buttonTextColor) {
[button setTitleColor:message.buttonTextColor forState:UIControlStateNormal];
}
#pragma clang diagnostic pop
[buttonView addSubview:button];
[_actionButtons addObject:button];
// Set up the button's accessibility values.
button.accessibilityIdentifier = message.action.accessibilityIdentifier;
button.accessibilityHint = message.action.accessibilityHint;
[button setTitle:message.action.title forState:UIControlStateNormal];
[button setTitle:message.action.title forState:UIControlStateHighlighted];
[button addTarget:self
action:@selector(handleButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
CGFloat buttonContentPadding =
MDCSnackbarMessage.usesLegacySnackbar ? kLegacyButtonPadding : kButtonPadding;
CGSize buttonSize = [button sizeThatFits:CGSizeMake(CGFLOAT_MAX,CGFLOAT_MAX)];
availableTextWidth -= buttonSize.width;
availableTextWidth -= 2 * buttonContentPadding;
[actions addObject:buttonView];
}
self.buttons = actions;
[self updateButtonFont];
}
- (void)dismissWithAction:(MDCSnackbarMessageAction *)action userInitiated:(BOOL)userInitiated {
if (self.dismissalHandler) {
self.dismissalHandler(userInitiated, action);
// In case our dismissal handler has a reference to us, release the block.
self.dismissalHandler = nil;
}
}
#pragma mark - Subclass overrides
+ (BOOL)requiresConstraintBasedLayout {
return YES;
}
- (CGFloat)minimumWidth {
return UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad ? kMinimumViewWidth_iPad
: kMinimumViewWidth_iPhone;
}
- (CGFloat)maximumWidth {
return UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad ? kMaximumViewWidth_iPad
: kMaximumViewWidth_iPhone;
}
#pragma mark - Styling the view
- (void)setSnackbarMessageViewBackgroundColor:(UIColor *)snackbarMessageViewBackgroundColor {
_snackbarMessageViewBackgroundColor = snackbarMessageViewBackgroundColor;
self.backgroundColor = snackbarMessageViewBackgroundColor;
}
- (void)setSnackbarShadowColor:(UIColor *)snackbarMessageViewShadowColor {
_snackbarMessageViewShadowColor = snackbarMessageViewShadowColor;
self.layer.shadowColor = snackbarMessageViewShadowColor.CGColor;
}
- (void)setSnackbarMessageViewTextColor:(UIColor *)snackbarMessageViewTextColor {
self.messageTextColor = snackbarMessageViewTextColor;
}
- (UIColor *)snackbarMessageViewTextColor {
return self.messageTextColor;
}
- (void)setMessageTextColor:(UIColor *)messageTextColor {
_messageTextColor = messageTextColor;
if (_messageTextColor) {
self.label.textColor = _messageTextColor;
} else {
self.label.textColor = UIColor.whiteColor;
}
}
- (nullable UIColor *)buttonTitleColorForState:(UIControlState)state {
return _buttonTitleColors[@(state)];
}
- (void)setButtonTitleColor:(nullable UIColor *)buttonTitleColor forState:(UIControlState)state {
_buttonTitleColors[@(state)] = buttonTitleColor;
for (MDCButton *button in _actionButtons) {
if (_buttonTitleColors[@(state)]) {
[button setTitleColor:buttonTitleColor forState:state];
} else {
// Set to default
UIColor *defaultButtonTitleColor;
switch(state) {
case UIControlStateHighlighted:
defaultButtonTitleColor = UIColor.whiteColor;
break;
case UIControlStateNormal:
default:
defaultButtonTitleColor = MDCRGBAColor(0xFF, 0xFF, 0xFF, 0.6f);
break;
}
[button setTitleColor:defaultButtonTitleColor forState:state];
}
}
}
- (void)addColorToMessageLabel:(UIColor *)color {
NSMutableAttributedString *messageString = [_label.attributedText mutableCopy];
[messageString addAttributes:@{
NSForegroundColorAttributeName : color,
}
range:NSMakeRange(0, messageString.length)];
_label.attributedText = messageString;
}
- (UIFont *)messageFont {
return _messageFont;
}
- (void)setMessageFont:(UIFont *)font {
_messageFont = font;
[self updateMessageFont];
}
- (void)updateMessageFont {
// If we have a custom font apply it to the label.
// If not, fall back to the Material specified font.
if (_messageFont) {
// If we are automatically adjusting for Dynamic Type resize the font based on the text style
if (_mdc_adjustsFontForContentSizeCategory) {
_label.font =
[_messageFont mdc_fontSizedForMaterialTextStyle:kMessageTextStyle
scaledForDynamicType:_mdc_adjustsFontForContentSizeCategory];
} else {
_label.font = _messageFont;
}
} else {
// TODO(#2709): Migrate to a single source of truth for fonts
// There is no custom font, so use the default font.
if (_mdc_adjustsFontForContentSizeCategory) {
// If we are using the default (system) font loader, retrieve the
// font from the UIFont preferredFont API.
if ([MDCTypography.fontLoader isKindOfClass:[MDCSystemFontLoader class]]) {
_label.font = [UIFont mdc_preferredFontForMaterialTextStyle:kMessageTextStyle];
} else {
// There is a custom font loader, retrieve the font and scale it.
UIFont *customTypographyFont = [MDCTypography body1Font];
_label.font =
[customTypographyFont mdc_fontSizedForMaterialTextStyle:kMessageTextStyle
scaledForDynamicType:_mdc_adjustsFontForContentSizeCategory];
}
} else {
// If we are using the default (system) font loader, retrieve the
// font from the UIFont standardFont API.
if ([MDCTypography.fontLoader isKindOfClass:[MDCSystemFontLoader class]]) {
_label.font = [UIFont mdc_standardFontForMaterialTextStyle:kMessageTextStyle];
} else {
// There is a custom font loader, retrieve the font from it.
_label.font = [MDCTypography body1Font];
}
}
}
[self setNeedsLayout];
}
- (UIFont *)buttonFont {
return _buttonFont;
}
- (void)setButtonFont:(UIFont *)font {
_buttonFont = font;
[self updateButtonFont];
}
- (void)updateButtonFont {
UIFont *finalButtonFont;
// If we have a custom font apply it to the label.
// If not, fall back to the Material specified font.
if (_buttonFont) {
// If we are automatically adjusting for Dynamic Type resize the font based on the text style
if (_mdc_adjustsFontForContentSizeCategory) {
finalButtonFont =
[_buttonFont mdc_fontSizedForMaterialTextStyle:kButtonTextStyle
scaledForDynamicType:_mdc_adjustsFontForContentSizeCategory];
} else {
finalButtonFont = _buttonFont;
}
} else {
// TODO(#2709): Migrate to a single source of truth for fonts
// There is no custom font, so use the default font.
if (_mdc_adjustsFontForContentSizeCategory) {
// If we are using the default (system) font loader, retrieve the
// font from the UIFont preferredFont API.
if ([MDCTypography.fontLoader isKindOfClass:[MDCSystemFontLoader class]]) {
finalButtonFont = [UIFont mdc_preferredFontForMaterialTextStyle:kButtonTextStyle];
} else {
// There is a custom font loader, retrieve the font and scale it.
UIFont *customTypographyFont = [MDCTypography buttonFont];
finalButtonFont =
[customTypographyFont mdc_fontSizedForMaterialTextStyle:kButtonTextStyle
scaledForDynamicType:_mdc_adjustsFontForContentSizeCategory];
}
} else {
// If we are using the default (system) font loader, retrieve the
// font from the UIFont standardFont API.
if ([MDCTypography.fontLoader isKindOfClass:[MDCSystemFontLoader class]]) {
finalButtonFont = [UIFont mdc_standardFontForMaterialTextStyle:kButtonTextStyle];
} else {
// There is a custom font loader, retrieve the font from it.
finalButtonFont = [MDCTypography buttonFont];
}
}
}
for (MDCButton *button in _actionButtons) {
[button setTitleFont:finalButtonFont forState:UIControlStateNormal];
[button setTitleFont:finalButtonFont forState:UIControlStateHighlighted];
}
[self setNeedsLayout];
}
- (BOOL)shouldWaitForDismissalDuringVoiceover {
return self.message.action != nil;
}
#pragma mark - Constraints and layout
- (void)setAnchoredToScreenBottom:(BOOL)anchoredToScreenBottom {
_anchoredToScreenBottom = anchoredToScreenBottom;
[self invalidateIntrinsicContentSize];
if (self.viewConstraints) {
[self removeConstraints:self.viewConstraints];
self.viewConstraints = nil;
[self updateConstraints];
}
}
- (void)updateConstraints {
[super updateConstraints];
if (self.viewConstraints) {
return;
}
NSMutableArray *constraints = [NSMutableArray array];
[constraints addObjectsFromArray:[self containerViewConstraints]];
[constraints addObjectsFromArray:[self contentViewConstraints]];
[constraints addObjectsFromArray:[self horizontalButtonLayoutConstraints]];
[self addConstraints:constraints];
self.viewConstraints = constraints;
}
/**
Provides constraints to pin the container view to the size of the Snackbar, inset by
@c kBorderWidth. Also positions the content view and button view inside of the container view.
*/
- (NSArray *)containerViewConstraints {
NSDictionary *metrics = @{
@"kBorderMargin" : @(kBorderWidth),
@"kBottomMargin" : @(self.safeContentMargin.bottom),
@"kLeftMargin" : @(self.safeContentMargin.left),
@"kRightMargin" : @(self.safeContentMargin.right),
@"kTitleImagePadding" : @(kTitleImagePadding),
@"kTopMargin" : @(self.safeContentMargin.top),
@"kTitleButtonPadding" : @(kTitleButtonPadding),
@"kContentSafeBottomInset" : @(kBorderWidth + self.contentSafeBottomInset),
@"kMinVerticalButtonPadding": @(kMinVerticalButtonPadding),
};
NSDictionary *views = @{
@"container" : self.containerView,
@"content" : self.contentView,
@"buttons" : self.buttonView,
};
BOOL hasButtons = (self.buttons.count > 0);
NSString *formatString = nil; // Scratch variable.
NSMutableArray *constraints = [NSMutableArray array];
// Pin the left and right edges of the container view to the Snackbar.
formatString = @"H:|-(==kBorderMargin)-[container]-(==kBorderMargin)-|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
// Pin the top and bottom edges of the container view to the Snackbar.
formatString = @"V:|-(==kBorderMargin)-[container]-(==kContentSafeBottomInset)-|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
// Pin the content to the top of the container view.
formatString = @"V:|-(==kTopMargin)-[content]";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
// Pin the content view to the left side of the container.
formatString = @"H:|-(==kLeftMargin)-[content]";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
// If there are no buttons, or the buttons are below the main content, then the content view
// should be pinned to the right side of the container. If there are horizontal buttons, the
// leftmost button will take care of positioning the trailing edge of the label view.
// If there are no buttons, then go ahead and pin the content view to the bottom of the
// container view.
if (!hasButtons) {
formatString = @"V:[content]-(==kBottomMargin)-|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
}
if (!hasButtons) {
// There is nothing to the right of the content, so go ahead and pin it to the trailing edge of
// the container view.
formatString = @"H:[content]-(==kRightMargin)-|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
} else { // This is a horizontal layout, and there are buttons present.
// Align the content and buttons horizontally.
formatString = @"H:[content]-(==kTitleButtonPadding)-[buttons]-(==kRightMargin)-|";
[constraints addObjectsFromArray:
[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:NSLayoutFormatAlignAllCenterY
metrics:metrics
views:views]];
if (MDCSnackbarMessage.usesLegacySnackbar) {
// The buttons should take up the entire height of the container view.
formatString = @"V:|[buttons]|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
} else {
formatString = @"V:|-(>=kMinVerticalButtonPadding)-[buttons]-(>=kMinVerticalButtonPadding)-|";
[constraints addObjectsFromArray:
[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:NSLayoutFormatAlignAllCenterY
metrics:metrics
views:views]];
}
// Pin the content to the bottom of the container view, since there's nothing below.
formatString = @"V:[content]-(==kBottomMargin)-|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
}
return constraints;
}
/**
Provides constraints for the image view and label within the content view.
*/
- (NSArray *)contentViewConstraints {
NSDictionary *metrics = @{
@"kBottomMargin" : @(self.safeContentMargin.bottom),
@"kLeftMargin" : @(self.safeContentMargin.left),
@"kRightMargin" : @(self.safeContentMargin.right),
@"kTitleImagePadding" : @(kTitleImagePadding),
@"kTopMargin" : @(self.safeContentMargin.top),
};
NSMutableDictionary *views = [NSMutableDictionary dictionary];
views[@"label"] = self.label;
if (self.imageView) {
views[@"imageView"] = self.imageView;
}
NSString *formatString = nil; // Scratch variable.
NSMutableArray *constraints = [NSMutableArray array];
// The label should take up the entire height of the content view.
formatString = @"V:|[label]|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
// Pin the label to the trailing edge of the content view.
formatString = @"H:[label]|";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
if (self.imageView) {
// Constrain the image view to be no taller than @c kMaximumImageSize.
formatString = @"V:[imageView(<=kMaximumImageSize)]";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
// Vertically center the image view within the content view.
[constraints addObject:[NSLayoutConstraint constraintWithItem:self.imageView
attribute:NSLayoutAttributeCenterY
relatedBy:NSLayoutRelationEqual
toItem:self.contentView
attribute:NSLayoutAttributeCenterY
multiplier:1.0
constant:0]];
// Constrain the image view to be no wider than @c kMaximumImageSize, and pin it to the leading
// edge of the content view. Pin the label to the trailing edge of the image.
formatString = @"H:|[imageView(<=kMaximumImageSize)]-(==kTitleImagePadding)-[label]";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
} else {
formatString = @"H:|[label]";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
}
return constraints;
}
/**
Provides constraints positioning the buttons horizontally within the button container.
*/
- (NSArray *)horizontalButtonLayoutConstraints {
NSMutableArray *constraints = [NSMutableArray array];
NSDictionary *metrics = @{
@"kLeftMargin" : @(self.safeContentMargin.left),
@"kRightMargin" : @(self.safeContentMargin.right),
@"kTopMargin" : @(self.safeContentMargin.top),
@"kBottomMargin" : @(self.safeContentMargin.bottom),
@"kTitleImagePadding" : @(kTitleImagePadding),
@"kBorderMargin" : @(kBorderWidth),
@"kTitleButtonPadding" : @(kTitleButtonPadding),
@"kButtonPadding" :
@(MDCSnackbarMessage.usesLegacySnackbar ? kLegacyButtonPadding : kButtonPadding),
};
__block UIView *previousButton = nil;
[self.buttons enumerateObjectsUsingBlock:^(UIView *button, NSUInteger idx, __unused BOOL *stop) {
// Convenience dictionary of views.
NSMutableDictionary *views = [NSMutableDictionary dictionary];
views[@"buttonContainer"] = button;
views[@"button"] = [button viewWithTag:kButtonTagStart + idx];
if (previousButton) {
views[@"previousButton"] = previousButton;
}
// In a horizontal layout, the button takes on the height of the Snackbar.
[constraints
addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[buttonContainer]|"
options:0
metrics:metrics
views:views]];
// Pin the button to the height of its container.
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[button]|"
options:0
metrics:metrics
views:views]];
// Pin the button to the width of its container.
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[button]|"
options:0
metrics:metrics
views:views]];
if (idx == 0) {
// The first button should be pinned to the leading edge of the button view.
[constraints addObjectsFromArray:[NSLayoutConstraint
constraintsWithVisualFormat:@"H:|[buttonContainer]"
options:0
metrics:metrics
views:views]];
}
if (idx == ([self.buttons count] - 1)) {
// The last button should be pinned to the trailing edge of the button view.
[constraints addObjectsFromArray:[NSLayoutConstraint
constraintsWithVisualFormat:@"H:[buttonContainer]|"
options:0
metrics:metrics
views:views]];
}
if (previousButton) {
// If there was a button before this one, pin this one to it.
NSString *formatString = @"H:[previousButton]-(==kButtonPadding)-[buttonContainer]";
[constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:formatString
options:0
metrics:metrics
views:views]];
}
}];
return constraints;
}
- (void)layoutSubviews {
[super layoutSubviews];
// As our layout changes, make sure that the shadow path is kept up-to-date.
UIBezierPath *path =
[UIBezierPath bezierPathWithRoundedRect:self.bounds cornerRadius:
MDCSnackbarMessage.usesLegacySnackbar ? kLegacyCornerRadius : kCornerRadius];
self.layer.shadowPath = path.CGPath;
}
#pragma mark - Sizing
- (CGSize)intrinsicContentSize {
CGFloat height = 0;
// Figure out which of the elements is tallest, and use that for calculating our preferred height.
// Images are forced to be no bigger than @c kMaximumImageSize.
height = MAX(height, self.label.intrinsicContentSize.height);
// Make sure that content margins are included in our calculation.
height += self.safeContentMargin.top + self.safeContentMargin.bottom;
// Make sure that the height of the image and text is larger than the minimum height;
height = MAX(kMinimumHeight, height) + self.contentSafeBottomInset;
return CGSizeMake(UIViewNoIntrinsicMetric, height);
}
- (CGFloat)contentSafeBottomInset {
// If a bottom offset has been set to raise the HUD/Snackbar, e.g. above a tab bar, we should ignore
// any safeAreaInsets, since it is no longer 'anchored' to the bottom of the screen. This is set
// by the MDCSnackbarOverlayView whenever the bottomOffset is non-zero.
if (!self.anchoredToScreenBottom || !MDCSnackbarMessage.usesLegacySnackbar) {
return 0;
}
#if defined(__IPHONE_11_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_11_0)
if (@available(iOS 11.0, *)) {
return self.window.safeAreaInsets.bottom;
}
#endif
return 0;
}
- (UIEdgeInsets)safeContentMargin {
UIEdgeInsets contentMargin =
MDCSnackbarMessage.usesLegacySnackbar ? kLegacyContentMargin : kContentMargin;
UIEdgeInsets safeAreaInsets = UIEdgeInsetsZero;
#if defined(__IPHONE_11_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_11_0)
if (@available(iOS 11.0, *)) {
safeAreaInsets = self.window.safeAreaInsets;
}
#endif
// We only take the left and right safeAreaInsets in to account because the bottom is
// handled by contentSafeBottomInset and we will never overlap the top inset.
contentMargin.left = MAX(contentMargin.left, safeAreaInsets.left);
contentMargin.right = MAX(contentMargin.right, safeAreaInsets.right);
return contentMargin;
}
#pragma mark - Event Handlers
- (void)handleBackgroundSwipedRight:(__unused UIButton *)sender {
CABasicAnimation *translationAnimation =
[CABasicAnimation animationWithKeyPath:@"transform.translation.x"];
translationAnimation.toValue = [NSNumber numberWithDouble:-self.frame.size.width];
translationAnimation.duration = MDCSnackbarLegacyTransitionDuration;
translationAnimation.timingFunction =
[CAMediaTimingFunction mdc_functionWithType:MDCAnimationTimingFunctionTranslateOffScreen];
translationAnimation.delegate = self;
translationAnimation.fillMode = kCAFillModeForwards;
translationAnimation.removedOnCompletion = NO;
[self.layer addAnimation:translationAnimation forKey:@"transform.translation.x"];
}
- (void)handleBackgroundSwipedLeft:(__unused UIButton *)sender {
CABasicAnimation *translationAnimation =
[CABasicAnimation animationWithKeyPath:@"transform.translation.x"];
translationAnimation.toValue = [NSNumber numberWithDouble:self.frame.size.width];
translationAnimation.duration = MDCSnackbarLegacyTransitionDuration;
translationAnimation.timingFunction =
[CAMediaTimingFunction mdc_functionWithType:MDCAnimationTimingFunctionTranslateOffScreen];
translationAnimation.delegate = self;
translationAnimation.fillMode = kCAFillModeForwards;
translationAnimation.removedOnCompletion = NO;
[self.layer addAnimation:translationAnimation forKey:@"transform.translation.x"];
}
- (void)handleBackgroundTapped:(__unused UIButton *)sender {
[self dismissWithAction:nil userInitiated:YES];
}
- (void)handleButtonTapped:(__unused UIButton *)sender {
[self dismissWithAction:self.message.action userInitiated:YES];
}
- (void)animationDidStop:(__unused CAAnimation *)theAnimation finished:(BOOL)flag {
if (flag) {
[self dismissWithAction:nil userInitiated:YES];
}
}
#pragma mark - Accessibility
// Override regular accessibility element ordering and ensure label
// is read first before any buttons, if they exist.
- (NSInteger)accessibilityElementCount {
return 1 + [self.buttons count];
}
- (id)accessibilityElementAtIndex:(NSInteger)index {
if (index == 0) {
return _label;
}
return [self.buttons objectAtIndex:(index - 1)];
}
- (NSInteger)indexOfAccessibilityElement:(id)element {
if (element == _label) {
return 0;
}
NSInteger buttonIndex = [self.buttons indexOfObject:element];
if (buttonIndex == NSNotFound) {
return NSNotFound;
}
return buttonIndex + 1;
}
#pragma mark - Animation
- (void)animateContentOpacityFrom:(CGFloat)fromOpacity
to:(CGFloat)toOpacity
duration:(NSTimeInterval)duration
timingFunction:(CAMediaTimingFunction *)timingFunction {
[CATransaction begin];
CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.duration = duration;
opacityAnimation.fromValue = @(fromOpacity);
opacityAnimation.toValue = @(toOpacity);
opacityAnimation.timingFunction = timingFunction;
// The text and the button do not share a common view that can be animated independently of the
// background color, so just animate them both independently here. If this becomes more
// complicated, refactor to add a containing view for both and animate that.
[self.contentView.layer addAnimation:opacityAnimation forKey:@"opacity"];
[self.buttonView.layer addAnimation:opacityAnimation forKey:@"opacity"];
[CATransaction commit];
}
- (CABasicAnimation *)animateSnackbarOpacityFrom:(CGFloat)fromOpacity
to:(CGFloat)toOpacity {
CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.fromValue = @(fromOpacity);
opacityAnimation.toValue = @(toOpacity);
return opacityAnimation;
}
- (CABasicAnimation *)animateSnackbarScaleFrom:(CGFloat)fromScale
toScale:(CGFloat)toScale {
CABasicAnimation *scaleAnimation = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
scaleAnimation.fromValue = [NSNumber numberWithDouble:fromScale];
scaleAnimation.toValue = [NSNumber numberWithDouble:toScale];
return scaleAnimation;
}
#pragma mark - Resource bundle
+ (NSBundle *)bundle {
static NSBundle *bundle = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
bundle = [NSBundle bundleWithPath:[self bundlePathWithName:kMaterialSnackbarBundle]];
});
return bundle;
}
+ (NSString *)bundlePathWithName:(NSString *)bundleName {
// In iOS 8+, we could be included by way of a dynamic framework, and our resource bundles may
// not be in the main .app bundle, but rather in a nested framework, so figure out where we live
// and use that as the search location.
NSBundle *bundle = [NSBundle bundleForClass:[MDCSnackbarMessageView class]];
NSString *resourcePath = [(nil == bundle ? [NSBundle mainBundle] : bundle) resourcePath];
return [resourcePath stringByAppendingPathComponent:bundleName];
}
#pragma mark - Dynamic Type Support
- (BOOL)mdc_adjustsFontForContentSizeCategory {
return _mdc_adjustsFontForContentSizeCategory;
}
- (void)mdc_setAdjustsFontForContentSizeCategory:(BOOL)adjusts {
_mdc_adjustsFontForContentSizeCategory = adjusts;
if (_mdc_adjustsFontForContentSizeCategory) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(contentSizeCategoryDidChange:)
name:UIContentSizeCategoryDidChangeNotification
object:nil];
} else {
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIContentSizeCategoryDidChangeNotification
object:nil];
}
[self updateMessageFont];
[self updateButtonFont];
}
// Handles UIContentSizeCategoryDidChangeNotifications
- (void)contentSizeCategoryDidChange:(__unused NSNotification *)notification {
[self updateMessageFont];
[self updateButtonFont];
}
@end