blob: bd7311aca43bc05d858d23839841ba2190a5fe0f [file] [log] [blame]
/*
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 "MDCAlertController.h"
#import "MDCDialogTransitionController.h"
#import "MaterialButtons.h"
#import "MaterialRTL.h"
#import "MaterialTypography.h"
#import "private/MaterialDialogsStrings.h"
#import "private/MaterialDialogsStrings_table.h"
// The Bundle for string resources.
static NSString *const kMaterialDialogsBundle = @"MaterialDialogs.bundle";
@interface MDCAlertAction ()
@property(nonatomic, nullable, copy) MDCActionHandler completionHandler;
@end
@implementation MDCAlertAction
+ (instancetype)actionWithTitle:(nonnull NSString *)title
handler:(void (^__nullable)(MDCAlertAction *action))handler {
return [[MDCAlertAction alloc] initWithTitle:title handler:handler];
}
- (instancetype)initWithTitle:(nonnull NSString *)title
handler:(void (^__nullable)(MDCAlertAction *action))handler {
self = [super init];
if (self) {
_title = [title copy];
_completionHandler = [handler copy];
}
return self;
}
- (id)copyWithZone:(__unused NSZone *)zone {
MDCAlertAction *action = [[self class] actionWithTitle:self.title handler:self.completionHandler];
return action;
}
@end
// https://material.io/guidelines/components/dialogs.html#dialogs-specs
static const UIEdgeInsets MDCDialogContentInsets = {24.0, 24.0, 24.0, 24.0};
static const CGFloat MDCDialogContentVerticalPadding = 20.0;
static const UIEdgeInsets MDCDialogActionsInsets = {8.0, 8.0, 8.0, 8.0};
static const CGFloat MDCDialogActionsHorizontalPadding = 8.0;
static const CGFloat MDCDialogActionsVerticalPadding = 8.0;
static const CGFloat MDCDialogActionButtonHeight = 36.0;
static const CGFloat MDCDialogActionButtonMinimumWidth = 48.0;
static const CGFloat MDCDialogMessageOpacity = 0.54f;
@interface MDCAlertController ()
@property(nonatomic, nonnull, strong) NSMutableArray<UIButton *> *actionButtons;
@property(nonatomic, strong) UIScrollView *contentScrollView;
@property(nonatomic, strong) UIScrollView *actionsScrollView;
@property(nonatomic, strong) UILabel *titleLabel;
@property(nonatomic, strong) UILabel *messageLabel;
@property(nonatomic, getter=isVerticalActionsLayout) BOOL verticalActionsLayout;
@property(nonatomic, strong) MDCDialogTransitionController *transitionController;
- (nonnull instancetype)initWithTitle:(nullable NSString *)title
message:(nullable NSString *)message;
@end
@implementation MDCAlertController {
NSString *_alertTitle;
NSString *_message;
NSMutableArray<MDCAlertAction *> *_actions;
CGSize _previousLayoutSize;
BOOL _mdc_adjustsFontForContentSizeCategory;
}
+ (instancetype)alertControllerWithTitle:(nullable NSString *)alertTitle
message:(nullable NSString *)message {
MDCAlertController *alertController =
[[MDCAlertController alloc] initWithTitle:alertTitle message:message];
return alertController;
}
- (nonnull instancetype)initWithTitle:(nullable NSString *)title
message:(nullable NSString *)message {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_alertTitle = [title copy];
_message = [message copy];
_contentScrollView = [[UIScrollView alloc] initWithFrame:CGRectZero];
_actionsScrollView = [[UIScrollView alloc] initWithFrame:CGRectZero];
_actions = [[NSMutableArray alloc] init];
_actionButtons = [[NSMutableArray alloc] init];
_transitionController = [[MDCDialogTransitionController alloc] init];
super.transitioningDelegate = _transitionController;
super.modalPresentationStyle = UIModalPresentationCustom;
}
return self;
}
/* Disable setter. Always use internal transition controller */
- (void)setTransitioningDelegate:
(__unused id<UIViewControllerTransitioningDelegate>)transitioningDelegate {
NSAssert(NO, @"MDCAlertController.transitioningDelegate cannot be changed.");
return;
}
/* Disable setter. Always use custom presentation style */
- (void)setModalPresentationStyle:(__unused UIModalPresentationStyle)modalPresentationStyle {
NSAssert(NO, @"MDCAlertController.modalPresentationStyle cannot be changed.");
return;
}
- (NSString *)title {
return _alertTitle;
}
- (void)setTitle:(NSString *)title {
_alertTitle = [title copy];
self.titleLabel.text = _alertTitle;
self.preferredContentSize = [self calculatePreferredContentSizeForBounds:CGRectInfinite.size];
[self.view setNeedsLayout];
}
- (NSString *)message {
return _message;
}
- (void)setMessage:(NSString *)message {
_message = [message copy];
self.messageLabel.text = _message;
self.preferredContentSize = [self calculatePreferredContentSizeForBounds:CGRectInfinite.size];
[self.view setNeedsLayout];
}
- (NSArray<MDCAlertAction *> *)actions {
return [_actions copy];
}
- (void)addAction:(MDCAlertAction *)action {
[_actions addObject:[action copy]];
MDCFlatButton *actionButton = [[MDCFlatButton alloc] initWithFrame:CGRectZero];
actionButton.mdc_adjustsFontForContentSizeCategory = self.mdc_adjustsFontForContentSizeCategory;
[actionButton setTitle:action.title forState:UIControlStateNormal];
// TODO(#1726): Determine default text color values for Normal and Disabled
[actionButton sizeToFit];
CGRect buttonRect = actionButton.bounds;
buttonRect.size.height = MAX(buttonRect.size.height, MDCDialogActionButtonHeight);
buttonRect.size.width = MAX(buttonRect.size.width, MDCDialogActionButtonMinimumWidth);
actionButton.frame = buttonRect;
[actionButton addTarget:self
action:@selector(actionButtonPressed:)
forControlEvents:UIControlEventTouchUpInside];
[self.actionsScrollView addSubview:actionButton];
[self.actionButtons addObject:actionButton];
self.preferredContentSize = [self calculatePreferredContentSizeForBounds:CGRectInfinite.size];
[self.view setNeedsLayout];
}
- (BOOL)mdc_adjustsFontForContentSizeCategory {
return _mdc_adjustsFontForContentSizeCategory;
}
- (void)mdc_setAdjustsFontForContentSizeCategory:(BOOL)adjusts {
_mdc_adjustsFontForContentSizeCategory = adjusts;
// Update any action buttons to match the alert controller.
for (MDCButton *actionButton in self.actionButtons) {
actionButton.mdc_adjustsFontForContentSizeCategory = adjusts;
}
if (_mdc_adjustsFontForContentSizeCategory) {
[self updateFontsForDynamicType];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(contentSizeCategoryDidChange:)
name:UIContentSizeCategoryDidChangeNotification
object:nil];
} else {
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIContentSizeCategoryDidChangeNotification
object:nil];
}
}
// Handles UIContentSizeCategoryDidChangeNotifications
- (void)contentSizeCategoryDidChange:(__unused NSNotification *)notification {
[self updateFontsForDynamicType];
}
// Update the fonts used based on mdc_preferredFontForMaterialTextStyle and recalculate the
// preferred content size.
- (void)updateFontsForDynamicType {
self.titleLabel.font = [UIFont mdc_preferredFontForMaterialTextStyle:MDCFontTextStyleTitle];
self.messageLabel.font = [UIFont mdc_preferredFontForMaterialTextStyle:MDCFontTextStyleBody1];
// The action MDCButtons handle their own resizing
// Our presentation controller reacts to changes to preferredContentSize to determine our
// frame at the presented controller.
self.preferredContentSize = [self calculatePreferredContentSizeForBounds:CGRectInfinite.size];
}
- (void)actionButtonPressed:(id)sender {
NSInteger actionIndex = [self.actionButtons indexOfObject:sender];
MDCAlertAction *action = self.actions[actionIndex];
if (action.completionHandler) {
action.completionHandler(action);
}
[self.presentingViewController dismissViewControllerAnimated:YES completion:NULL];
}
#pragma mark - UIViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.view.autoresizesSubviews = NO;
self.view.clipsToBounds = YES;
self.contentScrollView.backgroundColor = [UIColor whiteColor];
[self.view addSubview:self.contentScrollView];
self.actionsScrollView.backgroundColor = [UIColor whiteColor];
[self.view addSubview:self.actionsScrollView];
self.titleLabel = [[UILabel alloc] initWithFrame:CGRectZero];
self.titleLabel.numberOfLines = 0;
self.titleLabel.textAlignment = NSTextAlignmentNatural;
if (self.mdc_adjustsFontForContentSizeCategory) {
self.titleLabel.font = [UIFont mdc_preferredFontForMaterialTextStyle:MDCFontTextStyleTitle];
} else {
self.titleLabel.font = [MDCTypography titleFont];
}
[self.contentScrollView addSubview:self.titleLabel];
self.messageLabel = [[UILabel alloc] initWithFrame:CGRectZero];
self.messageLabel.numberOfLines = 0;
self.messageLabel.textAlignment = NSTextAlignmentNatural;
if (self.mdc_adjustsFontForContentSizeCategory) {
self.messageLabel.font = [UIFont mdc_preferredFontForMaterialTextStyle:MDCFontTextStyleBody1];
} else {
self.messageLabel.font = [MDCTypography body1Font];
}
self.messageLabel.textColor = [UIColor colorWithWhite:0.0 alpha:MDCDialogMessageOpacity];
[self.contentScrollView addSubview:self.messageLabel];
self.titleLabel.text = self.title;
self.messageLabel.text = self.message;
CGSize idealSize = [self calculatePreferredContentSizeForBounds:CGRectInfinite.size];
self.preferredContentSize = idealSize;
_previousLayoutSize = CGSizeZero;
[self.view setNeedsLayout];
NSString *key =
kMaterialDialogsStringTable[kStr_MaterialDialogsPresentedAccessibilityAnnouncement];
NSString *announcement = NSLocalizedStringFromTableInBundle(key,
kMaterialDialogsStringsTableName,
[[self class] bundle],
@"Alert");
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,
announcement);
}
#pragma mark - Resource bundle
+ (NSBundle *)bundle {
static NSBundle *bundle = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
bundle = [NSBundle bundleWithPath:[self bundlePathWithName:kMaterialDialogsBundle]];
});
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:[MDCAlertController class]];
NSString *resourcePath = [(nil == bundle ? [NSBundle mainBundle] : bundle) resourcePath];
return [resourcePath stringByAppendingPathComponent:bundleName];
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
// Recalculate preferredSize, which is based on width available, if the viewSize has changed.
if (CGRectGetWidth(self.view.bounds) != _previousLayoutSize.width ||
CGRectGetHeight(self.view.bounds) != _previousLayoutSize.height) {
CGSize currentPreferredContentSize = self.preferredContentSize;
CGSize calculatedPreferredContentSize =
[self calculatePreferredContentSizeForBounds:CGRectStandardize(self.view.bounds).size];
if (!CGSizeEqualToSize(currentPreferredContentSize, calculatedPreferredContentSize)) {
// NOTE: Setting the preferredContentSize can lead to a change to self.view.bounds.
self.preferredContentSize = calculatedPreferredContentSize;
}
_previousLayoutSize = CGRectStandardize(self.view.bounds).size;
}
// Used to calculate the height of the scrolling content, so we limit the width.
CGSize boundsSize = CGRectInfinite.size;
boundsSize.width = CGRectGetWidth(self.view.bounds);
// Content
CGSize contentSize = [self calculateContentSizeThatFitsWidth:boundsSize.width];
CGRect contentRect = CGRectZero;
contentRect.size.width = CGRectGetWidth(self.view.bounds);
contentRect.size.height = contentSize.height;
self.contentScrollView.contentSize = contentRect.size;
// Place Content in contentScrollView
boundsSize.width = boundsSize.width - MDCDialogContentInsets.left - MDCDialogContentInsets.right;
CGSize titleSize = [self.titleLabel sizeThatFits:boundsSize];
titleSize.width = boundsSize.width;
CGSize messageSize = [self.messageLabel sizeThatFits:boundsSize];
messageSize.width = boundsSize.width;
boundsSize.width = boundsSize.width + MDCDialogContentInsets.left + MDCDialogContentInsets.right;
CGFloat contentInternalVerticalPadding = 0.0;
if (0.0 < titleSize.height && 0.0 < messageSize.height) {
contentInternalVerticalPadding = MDCDialogContentVerticalPadding;
}
CGRect titleFrame = CGRectMake(MDCDialogContentInsets.left, MDCDialogContentInsets.top,
titleSize.width, titleSize.height);
CGRect messageFrame = CGRectMake(MDCDialogContentInsets.left,
CGRectGetMaxY(titleFrame) + contentInternalVerticalPadding,
messageSize.width, messageSize.height);
self.titleLabel.frame = titleFrame;
self.messageLabel.frame = messageFrame;
// Actions
CGSize actionSize = [self calculateActionsSizeThatFitsWidth:boundsSize.width];
const CGFloat horizontalActionHeight =
MDCDialogActionsInsets.top + MDCDialogActionButtonHeight + MDCDialogActionsInsets.bottom;
if (horizontalActionHeight < actionSize.height) {
self.verticalActionsLayout = YES;
} else {
self.verticalActionsLayout = NO;
}
CGRect actionsFrame = CGRectZero;
actionsFrame.size.width = CGRectGetWidth(self.view.bounds);
if (0 < [self.actions count]) {
actionsFrame.size.height = actionSize.height;
}
self.actionsScrollView.contentSize = actionsFrame.size;
// Place buttons in actionsScrollView
if (self.isVerticalActionsLayout) {
CGPoint buttonCenter;
buttonCenter.x = self.actionsScrollView.contentSize.width * 0.5f;
buttonCenter.y = self.actionsScrollView.contentSize.height - MDCDialogActionsInsets.bottom;
for (UIButton *button in self.actionButtons) {
CGRect buttonRect = button.frame;
buttonCenter.y -= buttonRect.size.height * 0.5;
button.center = buttonCenter;
if (button != [self.actionButtons lastObject]) {
buttonCenter.y -= buttonRect.size.height * 0.5;
buttonCenter.y -= MDCDialogActionsVerticalPadding;
}
}
} else {
CGPoint buttonOrigin = CGPointZero;
buttonOrigin.x = self.actionsScrollView.contentSize.width - MDCDialogActionsInsets.right;
buttonOrigin.y = MDCDialogActionsInsets.top;
for (UIButton *button in self.actionButtons) {
CGRect buttonRect = button.frame;
buttonOrigin.x -= buttonRect.size.width;
buttonRect.origin = buttonOrigin;
button.frame = buttonRect;
if (button != [self.actionButtons lastObject]) {
buttonOrigin.x -= MDCDialogActionsHorizontalPadding;
}
}
// Handle RTL
if (self.view.mdc_effectiveUserInterfaceLayoutDirection ==
UIUserInterfaceLayoutDirectionRightToLeft) {
for (UIButton *button in self.actionButtons) {
CGRect buttonRect = button.frame;
CGRect flippedRect = MDCRectFlippedForRTL(buttonRect, CGRectGetWidth(self.view.bounds),
UIUserInterfaceLayoutDirectionRightToLeft);
button.frame = flippedRect;
}
}
}
// Place scrollviews
CGRect contentScrollViewRect = CGRectZero;
contentScrollViewRect.size = self.contentScrollView.contentSize;
CGRect actionsScrollViewRect = CGRectZero;
actionsScrollViewRect.size = self.actionsScrollView.contentSize;
const CGFloat requestedHeight =
self.contentScrollView.contentSize.height + self.actionsScrollView.contentSize.height;
if (requestedHeight <= CGRectGetHeight(self.view.bounds)) {
// Simple layout case : both content and actions fit on the screen at once
self.contentScrollView.frame = contentScrollViewRect;
actionsScrollViewRect.origin.y =
CGRectGetHeight(self.view.bounds) - actionsScrollViewRect.size.height;
self.actionsScrollView.frame = actionsScrollViewRect;
} else {
// Complex layout case : Split the space between the two scrollviews
if (CGRectGetHeight(contentScrollViewRect) < CGRectGetHeight(self.view.bounds) * 0.5f) {
actionsScrollViewRect.size.height =
CGRectGetHeight(self.view.bounds) - contentScrollViewRect.size.height;
} else {
CGFloat maxActionsHeight = CGRectGetHeight(self.view.bounds) * 0.5f;
actionsScrollViewRect.size.height = MIN(maxActionsHeight, actionsScrollViewRect.size.height);
}
actionsScrollViewRect.origin.y =
CGRectGetHeight(self.view.bounds) - actionsScrollViewRect.size.height;
self.actionsScrollView.frame = actionsScrollViewRect;
contentScrollViewRect.size.height =
CGRectGetHeight(self.view.bounds) - actionsScrollViewRect.size.height;
self.contentScrollView.frame = contentScrollViewRect;
}
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
[coordinator animateAlongsideTransition:
^(__unused id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
// Reset preferredContentSize on viewWIllTransition to take advantage of additional width
self.preferredContentSize =
[self calculatePreferredContentSizeForBounds:CGRectInfinite.size];
}
completion:nil];
}
#pragma mark - Internal
// @param boundingWidth should not include any internal margins or padding
- (CGSize)calculateContentSizeThatFitsWidth:(CGFloat)boundingWidth {
CGSize boundsSize = CGRectInfinite.size;
boundsSize.width = boundingWidth - MDCDialogContentInsets.left - MDCDialogContentInsets.right;
CGSize titleSize = [self.titleLabel sizeThatFits:boundsSize];
CGSize messageSize = [self.messageLabel sizeThatFits:boundsSize];
CGFloat contentWidth = MAX(titleSize.width, messageSize.width);
contentWidth += MDCDialogContentInsets.left + MDCDialogContentInsets.right;
CGFloat contentInternalVerticalPadding = 0.0;
if (0.0 < titleSize.height && 0.0 < messageSize.height) {
contentInternalVerticalPadding = MDCDialogContentVerticalPadding;
}
CGFloat contentHeight = titleSize.height + contentInternalVerticalPadding + messageSize.height;
contentHeight += MDCDialogContentInsets.top + MDCDialogContentInsets.bottom;
CGSize contentSize;
contentSize.width = (CGFloat)ceil(contentWidth);
contentSize.height = (CGFloat)ceil(contentHeight);
return contentSize;
}
// @param boundingWidth should not include any internal margins or padding
- (CGSize)calculateActionsSizeThatFitsWidth:(CGFloat)boundingWidth {
CGSize boundsSize = CGRectInfinite.size;
boundsSize.width = boundingWidth;
CGSize horizontalSize = [self actionButtonsSizeInHorizontalLayout];
CGSize verticalSize = [self actionButtonsSizeInVericalLayout];
CGSize actionsSize;
if (boundsSize.width < horizontalSize.width) {
// Use VerticalLayout
actionsSize.width = MIN(verticalSize.width, boundsSize.width);
actionsSize.height = MIN(verticalSize.height, boundsSize.height);
} else {
// Use HorizontalLayout
actionsSize.width = MIN(horizontalSize.width, boundsSize.width);
actionsSize.height = MIN(horizontalSize.height, boundsSize.height);
}
actionsSize.width = (CGFloat)ceil(actionsSize.width);
actionsSize.height = (CGFloat)ceil(actionsSize.height);
return actionsSize;
}
// @param boundsSize should not include any internal margins or padding
- (CGSize)calculatePreferredContentSizeForBounds:(CGSize)boundsSize {
// Content & Actions
CGSize contentSize = [self calculateContentSizeThatFitsWidth:boundsSize.width];
CGSize actionSize = [self calculateActionsSizeThatFitsWidth:boundsSize.width];
// Final Sizing
CGSize totalSize;
totalSize.width = MAX(contentSize.width, actionSize.width);
totalSize.height = contentSize.height + actionSize.height;
return totalSize;
}
- (CGSize)actionButtonsSizeInHorizontalLayout {
CGSize size = CGSizeZero;
if (0 < [self.actions count]) {
size.height =
MDCDialogActionsInsets.top + MDCDialogActionButtonHeight + MDCDialogActionsInsets.bottom;
size.width = MDCDialogActionsInsets.left + MDCDialogActionsInsets.right;
for (UIButton *button in self.actionButtons) {
size.width += CGRectGetWidth(button.bounds);
if (button != [self.actionButtons lastObject]) {
size.width += MDCDialogActionsHorizontalPadding;
}
}
}
return size;
}
- (CGSize)actionButtonsSizeInVericalLayout {
CGSize size = CGSizeZero;
if (0 < [self.actions count]) {
size.height = MDCDialogActionsInsets.top + MDCDialogActionsInsets.bottom;
size.width = MDCDialogActionsInsets.left + MDCDialogActionsInsets.right;
for (UIButton *button in self.actionButtons) {
size.height += CGRectGetHeight(button.bounds);
size.width = MAX(size.width, CGRectGetWidth(button.bounds));
if (button != [self.actionButtons lastObject]) {
size.height += MDCDialogActionsVerticalPadding;
}
}
}
return size;
}
#pragma mark - UIAccessibilityAction
- (BOOL)accessibilityPerformEscape {
[self.presentingViewController dismissViewControllerAnimated:YES completion:NULL];
return YES;
}
@end