blob: f4cdaf454cf85c653dd51fb48c4b7226e26da2ab [file] [log] [blame] [edit]
// 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 "MDCAvailability.h"
#import "MDCButton.h"
#import "MDCFlatButton.h"
#import "UIView+MaterialElevationResponding.h"
#import "M3CButton.h"
#import "MDCShadowElevations.h"
#import "MDCShadowLayer.h"
#import "MDCSnackbarManager.h"
#import "MDCSnackbarMessage.h"
#import "MDCSnackbarMessageView.h"
#import "MDCSnackbarMessageViewInternal.h"
#import "MDCSnackbarOverlayView.h"
#import "MaterialSnackbarStrings.h"
#import "MaterialSnackbarStrings_table.h"
#import "MDCFontTextStyle.h"
#import "MDCTypography.h"
#import "UIFont+MaterialTypography.h"
#import "MDCMath.h"
NS_ASSUME_NONNULL_BEGIN
@interface AccessibilityAffordanceButton : UIButton
@end
@implementation AccessibilityAffordanceButton
- (BOOL)accessibilityActivate {
[self sendActionsForControlEvents:UIControlEventTouchUpInside];
return YES;
}
@end
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) / (CGFloat)255
green:(g) / (CGFloat)255
blue:(b) / (CGFloat)255
alpha:(a)];
}
/** Test whether any of the accessibility elements of a view is focused */
static BOOL UIViewHasFocusedAccessibilityElement(UIView *view) {
for (NSInteger i = 0; i < [view accessibilityElementCount]; i++) {
id accessibilityElement = [view accessibilityElementAtIndex:i];
if ([accessibilityElement accessibilityElementIsFocused]) {
return YES;
}
}
return NO;
};
/**
The thickness of the Snackbar border.
*/
static const CGFloat kBorderWidth = 0;
/**
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 const UIEdgeInsets kContentMarginSingleLineText = (UIEdgeInsets){6.0, 16.0, 6.0, 8.0};
static const UIEdgeInsets kContentMarginMutliLineText = (UIEdgeInsets){16.0, 16.0, 16.0, 8.0};
static const UIEdgeInsets kLegacyContentMargin = (UIEdgeInsets){18.0, 24.0, 18.0, 24.0};
/**
Padding between the main title and the first button.
*/
static const CGFloat kTitleButtonPadding = 8;
/**
Padding on the edges of the buttons.
*/
static const CGFloat kLegacyButtonPadding = 5;
static const CGFloat kButtonPadding = 8;
/**
The width of the Snackbar.
*/
static const CGFloat kMinimumViewWidth_iPad = 288;
static const CGFloat kMaximumViewWidth_iPad = 568;
static const CGFloat kMinimumViewWidth_iPhone = 320;
static const CGFloat kMaximumViewWidth_iPhone = 320;
/**
The maximimum ratio of the snackbar that can be occupied by the snackbar button.
*/
static const CGFloat kMaxButtonRatio = 0.333333;
/**
The minimum height of the Snackbar.
*/
static const CGFloat kMinimumHeight = 48;
/**
The minimum height of a snackbar using GM3 shapes.
*/
static const CGFloat kMinimumHeightGM3 = 52;
/**
The minimum height of a multiline Snackbar.
*/
static const CGFloat kMinimumHeightMultiline = 68;
static const MDCFontTextStyle kMessageTextStyle = MDCFontTextStyleBody1;
static const MDCFontTextStyle kButtonTextStyle = MDCFontTextStyleButton;
/**
The minimum font size at which to switch to a vertical layout for dynamic type. Picked to be 1
larger then MDCFontTextStyleBody1 at the largest non-a11y size. Ideally we would not use a constant
here, b/198825058 tracks work to make this more dynamic and less reliant on a magic number.
*/
static const CGFloat kMinimumAccessibiltyFontSize = 21;
@protocol MDCHighlightableScrollViewDelegate
- (void)scrollViewTouchBegan:(UIScrollView *)scrollView;
- (void)scrollViewTouchEnded:(UIScrollView *)scrollView;
- (void)scrollViewTouchCancelled:(UIScrollView *)scrollView;
@end
@interface MDCHighlightableScrollView : UIScrollView
@property(nullable, nonatomic, weak) id<MDCHighlightableScrollViewDelegate> highlightDelegate;
@end
@implementation MDCHighlightableScrollView
- (id)init {
self = [super init];
if (self) {
self.delaysContentTouches = NO;
}
return self;
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
[_highlightDelegate scrollViewTouchBegan:self];
[super touchesBegan:touches withEvent:event];
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
[_highlightDelegate scrollViewTouchEnded:self];
[super touchesEnded:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
[_highlightDelegate scrollViewTouchCancelled:self];
[super touchesCancelled:touches withEvent:event];
}
@end
#if MDC_AVAILABLE_SDK_IOS(10_0)
@interface MDCSnackbarMessageView () <CAAnimationDelegate>
@end
#endif // MDC_AVAILABLE_SDK_IOS(10_0)
@interface MDCSnackbarMessageView () <MDCHighlightableScrollViewDelegate>
/**
Holds the text label for the main message.
*/
@property(nonatomic, strong) UILabel *label;
/**
The constraints managing this view.
*/
@property(nonatomic, strong, nullable) 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 the button view.
*/
@property(nonatomic, strong) UIView *buttonContainer;
/**
The view containing the title view.
*/
@property(nonatomic, strong) UIView *contentView;
/**
The accessibility element representing the dismissal touch affordance.
*/
@property(nonatomic, strong) UIButton *dismissalAccessibilityAffordance;
/**
An invisible hit target to ensure that tapping the horizontal area between the button and the edge
of the screen triggers a button tap instead of dismissing the snackbar.
*/
@property(nonatomic, strong) UIControl *buttonGutterTapTarget;
/**
* The constraint for the snackbar button's width.
*/
@property(nonatomic, strong) NSLayoutConstraint *buttonWidthConstraint;
/**
Holds onto the dismissal handler, called when the Snackbar should dismiss due to user interaction.
*/
@property(nonatomic, copy, nullable) MDCSnackbarMessageDismissHandler dismissalHandler;
@end
@interface MDCSnackbarMessageViewButton : MDCFlatButton
@end
@implementation MDCSnackbarMessageViewButton
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
self.inkColor = [UIColor colorWithWhite:1 alpha:(CGFloat)0.06];
CGFloat buttonContentPadding =
MDCSnackbarMessage.usesLegacySnackbar ? kLegacyButtonPadding : kButtonPadding;
[self setTranslatesAutoresizingMaskIntoConstraints:NO];
// Style the text in the button.
self.titleLabel.numberOfLines = 1;
self.contentHorizontalAlignment = UIControlContentHorizontalAlignmentRight;
self.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter;
self.contentEdgeInsets = UIEdgeInsetsMake(buttonContentPadding, buttonContentPadding,
buttonContentPadding, buttonContentPadding);
// Minimum touch target size (44, 44).
self.minimumSize = CGSizeMake(44, 44);
// 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;
NSMutableDictionary<NSNumber *, UIColor *> *_buttonTitleColors;
BOOL _shouldDismissOnOverlayTap;
BOOL _isMultilineText;
CGFloat _cornerRadius;
BOOL _usesGM3Shapes;
}
@synthesize mdc_overrideBaseElevation = _mdc_overrideBaseElevation;
@synthesize mdc_elevationDidChangeBlock = _mdc_elevationDidChangeBlock;
- (instancetype)initWithFrame:(CGRect)frame {
return [self initWithMessage:nil
dismissHandler:nil
snackbarManager:MDCSnackbarManager.defaultManager];
}
- (instancetype)initWithMessage:(nullable MDCSnackbarMessage *)message
dismissHandler:(nullable MDCSnackbarMessageDismissHandler)handler
snackbarManager:(MDCSnackbarManager *)manager {
self = [super initWithFrame:CGRectZero];
if (self) {
_snackbarMessageViewShadowColor = manager.snackbarMessageViewShadowColor ?: UIColor.blackColor;
_snackbarMessageViewBackgroundColor =
manager.snackbarMessageViewBackgroundColor ?: MDCRGBAColor(0x32, 0x32, 0x32, 1);
_messageTextColor = manager.messageTextColor ?: UIColor.whiteColor;
_buttonTitleColors = [NSMutableDictionary dictionary];
_buttonTitleColors[@(UIControlStateNormal)] =
[manager buttonTitleColorForState:UIControlStateNormal]
?: MDCRGBAColor(0xFF, 0xFF, 0xFF, (float)0.6);
_buttonTitleColors[@(UIControlStateHighlighted)] =
[manager buttonTitleColorForState:UIControlStateHighlighted] ?: UIColor.whiteColor;
#pragma clang diagnostic push
_messageFont = manager.messageFont;
_buttonFont = manager.buttonFont;
_message = message;
_shouldDismissOnOverlayTap = message.shouldDismissOnOverlayTap;
_dismissalHandler = [handler copy];
_mdc_overrideBaseElevation = manager.mdc_overrideBaseElevation;
_enableDismissalAccessibilityAffordance = manager.enableDismissalAccessibilityAffordance;
_traitCollectionDidChangeBlock = manager.traitCollectionDidChangeBlockForMessageView;
_mdc_elevationDidChangeBlock = manager.mdc_elevationDidChangeBlockForMessageView;
self.backgroundColor = _snackbarMessageViewBackgroundColor;
_cornerRadius = MDCSnackbarMessage.usesLegacySnackbar ? kLegacyCornerRadius : kCornerRadius;
self.layer.cornerRadius = _cornerRadius;
_usesGM3Shapes = manager.usesGM3Shapes;
_elevation = _usesGM3Shapes ? MDCShadowElevationNone : manager.messageElevation;
[(MDCShadowLayer *)self.layer setElevation:_elevation];
_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 = _cornerRadius;
_containerView.layer.masksToBounds = YES;
// Listen for taps on the background of the view.
[_containerView addTarget:self
action:@selector(handleBackgroundTapped:)
forControlEvents:UIControlEventTouchUpInside];
if (_usesGM3Shapes) {
[_containerView addTarget:self
action:@selector(highlightBackground)
forControlEvents:UIControlEventTouchDown];
[_containerView addTarget:self
action:@selector(unhighlightBackground)
forControlEvents:UIControlEventTouchDragExit];
}
_buttonGutterTapTarget = [[UIControl alloc] init];
_buttonGutterTapTarget.translatesAutoresizingMaskIntoConstraints = NO;
[_buttonGutterTapTarget addTarget:self
action:@selector(handleButtonGutterTapped:)
forControlEvents:UIControlEventTouchUpInside];
if (_usesGM3Shapes) {
[_containerView addSubview:_buttonGutterTapTarget];
} else {
[self addSubview:_buttonGutterTapTarget];
}
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];
}
NSString *dismissalAccessibilityLabelKey =
kMaterialSnackbarStringTable[kStr_MaterialSnackbarMessageViewDismissalLabel];
NSString *dismissalAccessibilityLabel = NSLocalizedStringFromTableInBundle(
dismissalAccessibilityLabelKey, kMaterialSnackbarStringsTableName, [[self class] bundle],
@"Dismissal accessibility label for Snackbar");
NSString *dismissalAccessibilityHintKey =
kMaterialSnackbarStringTable[kStr_MaterialSnackbarMessageViewTitleA11yHint];
NSString *dismissalAccessibilityHint = NSLocalizedStringFromTableInBundle(
dismissalAccessibilityHintKey, kMaterialSnackbarStringsTableName, [[self class] bundle],
@"Dismissal accessibility hint for Snackbar");
_dismissalAccessibilityAffordance = [[AccessibilityAffordanceButton alloc] init];
_dismissalAccessibilityAffordance.isAccessibilityElement = YES;
_dismissalAccessibilityAffordance.accessibilityLabel = dismissalAccessibilityLabel;
_dismissalAccessibilityAffordance.accessibilityHint = dismissalAccessibilityHint;
[self addSubview:_dismissalAccessibilityAffordance];
_dismissalAccessibilityAffordance.hidden = !_enableDismissalAccessibilityAffordance;
_dismissalAccessibilityAffordance.accessibilityElementsHidden =
!_enableDismissalAccessibilityAffordance;
[_dismissalAccessibilityAffordance addTarget:self
action:@selector(didTapDismissalTouchAffordance:)
forControlEvents:UIControlEventTouchUpInside];
if (MDCSnackbarMessage.usesLegacySnackbar) {
_contentView = [[UIView alloc] init];
_contentView.userInteractionEnabled = NO;
} else {
UIScrollView *contentView;
if (_usesGM3Shapes) {
MDCHighlightableScrollView *highlightScrollView = [[MDCHighlightableScrollView alloc] init];
highlightScrollView.highlightDelegate = self;
contentView = highlightScrollView;
} else {
contentView = [[UIScrollView alloc] init];
}
contentView.indicatorStyle =
self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleLight
? UIScrollViewIndicatorStyleWhite
: UIScrollViewIndicatorStyleBlack;
_contentView = contentView;
// Use a separate gesture recognizer on the scroll view to allow for scrolling content without
// dismissing the snackbar.
UITapGestureRecognizer *tapGesture =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(handleContentTapped:)];
tapGesture.cancelsTouchesInView = NO;
[contentView addGestureRecognizer:tapGesture];
}
[_contentView setTranslatesAutoresizingMaskIntoConstraints:NO];
[_containerView addSubview:_contentView];
_buttonContainer = [[UIView alloc] init];
[_buttonContainer setTranslatesAutoresizingMaskIntoConstraints:NO];
[_containerView addSubview:_buttonContainer];
// 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) {
// 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 = MDCSnackbarMessage.usesLegacySnackbar;
_label.adjustsFontForContentSizeCategory = !MDCSnackbarMessage.usesLegacySnackbar;
_label.attributedText = messageString;
_label.numberOfLines = 0;
[_label setTranslatesAutoresizingMaskIntoConstraints:NO];
[_label setContentCompressionResistancePriority:UILayoutPriorityDefaultHigh
forAxis:UILayoutConstraintAxisHorizontal];
[_label setContentHuggingPriority:UILayoutPriorityDefaultLow
forAxis:UILayoutConstraintAxisHorizontal];
_label.accessibilityIdentifier = MDCSnackbarMessageTitleAutomationIdentifier;
if (!_enableDismissalAccessibilityAffordance) {
// For UIAccessibility purposes, the label is the primary 'button' for dismissing the
// Snackbar, so we'll make sure the label is identified with a dismissal hint.
_label.accessibilityHint = dismissalAccessibilityHint;
}
// If an accessibility label or hint was set on the message model object, use that instead of
// the text in the label or the default hint.
if ([message.accessibilityLabel length]) {
_label.accessibilityLabel = message.accessibilityLabel;
}
if (message.accessibilityHint.length) {
_label.accessibilityHint = message.accessibilityHint;
}
_label.textColor = _messageTextColor;
[self initializeMDCSnackbarMessageViewButtons:message withManager:manager];
}
return self;
}
- (void)initializeMDCSnackbarMessageViewButtons:(MDCSnackbarMessage *)message
withManager:(MDCSnackbarManager *)manager {
// Add button to the view. We'll use this opportunity to determine how much space a button will
// need, to inform the layout direction.
if (!message.action) {
return;
}
UIButton *button;
if (_usesGM3Shapes) {
M3CButton *actionButton = [[M3CButton alloc] init];
[actionButton setTitleColor:_buttonTitleColors[@(UIControlStateNormal)]
forState:UIControlStateNormal];
[actionButton setTitleColor:_buttonTitleColors[@(UIControlStateHighlighted)]
forState:UIControlStateHighlighted];
actionButton.translatesAutoresizingMaskIntoConstraints = NO;
button = actionButton;
} else {
MDCButton *actionButton = [[MDCSnackbarMessageViewButton alloc] init];
[actionButton setTitleColor:_buttonTitleColors[@(UIControlStateNormal)]
forState:UIControlStateNormal];
[actionButton 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) {
[actionButton setTitleColor:message.buttonTextColor forState:UIControlStateNormal];
}
#pragma clang diagnostic pop
actionButton.enableRippleBehavior = message.enableRippleBehavior;
actionButton.uppercaseTitle = manager.uppercaseButtonTitle;
if (manager.buttonInkColor) {
actionButton.inkColor = manager.buttonInkColor;
}
button = actionButton;
}
[_buttonContainer addSubview: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];
BOOL adjustsFont = _usesGM3Shapes || !MDCSnackbarMessage.usesLegacySnackbar;
button.titleLabel.adjustsFontForContentSizeCategory = adjustsFont;
button.titleLabel.adjustsFontSizeToFitWidth = adjustsFont;
self.actionButton = button;
[self updateButtonFont];
}
- (void)dismissWithAction:(nullable MDCSnackbarMessageAction *)action
userInitiated:(BOOL)userInitiated {
if (self.dismissalHandler) {
self.dismissalHandler(userInitiated, action);
// Change focus only if the focus is on this view.
if (self.message.elementToFocusOnDismiss) {
BOOL hasVoiceOverFocus =
UIAccessibilityIsVoiceOverRunning() && UIViewHasFocusedAccessibilityElement(self);
if (hasVoiceOverFocus) {
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,
self.message.elementToFocusOnDismiss);
}
}
// In case our dismissal handler has a reference to us, release the block.
self.dismissalHandler = nil;
}
}
+ (Class)layerClass {
return [MDCShadowLayer class];
}
- (void)setElevation:(MDCShadowElevation)elevation {
BOOL elevationChanged = !MDCCGFloatEqual(_elevation, elevation);
_elevation = elevation;
[(MDCShadowLayer *)self.layer setElevation:_elevation];
if (elevationChanged) {
[self mdc_elevationDidChange];
}
}
- (void)setEnableDismissalAccessibilityAffordance:(BOOL)enableDismissalAccessibilityAffordance {
if (_enableDismissalAccessibilityAffordance == enableDismissalAccessibilityAffordance) {
return;
}
_enableDismissalAccessibilityAffordance = enableDismissalAccessibilityAffordance;
_dismissalAccessibilityAffordance.hidden = !enableDismissalAccessibilityAffordance;
_dismissalAccessibilityAffordance.accessibilityElementsHidden =
!enableDismissalAccessibilityAffordance;
NSString *accessibilityHintKey =
kMaterialSnackbarStringTable[kStr_MaterialSnackbarMessageViewTitleA11yHint];
NSString *accessibilityHint = NSLocalizedStringFromTableInBundle(
accessibilityHintKey, kMaterialSnackbarStringsTableName, [[self class] bundle],
@"Dismissal accessibility hint for Snackbar");
if (enableDismissalAccessibilityAffordance) {
if ([_label.accessibilityHint isEqualToString:accessibilityHint]) {
_label.accessibilityHint = nil;
}
} else {
if (![_label.accessibilityHint length]) {
_label.accessibilityHint = accessibilityHint;
}
}
}
- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (self.traitCollectionDidChangeBlock) {
self.traitCollectionDidChangeBlock(self, previousTraitCollection);
}
}
- (NSString *)description {
NSString *messageString = self.message.description;
NSMutableString *description = [[NSMutableString alloc] init];
[description appendFormat:@"%@ {\n", [super description]];
[description appendFormat:@" message: %@;\n",
[messageString stringByReplacingOccurrencesOfString:@"\n"
withString:@"\n "]];
[description appendString:@"}"];
return [description copy];
}
#pragma mark - Subclass overrides
+ (BOOL)requiresConstraintBasedLayout {
return YES;
}
- (CGFloat)minimumWidth {
return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad
? kMinimumViewWidth_iPad
: kMinimumViewWidth_iPhone;
}
- (CGFloat)maximumWidth {
return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad
? kMaximumViewWidth_iPad
: kMaximumViewWidth_iPhone;
}
#pragma mark - Styling the view
- (void)setSnackbarMessageViewBackgroundColor:
(nullable UIColor *)snackbarMessageViewBackgroundColor {
_snackbarMessageViewBackgroundColor = snackbarMessageViewBackgroundColor;
self.backgroundColor = snackbarMessageViewBackgroundColor;
}
- (void)setSnackbarShadowColor:(UIColor *)snackbarMessageViewShadowColor {
_snackbarMessageViewShadowColor = snackbarMessageViewShadowColor;
self.layer.shadowColor = snackbarMessageViewShadowColor.CGColor;
}
- (void)setMessageTextColor:(nullable 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;
if (self.actionButton) {
if (_buttonTitleColors[@(state)]) {
[self.actionButton 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, (float)0.6);
break;
}
[self.actionButton 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;
}
- (nullable UIFont *)messageFont {
return _messageFont;
}
- (void)setMessageFont:(nullable 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) {
_label.font = _messageFont;
} 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];
}
- (nullable UIFont *)buttonFont {
return _buttonFont;
}
- (void)setButtonFont:(nullable 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) {
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 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];
}
}
if ([self.actionButton isKindOfClass:[MDCButton class]]) {
MDCButton *button = (MDCButton *)self.actionButton;
[button setTitleFont:finalButtonFont forState:UIControlStateNormal];
[button setTitleFont:finalButtonFont forState:UIControlStateHighlighted];
} else {
self.actionButton.titleLabel.font = finalButtonFont;
}
[self setNeedsLayout];
}
- (CGFloat)cornerRadius {
return _cornerRadius;
}
- (void)setCornerRadius:(CGFloat)cornerRadius {
_cornerRadius = cornerRadius;
self.layer.cornerRadius = _cornerRadius;
_containerView.layer.cornerRadius = _cornerRadius;
[self setNeedsLayout];
}
- (BOOL)shouldWaitForDismissalDuringVoiceover {
return self.message.action != nil;
}
#pragma mark - Constraints and layout
- (NSInteger)numberOfLines {
CGSize maxLabelSize = self.label.intrinsicContentSize;
CGFloat lineHeight = self.label.font.lineHeight;
return (NSInteger)round(maxLabelSize.height / lineHeight);
}
- (void)resetConstraints {
[self removeConstraints:self.viewConstraints];
self.viewConstraints = nil;
[self setNeedsUpdateConstraints];
}
- (void)setAnchoredToScreenBottom:(BOOL)anchoredToScreenBottom {
_anchoredToScreenBottom = anchoredToScreenBottom;
[self invalidateIntrinsicContentSize];
if (self.viewConstraints) {
[self resetConstraints];
}
}
- (void)updateConstraints {
if (self.viewConstraints) {
[super updateConstraints];
return;
}
NSMutableArray *constraints = [NSMutableArray array];
[constraints addObjectsFromArray:[self containerViewConstraints]];
[constraints addObjectsFromArray:[self contentViewConstraints]];
[constraints addObjectsFromArray:[self horizontalButtonLayoutConstraints]];
[self addConstraints:constraints];
[self updateButtonWidthConstraint];
self.viewConstraints = constraints;
[super updateConstraints];
}
- (void)updatePreferredMaxLayoutWidth {
if (!MDCSnackbarMessage.usesLegacySnackbar) {
UIEdgeInsets safeContentMargin = self.safeContentMargin;
CGFloat availableWidth =
self.bounds.size.width - safeContentMargin.left - safeContentMargin.right;
BOOL shouldUseHorizontalLayout = ![self shouldUseVerticalLayout];
// Account for the action button if present and the layout is horizontal.
if (shouldUseHorizontalLayout && self.actionButton) {
availableWidth = availableWidth - [self actionButtonWidth] - kTitleButtonPadding;
} else if (_usesGM3Shapes) {
// If the text spans the width of the snackbar (either because it's using vertical layout or
// because there's no action), the left and right margins should match, so we'll replace the
// `right` margin with the `left` margin.
availableWidth = availableWidth + safeContentMargin.right - safeContentMargin.left;
}
self.label.preferredMaxLayoutWidth = availableWidth;
}
}
- (void)updateButtonWidthConstraint {
BOOL shouldUseHorizontalLayout = ![self shouldUseVerticalLayout];
if (shouldUseHorizontalLayout && self.actionButton) {
self.buttonWidthConstraint.constant = [self actionButtonWidth];
}
}
- (CGFloat)actionButtonWidth {
CGFloat availableWidth = self.bounds.size.width - self.safeContentMargin.left -
self.safeContentMargin.right - kTitleButtonPadding;
if (availableWidth < 0) {
return self.actionButton.intrinsicContentSize.width;
}
CGFloat textWidth =
[self.label textRectForBounds:CGRectInfinite limitedToNumberOfLines:1].size.width;
// Pick a size that is either, the remainder of the available space when the label is small and
// fits on one line, 1/3 of the space when 1/3 of the space is larger than what is left by the
// label or the button size itself when it is smaller than either the available spaced or 1/3 of
// the width.
return MIN(self.actionButton.intrinsicContentSize.width,
MAX(kMaxButtonRatio * availableWidth, availableWidth - textWidth));
}
- (CGFloat)minimumLayoutHeight {
if ([self shouldUseVerticalLayout]) {
return self.actionButton.intrinsicContentSize.height + self.label.font.lineHeight +
self.safeContentMargin.top + self.safeContentMargin.bottom;
} else {
return MAX(self.actionButton.intrinsicContentSize.height, self.label.font.lineHeight) +
self.safeContentMargin.top + self.safeContentMargin.bottom;
}
}
/**
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 {
UIEdgeInsets safeContentMargin = self.safeContentMargin;
CGFloat contentSafeBottomInset = kBorderWidth + self.contentSafeBottomInset;
// In GM3, we want the text's leading/trailing padding to be equal if it spans the full width of
// the snackbar, so we're using the `left` margin here, instead of the `right` margin.
CGFloat fullWidthTextTrailingMargin =
_usesGM3Shapes ? safeContentMargin.left : safeContentMargin.right;
BOOL hasButtons = self.actionButton != nil;
NSMutableArray *constraints = [NSMutableArray array];
[constraints addObjectsFromArray:@[
// Pin the left and right edges of the container view to the Snackbar.
[self.containerView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor
constant:kBorderWidth],
[self.containerView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor
constant:-kBorderWidth],
// Pin the top and bottom edges of the container view to the Snackbar.
[self.containerView.topAnchor constraintEqualToAnchor:self.topAnchor constant:kBorderWidth],
[self.containerView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor
constant:-contentSafeBottomInset],
// Pin the content to the top of the container view.
[self.contentView.topAnchor constraintEqualToAnchor:self.containerView.topAnchor
constant:self.safeContentMargin.top],
// Pin the content view to the left side of the container.
[self.contentView.leadingAnchor constraintEqualToAnchor:self.containerView.leadingAnchor
constant:self.safeContentMargin.left],
]];
if (hasButtons) {
if (MDCSnackbarMessage.usesLegacySnackbar) {
[constraints addObjectsFromArray:@[
// Align the content and buttons horizontally.
[self.contentView.trailingAnchor constraintEqualToAnchor:self.buttonContainer.leadingAnchor
constant:-kTitleButtonPadding],
[self.buttonGutterTapTarget.widthAnchor constraintEqualToConstant:safeContentMargin.right],
[self.buttonContainer.trailingAnchor
constraintEqualToAnchor:self.buttonGutterTapTarget.leadingAnchor],
[self.buttonGutterTapTarget.trailingAnchor
constraintEqualToAnchor:self.containerView.trailingAnchor],
// The buttons should take up the entire height of the container view.
[self.buttonContainer.topAnchor constraintEqualToAnchor:self.containerView.topAnchor],
[self.buttonContainer.bottomAnchor constraintEqualToAnchor:self.containerView.bottomAnchor],
// The button gutter tap target should take up the entire height of the container view.
[self.buttonGutterTapTarget.topAnchor constraintEqualToAnchor:self.containerView.topAnchor],
[self.buttonGutterTapTarget.bottomAnchor
constraintEqualToAnchor:self.containerView.bottomAnchor],
// Pin the content to the bottom of the container view, since there's nothing below.
[self.contentView.bottomAnchor constraintEqualToAnchor:self.containerView.bottomAnchor
constant:-safeContentMargin.bottom]
]];
} else { // Modern snackbar layout.
if ([self shouldUseVerticalLayout]) {
[constraints addObjectsFromArray:@[
// Align the content and buttons vertically.
[self.contentView.bottomAnchor constraintEqualToAnchor:self.buttonContainer.topAnchor
constant:-kTitleButtonPadding],
// Pin the trailing edge of the contentView to its superview.
[self.contentView.trailingAnchor constraintEqualToAnchor:self.containerView.trailingAnchor
constant:-fullWidthTextTrailingMargin],
// Make the leading edge of the button container less than the size of the view.
[self.buttonContainer.leadingAnchor
constraintGreaterThanOrEqualToAnchor:self.containerView.leadingAnchor
constant:self.safeContentMargin.left],
// Pin the bottom edge of the button to the bottom of the container view.
[self.buttonContainer.bottomAnchor constraintEqualToAnchor:self.containerView.bottomAnchor
constant:-safeContentMargin.bottom],
]];
// Make sure the button takes up no more space than needed.
[self.buttonContainer setContentHuggingPriority:UILayoutPriorityRequired
forAxis:UILayoutConstraintAxisHorizontal];
} else { // Horizontal layout.
[constraints addObjectsFromArray:@[
// Align the content and buttons horizontally.
[self.contentView.trailingAnchor
constraintEqualToAnchor:self.buttonContainer.leadingAnchor
constant:-kTitleButtonPadding],
// Pin the top/bottom of the button container to its parent view.
[self.buttonContainer.topAnchor constraintEqualToAnchor:self.containerView.topAnchor],
[self.buttonContainer.bottomAnchor
constraintEqualToAnchor:self.containerView.bottomAnchor],
// Pin the content to the bottom of the container view, since there's nothing below.
[self.contentView.bottomAnchor constraintEqualToAnchor:self.containerView.bottomAnchor
constant:-safeContentMargin.bottom],
// Constrain the button container to a third of the width of its parent view to ensure
// button and text content are always visible.
self.buttonWidthConstraint = [self.buttonContainer.widthAnchor
constraintLessThanOrEqualToConstant:self.frame.size.width]
]];
}
// The below constraints are shared by both vertical and horizontal layouts.
[constraints addObjectsFromArray:@[
// Pin the button container to the trailing edge of the container view.
[self.buttonContainer.trailingAnchor
constraintEqualToAnchor:self.containerView.trailingAnchor
constant:-self.safeContentMargin.right],
]];
if (_usesGM3Shapes) {
[constraints addObjectsFromArray:@[
[self.buttonGutterTapTarget.leadingAnchor
constraintEqualToAnchor:self.buttonContainer.leadingAnchor],
[self.buttonGutterTapTarget.trailingAnchor
constraintEqualToAnchor:self.containerView.trailingAnchor],
[self.buttonGutterTapTarget.topAnchor
constraintEqualToAnchor:self.buttonContainer.topAnchor],
[self.buttonGutterTapTarget.bottomAnchor
constraintEqualToAnchor:self.containerView.bottomAnchor],
]];
}
}
} else { // There is not an action button present.
[constraints addObjectsFromArray:@[
// If there are no buttons, then go ahead and pin the content view to the bottom and trailing
// edges of the container view.
[self.contentView.bottomAnchor constraintEqualToAnchor:self.containerView.bottomAnchor
constant:-self.safeContentMargin.bottom],
[self.contentView.trailingAnchor constraintEqualToAnchor:self.containerView.trailingAnchor
constant:-fullWidthTextTrailingMargin]
]];
}
return constraints;
}
/**
Provides constraints for the label within the content view.
*/
- (NSArray *)contentViewConstraints {
NSMutableArray *constraints = [NSMutableArray array];
[constraints addObjectsFromArray:@[
// The label should take up the entire height of the content view.
[self.label.topAnchor constraintEqualToAnchor:self.contentView.topAnchor],
[self.label.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor],
// Pin the label to the trailing edge of the content view.
[self.label.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor],
[self.label.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor],
[self.label.widthAnchor constraintEqualToAnchor:self.contentView.widthAnchor],
]];
// Allow the content height constraint to break so that label will take up the content view
// height but will grow beyond it and scroll if needed.
NSLayoutConstraint *heightConstraint =
[self.contentView.heightAnchor constraintEqualToAnchor:self.label.heightAnchor];
heightConstraint.priority = UILayoutPriorityDefaultHigh;
[constraints addObject:heightConstraint];
return constraints;
}
/**
Provides constraints positioning the buttons horizontally within the button container.
*/
- (NSArray *)horizontalButtonLayoutConstraints {
NSMutableArray *constraints = [NSMutableArray array];
if (self.actionButton) {
[constraints addObjectsFromArray:@[
// Ensure that the button is vertically centered in its container view
[self.actionButton.centerYAnchor constraintEqualToAnchor:self.buttonContainer.centerYAnchor],
[self.buttonContainer.heightAnchor
constraintGreaterThanOrEqualToAnchor:self.actionButton.heightAnchor],
// Pin the button to the width of its container.
[self.actionButton.leadingAnchor constraintEqualToAnchor:self.buttonContainer.leadingAnchor],
[self.actionButton.trailingAnchor
constraintEqualToAnchor:self.buttonContainer.trailingAnchor],
]];
}
return constraints;
}
- (void)layoutSubviews {
[super layoutSubviews];
[self updatePreferredMaxLayoutWidth];
[self updateButtonWidthConstraint];
#if !TARGET_OS_VISION
if (!self.dismissalAccessibilityAffordance.hidden) {
// Update frame of the dismissal touch affordance.
CGRect globalFrame = [self convertRect:self.bounds toView:nil];
CGRect globalDismissalAreaFrame =
CGRectMake(0, 0, CGRectGetWidth([UIScreen mainScreen].bounds), CGRectGetMinY(globalFrame));
CGRect localDismissalAreaFrame = [self convertRect:globalDismissalAreaFrame fromView:nil];
self.dismissalAccessibilityAffordance.frame = localDismissalAreaFrame;
}
#endif // TODO: b/359236816 - fix visionOS-specific compatibility workarounds.
BOOL isMultilineText = [self numberOfLines] > 1;
if (_isMultilineText != isMultilineText) {
_isMultilineText = isMultilineText;
[self resetConstraints];
}
// As our layout changes, make sure that the shadow path is kept up-to-date.
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:self.bounds
cornerRadius:_cornerRadius];
self.layer.shadowPath = path.CGPath;
self.layer.shadowColor = self.snackbarMessageViewShadowColor.CGColor;
[self invalidateIntrinsicContentSize];
}
#pragma mark - Sizing
- (CGSize)intrinsicContentSize {
CGFloat height = 0;
[self updatePreferredMaxLayoutWidth];
// Figure out which of the elements is tallest, and use that for calculating our preferred height.
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 text is larger than the minimum height;
CGFloat minimumHeight = _isMultilineText ? kMinimumHeightMultiline
: _usesGM3Shapes ? kMinimumHeightGM3
: kMinimumHeight;
height = MAX(minimumHeight, height) + self.contentSafeBottomInset;
if ([self shouldUseVerticalLayout]) {
height += self.actionButton.intrinsicContentSize.height + kTitleButtonPadding;
}
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;
}
return self.window.safeAreaInsets.bottom;
}
- (UIEdgeInsets)safeContentMargin {
if (MDCSnackbarMessage.usesLegacySnackbar) {
return kLegacyContentMargin;
} else {
return _isMultilineText || [self shouldUseVerticalLayout] ? kContentMarginMutliLineText
: kContentMarginSingleLineText;
}
}
#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 functionWithName:kCAMediaTimingFunctionEaseIn];
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 functionWithName:kCAMediaTimingFunctionEaseIn];
translationAnimation.delegate = self;
translationAnimation.fillMode = kCAFillModeForwards;
translationAnimation.removedOnCompletion = NO;
[self.layer addAnimation:translationAnimation forKey:@"transform.translation.x"];
}
- (void)highlightBackground {
if (_snackbarMessageViewHighlightColor) {
self.backgroundColor = _snackbarMessageViewHighlightColor;
}
}
- (void)unhighlightBackground {
self.backgroundColor = _snackbarMessageViewBackgroundColor;
}
- (void)handleBackgroundTapped:(__unused UIButton *)sender {
BOOL accessibilityEnabled =
UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning();
if (accessibilityEnabled && self.enableDismissalAccessibilityAffordance &&
self.label.accessibilityElementIsFocused) {
// When enableDismissalAccessibilityAffordance is YES, tapping on background of the container
// shouldn't dismiss snackbar.
return;
}
[self dismissWithAction:nil userInitiated:YES];
}
- (void)handleContentTapped:(__unused UITapGestureRecognizer *)sender {
BOOL scrollViewShouldScroll = NO;
if (!MDCSnackbarMessage.usesLegacySnackbar &&
[self.contentView isKindOfClass:UIScrollView.class]) {
// If the scroll view can scroll allow touches to scroll content.
UIScrollView *contentView = (UIScrollView *)self.contentView;
scrollViewShouldScroll = contentView.contentSize.height > contentView.bounds.size.height;
}
if (!scrollViewShouldScroll) {
BOOL accessibilityEnabled =
UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning();
if (accessibilityEnabled && self.enableDismissalAccessibilityAffordance &&
self.label.accessibilityElementIsFocused) {
// When enableDismissalAccessibilityAffordance is YES, tapping on the content
// shouldn't dismiss snackbar.
return;
}
[self dismissWithAction:nil userInitiated:YES];
}
}
- (void)handleButtonGutterTapped:(__unused UIControl *)sender {
[self handleButtonTapped:nil];
}
- (void)handleButtonTapped:(nullable __unused UIButton *)sender {
[self dismissWithAction:self.message.action userInitiated:YES];
}
- (void)didTapDismissalTouchAffordance:(__unused UIControl *)sender {
[self dismissWithAction:nil userInitiated:YES];
}
- (void)animationDidStop:(__unused CAAnimation *)theAnimation finished:(BOOL)flag {
if (flag) {
[self dismissWithAction:nil userInitiated:YES];
}
}
#pragma mark - MDCHighlightableScrollViewDelegate
- (void)scrollViewTouchBegan:(UIScrollView *)scrollView {
[self highlightBackground];
}
- (void)scrollViewTouchEnded:(UIScrollView *)scrollView {
[self unhighlightBackground];
}
- (void)scrollViewTouchCancelled:(UIScrollView *)scrollView {
[self unhighlightBackground];
}
#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.actionButton ? 1 : 0) +
(self.dismissalAccessibilityAffordance.accessibilityElementsHidden ? 0 : 1);
}
- (nullable id)accessibilityElementAtIndex:(NSInteger)index {
if (index == 0) {
return _label;
} else if (index == 1 && self.actionButton) {
return self.actionButton;
}
if (!self.dismissalAccessibilityAffordance.accessibilityElementsHidden) {
return self.dismissalAccessibilityAffordance;
}
return nil;
}
- (NSInteger)indexOfAccessibilityElement:(id)element {
if (element == _label) {
return 0;
} else if (element == _actionButton) {
return 1;
} else if (element == _dismissalAccessibilityAffordance &&
!self.dismissalAccessibilityAffordance.accessibilityElementsHidden) {
return _actionButton ? 2 : 1;
}
return NSNotFound;
}
#pragma mark - Animation
- (void)animateContentOpacityFrom:(CGFloat)fromOpacity
to:(CGFloat)toOpacity
duration:(NSTimeInterval)duration
timingFunction:(nullable 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.buttonContainer.layer addAnimation:opacityAnimation forKey:@"opacity"];
[CATransaction commit];
}
- (nullable CABasicAnimation *)animateSnackbarOpacityFrom:(CGFloat)fromOpacity
to:(CGFloat)toOpacity {
CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.fromValue = @(fromOpacity);
opacityAnimation.toValue = @(toOpacity);
return opacityAnimation;
}
- (nullable 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];
}
- (BOOL)shouldUseVerticalLayout {
if (MDCSnackbarMessage.usesLegacySnackbar) {
return false;
}
BOOL actionIsLong = self.actionButton != nil &&
self.actionButton.intrinsicContentSize.width > self.maximumWidth * 0.4;
BOOL textIsLarge = UIContentSizeCategoryIsAccessibilityCategory(
self.traitCollection.preferredContentSizeCategory) &&
self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular &&
self.label.font.pointSize > kMinimumAccessibiltyFontSize;
return actionIsLong || textIsLarge;
}
#pragma mark - Elevation
- (CGFloat)mdc_currentElevation {
return self.elevation;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event {
// This check determines if pointInside has been called with an "empty" touch parameter.
// An empty touch is a UIEvent of type UIEventTypeTouch, with a nil `allTouches` property.
// The check's purpose is prevention of instant snackbar dismissal when VoiceControl is enabled.
// If VoiceControl is enabled, pointInside is called with an empty touch when a snackbar appears.
// If VoiceOver is enabled, pointInside is called with an empty touch when focusing an element.
// This check does not prevent taps & swipes from passing through to the accessibilityAffordance
// check below when VoiceOver or SwitchControl are enabled.
if ([event allTouches] == nil && event.type == UIEventTypeTouches &&
event.subtype == UIEventSubtypeNone) {
return [super pointInside:point withEvent:event];
}
BOOL result = [super pointInside:point withEvent:event];
BOOL accessibilityEnabled =
UIAccessibilityIsVoiceOverRunning() || UIAccessibilityIsSwitchControlRunning();
if (accessibilityEnabled && self.enableDismissalAccessibilityAffordance &&
CGRectContainsPoint(self.dismissalAccessibilityAffordance.frame, point)) {
// Count @c dismissalAccessibilityAffordance as hittable when VoiceOver is running.
return YES;
}
if (!result && !accessibilityEnabled && _shouldDismissOnOverlayTap) {
[self dismissWithAction:nil userInitiated:YES];
}
return result;
}
@end
NS_ASSUME_NONNULL_END