blob: b5c600497a377a19ed7f6b87dd97245e8e861ae7 [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 "MDCButton.h"
#import <UIKit/UIKit.h>
#import "private/MDCButton+Subclassing.h"
#import "UIView+MaterialElevationResponding.h"
#import "MDCInkView.h"
#import "MDCRippleView.h"
#import "MDCStatefulRippleView.h"
#import "M3CAnimationActions.h"
#import "MDCShadow.h"
#import "MDCShadowsCollection.h"
#import "MDCShadowElevations.h"
#import "MDCRoundedCornerTreatment.h"
#import "MDCRectangleShapeGenerator.h"
#import "MDCShapeMediator.h"
#import "MDCShapedShadowLayer.h"
#import "MDCFontTextStyle.h"
#import "MDCTypography.h"
#import "UIFont+MaterialScalable.h"
#import "UIFont+MaterialTypography.h"
#import "MDCMath.h"
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
// For code review, use the review queue listed inĀ go/material-visionos-review.
#define IS_VISIONOS 1
#else
#define IS_VISIONOS 0
#endif
// TODO(ajsecord): Animate title color when animating between enabled/disabled states.
// Non-trivial: http://corecocoa.wordpress.com/2011/10/04/animatable-text-color-of-uilabel/
static const CGFloat MDCButtonMinimumTouchTargetHeight = 44;
static const CGFloat MDCButtonMinimumTouchTargetWidth = 44;
static const CGFloat MDCButtonDefaultCornerRadius = 2.0;
static const CGFloat kDefaultRippleAlpha = (CGFloat)0.12;
static const NSTimeInterval MDCButtonAnimationDuration = 0.2;
// https://material.io/go/design-buttons#buttons-main-buttons
static const CGFloat MDCButtonDisabledAlpha = (CGFloat)0.12;
// Blue 500 from https://material.io/go/design-color-theming#color-color-palette .
static const uint32_t MDCButtonDefaultBackgroundColor = 0x191919;
// KVO contexts
static char *const kKVOContextCornerRadius = "kKVOContextCornerRadius";
// Creates a UIColor from a 24-bit RGB color encoded as an integer.
static inline UIColor *MDCColorFromRGB(uint32_t rgbValue) {
return [UIColor colorWithRed:((CGFloat)((rgbValue & 0xFF0000) >> 16)) / 255
green:((CGFloat)((rgbValue & 0x00FF00) >> 8)) / 255
blue:((CGFloat)((rgbValue & 0x0000FF) >> 0)) / 255
alpha:1];
}
// Expands size by provided edge insets.
static inline CGSize CGSizeExpandWithInsets(CGSize size, UIEdgeInsets edgeInsets) {
return CGSizeMake(size.width + edgeInsets.left + edgeInsets.right,
size.height + edgeInsets.top + edgeInsets.bottom);
}
// Shrinks size by provided edge insets, and also standardized.
static inline CGSize CGSizeShrinkWithInsets(CGSize size, UIEdgeInsets edgeInsets) {
return CGSizeMake(MAX(0, size.width - (edgeInsets.left + edgeInsets.right)),
MAX(0, size.height - (edgeInsets.top + edgeInsets.bottom)));
}
static NSAttributedString *UppercaseAttributedString(NSAttributedString *string) {
// Store the attributes.
NSMutableArray<NSDictionary *> *attributes = [NSMutableArray array];
[string enumerateAttributesInRange:NSMakeRange(0, [string length])
options:0
usingBlock:^(NSDictionary *attrs, NSRange range, __unused BOOL *stop) {
[attributes addObject:@{
@"attrs" : attrs,
@"range" : [NSValue valueWithRange:range]
}];
}];
// Make the string uppercase.
NSString *uppercaseString = [[string string] uppercaseStringWithLocale:[NSLocale currentLocale]];
// Apply the text and attributes to a mutable copy of the title attributed string.
NSMutableAttributedString *mutableString = [string mutableCopy];
[mutableString replaceCharactersInRange:NSMakeRange(0, [string length])
withString:uppercaseString];
for (NSDictionary *attribute in attributes) {
[mutableString setAttributes:attribute[@"attrs"] range:[attribute[@"range"] rangeValue]];
}
return [mutableString copy];
}
@interface MDCButton () {
// For each UIControlState.
NSMutableDictionary<NSNumber *, NSNumber *> *_userElevations;
NSMutableDictionary<NSNumber *, UIColor *> *_backgroundColors;
NSMutableDictionary<NSNumber *, UIColor *> *_borderColors;
NSMutableDictionary<NSNumber *, NSNumber *> *_borderWidths;
NSMutableDictionary<NSNumber *, UIColor *> *_shadowColors;
NSMutableDictionary<NSNumber *, UIColor *> *_imageTintColors;
NSMutableDictionary<NSNumber *, UIFont *> *_fonts;
CGFloat _enabledAlpha;
BOOL _hasCustomDisabledTitleColor;
BOOL _imageTintStatefulAPIEnabled;
// Cached titles and accessibility labels.
NSMutableDictionary<NSNumber *, id> *_nontransformedTitles;
NSString *_accessibilityLabelExplicitValue;
BOOL _mdc_adjustsFontForContentSizeCategory;
BOOL _cornerRadiusObserverAdded;
CGFloat _inkMaxRippleRadius;
MDCShapeMediator *_shapedLayer;
CGFloat _currentElevation;
}
@property(nonatomic, strong, readonly, nonnull) MDCStatefulRippleView *rippleView;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
@property(nonatomic, strong) MDCInkView *inkView;
#pragma clang diagnostic pop
@property(nonatomic, readonly, strong) MDCShapedShadowLayer *layer;
@property(nonatomic, assign) BOOL accessibilityTraitsIncludesButton;
@property(nonatomic, assign) BOOL enableTitleFontForState;
@property(nonatomic, assign) UIEdgeInsets visibleAreaInsets;
@property(nonatomic, strong) UIView *visibleAreaLayoutGuideView;
@property(nonatomic) UIEdgeInsets hitAreaInsets;
@property(nonatomic, assign) UIEdgeInsets currentVisibleAreaInsets;
@property(nonatomic, assign) CGSize lastRecordedIntrinsicContentSize;
// Used only when layoutTitleWithConstraints is enabled.
@property(nonatomic, strong) NSLayoutConstraint *titleTopConstraint;
@property(nonatomic, strong) NSLayoutConstraint *titleBottomConstraint;
@property(nonatomic, strong) NSLayoutConstraint *titleLeadingConstraint;
@property(nonatomic, strong) NSLayoutConstraint *titleTrailingConstraint;
@end
@implementation MDCButton
static BOOL gEnablePerformantShadow = NO;
@synthesize mdc_overrideBaseElevation = _mdc_overrideBaseElevation;
@synthesize mdc_elevationDidChangeBlock = _mdc_elevationDidChangeBlock;
@synthesize visibleAreaInsets = _visibleAreaInsets;
@synthesize visibleAreaLayoutGuide = _visibleAreaLayoutGuide;
@synthesize shadowsCollection = _shadowsCollection;
@dynamic layer;
+ (Class)layerClass {
if (gEnablePerformantShadow) {
return [super layerClass];
} else {
return [MDCShapedShadowLayer class];
}
}
- (instancetype)init {
return [self initWithFrame:CGRectZero];
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
// Set up title label attributes.
// TODO(#2709): Have a single source of truth for fonts
// Migrate to [UIFont standardFont] when possible
self.titleLabel.font = [MDCTypography buttonFont];
[self commonMDCButtonInit];
[self updateBackgroundColor];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
[self commonMDCButtonInit];
if (self.titleLabel.font) {
_fonts = [@{} mutableCopy];
_fonts[@(UIControlStateNormal)] = self.titleLabel.font;
}
// Storyboards will set the backgroundColor via the UIView backgroundColor setter, so we have
// to write that in to our _backgroundColors dictionary.
_backgroundColors[@(UIControlStateNormal)] = gEnablePerformantShadow
? _shapedLayer.shapedBackgroundColor
: self.layer.shapedBackgroundColor;
[self updateBackgroundColor];
}
return self;
}
- (void)commonMDCButtonInit {
// TODO(b/142861610): Default to `NO`, then remove once all internal usage is migrated.
_enableTitleFontForState = YES;
if (gEnablePerformantShadow) {
_shapedLayer = [[MDCShapeMediator alloc] initWithViewLayer:self.layer];
}
_disabledAlpha = MDCButtonDisabledAlpha;
_enabledAlpha = self.alpha;
_uppercaseTitle = YES;
_userElevations = [NSMutableDictionary dictionary];
_nontransformedTitles = [NSMutableDictionary dictionary];
_borderColors = [NSMutableDictionary dictionary];
_imageTintColors = [NSMutableDictionary dictionary];
_borderWidths = [NSMutableDictionary dictionary];
_fonts = [NSMutableDictionary dictionary];
_accessibilityTraitsIncludesButton = YES;
_mdc_overrideBaseElevation = -1;
_currentElevation = 0;
if (!_backgroundColors) {
// _backgroundColors may have already been initialized by setting the backgroundColor setter.
_backgroundColors = [NSMutableDictionary dictionary];
_backgroundColors[@(UIControlStateNormal)] = MDCColorFromRGB(MDCButtonDefaultBackgroundColor);
}
// Disable default highlight state.
self.adjustsImageWhenHighlighted = NO;
self.tintAdjustmentMode = UIViewTintAdjustmentModeNormal;
#if (!defined(TARGET_OS_TV) || TARGET_OS_TV == 0)
self.showsTouchWhenHighlighted = NO;
#endif
self.layer.cornerRadius = MDCButtonDefaultCornerRadius;
if (gEnablePerformantShadow) {
self.layer.shadowColor = MDCShadowColor().CGColor;
} else {
self.layer.shadowColor = [UIColor blackColor].CGColor;
self.layer.elevation = [self elevationForState:self.state];
}
_shadowColors = [NSMutableDictionary dictionary];
_shadowColors[@(UIControlStateNormal)] = [UIColor colorWithCGColor:self.layer.shadowColor];
// Set up ink layer.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
_inkView = [[MDCInkView alloc] initWithFrame:self.bounds];
#pragma clang diagnostic pop
_inkView.usesLegacyInkRipple = NO;
[self insertSubview:_inkView belowSubview:self.imageView];
// UIButton has a drag enter/exit boundary that is outside of the frame of the button itself.
// Because this is not exposed externally, we can't use -touchesMoved: to calculate when to
// change ink state. So instead we fall back on adding target/actions for these specific events.
[self addTarget:self
action:@selector(touchDragEnter:forEvent:)
forControlEvents:UIControlEventTouchDragEnter];
[self addTarget:self
action:@selector(touchDragExit:forEvent:)
forControlEvents:UIControlEventTouchDragExit];
#if (!defined(TARGET_OS_TV) || TARGET_OS_TV == 0)
// Block users from activating multiple buttons simultaneously by default.
self.exclusiveTouch = YES;
#endif
_inkView.inkColor = [UIColor colorWithWhite:1 alpha:(CGFloat)0.2];
_rippleView = [[MDCStatefulRippleView alloc] initWithFrame:self.bounds];
_rippleColor = [UIColor colorWithWhite:0 alpha:kDefaultRippleAlpha];
_rippleView.rippleColor = [UIColor colorWithWhite:0 alpha:(CGFloat)0.12];
// Default content insets
// The default contentEdgeInsets are set here (instead of above, as they were previously) because
// of a UIButton bug introduced in the iOS 13 betas that is unresolved as of Xcode 11 beta 4
// (b/136088498) wherein setting self.contentEdgeInsets before accessing self.imageView causes the
// imageView's bounds.origin to be set to { -(i.left + i.right), -(i.top + i.bottom) }
// This causes images created by using imageWithHorizontallyFlippedOrientation to not display.
// Images that have not been created this way seem to be fine.
// This behavior can also be seen in vanilla UIButtons by setting contentEdgeInsets to non-zero
// inset values and then setting an image created with imageWithHorizontallyFlippedOrientation.
self.contentEdgeInsets = [self defaultContentEdgeInsets];
_minimumSize = CGSizeZero;
_maximumSize = CGSizeZero;
// Uppercase all titles
if (_uppercaseTitle) {
[self updateTitleCase];
}
}
- (void)dealloc {
[self removeTarget:self action:NULL forControlEvents:UIControlEventAllEvents];
if (_cornerRadiusObserverAdded) {
[self.layer removeObserver:self
forKeyPath:NSStringFromSelector(@selector(cornerRadius))
context:kKVOContextCornerRadius];
}
}
- (void)setUnderlyingColorHint:(UIColor *)underlyingColorHint {
_underlyingColorHint = underlyingColorHint;
[self updateAlphaAndBackgroundColorAnimated:NO];
}
- (void)setAlpha:(CGFloat)alpha {
_enabledAlpha = alpha;
[super setAlpha:alpha];
}
- (void)setDisabledAlpha:(CGFloat)disabledAlpha {
_disabledAlpha = disabledAlpha;
[self updateAlphaAndBackgroundColorAnimated:NO];
}
#pragma mark - UIView
- (void)layoutSubviews {
[super layoutSubviews];
[self updateShadowColor];
[self updateBackgroundColor];
[self updateBorderColor];
if (self.centerVisibleArea) {
UIEdgeInsets visibleAreaInsets = self.visibleAreaInsets;
if (!UIEdgeInsetsEqualToEdgeInsets(visibleAreaInsets, self.currentVisibleAreaInsets)) {
self.currentVisibleAreaInsets = visibleAreaInsets;
MDCRectangleShapeGenerator *shapeGenerator =
[self generateShapeWithCornerRadius:self.layer.cornerRadius
visibleAreaInsets:visibleAreaInsets];
[self configureLayerWithShapeGenerator:shapeGenerator];
if (self.visibleAreaLayoutGuideView) {
self.visibleAreaLayoutGuideView.frame =
UIEdgeInsetsInsetRect(self.bounds, visibleAreaInsets);
}
}
}
if (gEnablePerformantShadow) {
if (_shapedLayer.shapeGenerator) {
[_shapedLayer layoutShapedSublayers];
}
[self updateShadow];
} else {
if (!self.layer.shapeGenerator) {
self.layer.shadowPath = [self boundingPath].CGPath;
}
}
// Center unbounded ink view frame taking into account possible insets using contentRectForBounds.
if (_inkView.inkStyle == MDCInkStyleUnbounded && _inkView.usesLegacyInkRipple) {
CGRect contentRect = [self contentRectForBounds:self.bounds];
CGPoint contentCenterPoint =
CGPointMake(CGRectGetMidX(contentRect), CGRectGetMidY(contentRect));
CGPoint boundsCenterPoint = CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
CGFloat offsetX = contentCenterPoint.x - boundsCenterPoint.x + self.inkViewOffset.width;
CGFloat offsetY = contentCenterPoint.y - boundsCenterPoint.y + self.inkViewOffset.height;
_inkView.frame =
CGRectMake(offsetX, offsetY, CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds));
} else {
CGRect bounds = CGRectStandardize(self.bounds);
bounds = CGRectOffset(bounds, self.inkViewOffset.width, self.inkViewOffset.height);
bounds = UIEdgeInsetsInsetRect(bounds, self.rippleEdgeInsets);
_inkView.frame = bounds;
self.rippleView.frame = bounds;
}
#if IS_VISIONOS
UITraitCollection *current = [UITraitCollection currentTraitCollection];
self.titleLabel.frame =
MDCRectAlignToScale(self.titleLabel.frame, current ? [current displayScale] : 1.0);
#else
self.titleLabel.frame = MDCRectAlignToScale(self.titleLabel.frame, [UIScreen mainScreen].scale);
#endif
if ([self shouldInferMinimumAndMaximumSize]) {
[self inferMinimumAndMaximumSize];
}
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
// If there are custom hitAreaInsets, use those
if (!UIEdgeInsetsEqualToEdgeInsets(self.hitAreaInsets, UIEdgeInsetsZero)) {
return CGRectContainsPoint(
UIEdgeInsetsInsetRect(CGRectStandardize(self.bounds), self.hitAreaInsets), point);
}
// If the bounds are smaller than the minimum touch target, produce a warning once
CGFloat width = CGRectGetWidth(self.bounds);
CGFloat height = CGRectGetHeight(self.bounds);
if (width < MDCButtonMinimumTouchTargetWidth || height < MDCButtonMinimumTouchTargetHeight) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSLog(
@"Button touch target does not meet minimum size guidelines of (%0.f, %0.f). Button: %@, "
@"Touch Target: %@",
MDCButtonMinimumTouchTargetWidth, MDCButtonMinimumTouchTargetHeight, [self description],
NSStringFromCGSize(CGSizeMake(width, height)));
});
}
return [super pointInside:point withEvent:event];
}
- (void)willMoveToSuperview:(UIView *)newSuperview {
[super willMoveToSuperview:newSuperview];
[self.inkView cancelAllAnimationsAnimated:NO];
[self.rippleView cancelAllRipplesAnimated:NO completion:nil];
}
- (CGSize)sizeThatFits:(CGSize)size {
CGSize givenSizeWithInsets = CGSizeShrinkWithInsets(size, _visibleAreaInsets);
CGSize superSize = [super sizeThatFits:givenSizeWithInsets];
// TODO(b/171816831): revisit this in a future iOS version to verify reproducibility.
// Because of a UIKit bug in iOS 13 and 14 (current), buttons that have both an image and text
// will not return an appropriately large size from [super sizeThatFits:]. In this case, we need
// to expand the width. The number 1 was chosen somewhat arbitrarily, but based on some spot
// testing, adding the smallest amount of extra width possible seems to fix the issue.
#if defined(__IPHONE_13_0) && (__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_13_0)
if (@available(iOS 13.0, *)) {
if (UIAccessibilityIsBoldTextEnabled() && [self imageForState:UIControlStateNormal] &&
[self titleForState:UIControlStateNormal]) {
superSize.width += 1;
}
}
#endif
if (self.minimumSize.height > 0) {
superSize.height = MAX(self.minimumSize.height, superSize.height);
}
if (self.maximumSize.height > 0) {
superSize.height = MIN(self.maximumSize.height, superSize.height);
}
if (self.minimumSize.width > 0) {
superSize.width = MAX(self.minimumSize.width, superSize.width);
}
if (self.maximumSize.width > 0) {
superSize.width = MIN(self.maximumSize.width, superSize.width);
}
CGSize adjustedSize = CGSizeExpandWithInsets(superSize, _visibleAreaInsets);
return adjustedSize;
}
- (CGSize)intrinsicContentSize {
CGSize size = [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
self.lastRecordedIntrinsicContentSize = size;
return size;
}
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (self.traitCollectionDidChangeBlock) {
self.traitCollectionDidChangeBlock(self, previousTraitCollection);
}
}
#pragma mark - UIResponder
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
if (self.enableRippleBehavior) {
[self.rippleView touchesBegan:touches withEvent:event];
}
[super touchesBegan:touches withEvent:event];
if (!self.enableRippleBehavior) {
[self handleBeginTouches:touches];
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
if (self.enableRippleBehavior) {
[self.rippleView touchesMoved:touches withEvent:event];
}
[super touchesMoved:touches withEvent:event];
// Drag events handled by -touchDragExit:forEvent: and -touchDragEnter:forEvent:
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
if (self.enableRippleBehavior) {
[self.rippleView touchesEnded:touches withEvent:event];
}
[super touchesEnded:touches withEvent:event];
if (!self.enableRippleBehavior) {
CGPoint location = [self locationFromTouches:touches];
[_inkView startTouchEndedAnimationAtPoint:location completion:nil];
}
}
// Note - in some cases, event may be nil (e.g. view removed from window).
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
if (self.enableRippleBehavior) {
[self.rippleView touchesCancelled:touches withEvent:event];
}
[super touchesCancelled:touches withEvent:event];
if (!self.enableRippleBehavior) {
[self evaporateInkToPoint:[self locationFromTouches:touches]];
}
}
#pragma mark - UIControl methods
- (void)setEnabled:(BOOL)enabled {
[self setEnabled:enabled animated:NO];
}
- (void)setEnabled:(BOOL)enabled animated:(BOOL)animated {
[super setEnabled:enabled];
[self updateAfterStateChange:animated];
}
- (void)setHighlighted:(BOOL)highlighted {
[super setHighlighted:highlighted];
self.rippleView.rippleHighlighted = highlighted;
[self updateAfterStateChange:NO];
}
- (void)setSelected:(BOOL)selected {
[super setSelected:selected];
self.rippleView.selected = selected;
[self updateAfterStateChange:NO];
}
- (void)updateAfterStateChange:(BOOL)animated {
[self updateAlphaAndBackgroundColorAnimated:animated];
[self animateButtonToHeightForState:self.state];
[self updateBorderColor];
[self updateBorderWidth];
[self updateShadowColor];
[self updateTitleFont];
[self updateImageTintColor];
}
#pragma mark - Title Uppercasing
- (void)setUppercaseTitle:(BOOL)uppercaseTitle {
_uppercaseTitle = uppercaseTitle;
[self updateTitleCase];
}
- (void)updateTitleCase {
// This calls setTitle or setAttributedTitle for every title value we have stored. In each
// respective setter the title is upcased if _uppercaseTitle is YES.
NSDictionary<NSNumber *, NSString *> *nontransformedTitles = [_nontransformedTitles copy];
for (NSNumber *key in nontransformedTitles.keyEnumerator) {
UIControlState state = key.unsignedIntegerValue;
NSString *title = nontransformedTitles[key];
if ([title isKindOfClass:[NSAttributedString class]]) {
[self setAttributedTitle:(NSAttributedString *)title forState:state];
} else if ([title isKindOfClass:[NSString class]]) {
[self setTitle:title forState:state];
}
}
}
- (void)updateShadowColor {
self.layer.shadowColor = [self shadowColorForState:self.state].CGColor;
}
- (void)setShadowColor:(UIColor *)shadowColor forState:(UIControlState)state {
if (shadowColor) {
_shadowColors[@(state)] = shadowColor;
} else {
[_shadowColors removeObjectForKey:@(state)];
}
if (state == self.state) {
[self updateShadowColor];
}
}
- (UIColor *)shadowColorForState:(UIControlState)state {
UIColor *shadowColor = _shadowColors[@(state)];
if (state != UIControlStateNormal && !shadowColor) {
shadowColor = _shadowColors[@(UIControlStateNormal)];
}
return shadowColor;
}
- (void)setTitle:(NSString *)title forState:(UIControlState)state {
// Intercept any setting of the title and store a copy in case the accessibilityLabel
// is requested and the original non-uppercased version needs to be returned.
if ([title length]) {
_nontransformedTitles[@(state)] = [title copy];
} else {
[_nontransformedTitles removeObjectForKey:@(state)];
}
if (_uppercaseTitle) {
title = [title uppercaseStringWithLocale:[NSLocale currentLocale]];
}
[super setTitle:title forState:state];
}
- (void)setAttributedTitle:(NSAttributedString *)title forState:(UIControlState)state {
// Intercept any setting of the title and store a copy in case the accessibilityLabel
// is requested and the original non-uppercased version needs to be returned.
if ([title length]) {
_nontransformedTitles[@(state)] = [title copy];
} else {
[_nontransformedTitles removeObjectForKey:@(state)];
}
if (_uppercaseTitle) {
title = UppercaseAttributedString(title);
}
[super setAttributedTitle:title forState:state];
}
#pragma mark - Accessibility
- (void)setAccessibilityLabel:(NSString *)accessibilityLabel {
// Intercept any explicit setting of the accessibilityLabel so it can be returned
// later before the accessibiilityLabel is inferred from the setTitle:forState:
// argument values.
_accessibilityLabelExplicitValue = [accessibilityLabel copy];
[super setAccessibilityLabel:accessibilityLabel];
}
- (NSString *)accessibilityLabel {
if (!_uppercaseTitle) {
return [super accessibilityLabel];
}
if ([_accessibilityLabelExplicitValue length]) {
return _accessibilityLabelExplicitValue;
}
NSString *titleLabel;
id stateTitle = _nontransformedTitles[@(self.state)];
if ([stateTitle isKindOfClass:[NSAttributedString class]]) {
titleLabel = [(NSAttributedString *)stateTitle string];
} else if ([stateTitle isKindOfClass:[NSString class]]) {
titleLabel = stateTitle;
}
if ([titleLabel length]) {
return titleLabel;
}
id normalTitle = _nontransformedTitles[@(UIControlStateNormal)];
if ([normalTitle isKindOfClass:[NSAttributedString class]]) {
titleLabel = [(NSAttributedString *)normalTitle string];
} else if ([normalTitle isKindOfClass:[NSString class]]) {
titleLabel = normalTitle;
}
if ([titleLabel length]) {
return titleLabel;
}
NSString *label = [super accessibilityLabel];
if ([label length]) {
return label;
}
return nil;
}
- (UIAccessibilityTraits)accessibilityTraits {
if (self.accessibilityTraitsIncludesButton) {
return [super accessibilityTraits] | UIAccessibilityTraitButton;
}
return [super accessibilityTraits];
}
#pragma mark - Ink
- (MDCInkStyle)inkStyle {
return _inkView.inkStyle;
}
- (void)setInkStyle:(MDCInkStyle)inkStyle {
_inkView.inkStyle = inkStyle;
self.rippleView.rippleStyle =
(inkStyle == MDCInkStyleUnbounded) ? MDCRippleStyleUnbounded : MDCRippleStyleBounded;
}
- (void)setRippleStyle:(MDCRippleStyle)rippleStyle {
_rippleStyle = rippleStyle;
self.rippleView.rippleStyle = rippleStyle;
}
- (UIColor *)inkColor {
return _inkView.inkColor;
}
- (void)setInkColor:(UIColor *)inkColor {
_inkView.inkColor = inkColor;
[self.rippleView setRippleColor:inkColor forState:MDCRippleStateHighlighted];
}
- (void)setRippleColor:(UIColor *)rippleColor {
_rippleColor = rippleColor ?: [UIColor colorWithWhite:0 alpha:kDefaultRippleAlpha];
[self.rippleView setRippleColor:_rippleColor forState:MDCRippleStateHighlighted];
}
- (CGFloat)inkMaxRippleRadius {
return _inkMaxRippleRadius;
}
- (void)setInkMaxRippleRadius:(CGFloat)inkMaxRippleRadius {
_inkMaxRippleRadius = inkMaxRippleRadius;
_inkView.maxRippleRadius = inkMaxRippleRadius;
self.rippleView.maximumRadius = inkMaxRippleRadius;
}
- (void)setRippleMaximumRadius:(CGFloat)rippleMaximumRadius {
_rippleMaximumRadius = rippleMaximumRadius;
self.rippleView.maximumRadius = rippleMaximumRadius;
}
- (void)setInkViewOffset:(CGSize)inkViewOffset {
_inkViewOffset = inkViewOffset;
[self setNeedsLayout];
}
- (void)setRippleEdgeInsets:(UIEdgeInsets)rippleEdgeInsets {
_rippleEdgeInsets = rippleEdgeInsets;
[self setNeedsLayout];
}
- (void)setEnableRippleBehavior:(BOOL)enableRippleBehavior {
_enableRippleBehavior = enableRippleBehavior;
if (enableRippleBehavior) {
[self.inkView removeFromSuperview];
[self insertSubview:self.rippleView belowSubview:self.imageView];
} else {
[self.rippleView removeFromSuperview];
[self insertSubview:self.inkView belowSubview:self.imageView];
}
}
#pragma mark - Shadows
- (void)animateButtonToHeightForState:(UIControlState)state {
CGFloat newElevation = [self elevationForState:state];
if (MDCCGFloatEqual(self.mdc_currentElevation, newElevation)) {
return;
}
_currentElevation = newElevation;
[CATransaction begin];
[CATransaction setAnimationDuration:MDCButtonAnimationDuration];
if (gEnablePerformantShadow) {
[self updateShadow];
} else {
self.layer.elevation = newElevation;
}
[CATransaction commit];
[self mdc_elevationDidChange];
}
#pragma mark - BackgroundColor
- (void)setBackgroundColor:(nullable UIColor *)backgroundColor {
// Since setBackgroundColor can be called in the initializer we need to optionally build the dict.
if (!_backgroundColors) {
_backgroundColors = [NSMutableDictionary dictionary];
}
_backgroundColors[@(UIControlStateNormal)] = backgroundColor;
[self updateBackgroundColor];
}
- (UIColor *)backgroundColor {
return gEnablePerformantShadow ? _shapedLayer.shapedBackgroundColor
: self.layer.shapedBackgroundColor;
}
- (UIColor *)backgroundColorForState:(UIControlState)state {
// If the `.highlighted` flag is set, turn off the `.disabled` flag
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
state = state & ~UIControlStateDisabled;
}
return _backgroundColors[@(state)] ?: _backgroundColors[@(UIControlStateNormal)];
}
- (void)setBackgroundColor:(UIColor *)backgroundColor forState:(UIControlState)state {
UIControlState storageState = state;
// If the `.highlighted` flag is set, turn off the `.disabled` flag
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
storageState = state & ~UIControlStateDisabled;
}
// Only update the backing dictionary if:
// 1. The `state` argument is the same as the "storage" state, OR
// 2. There is already a value in the "storage" state.
if (storageState == state || _backgroundColors[@(storageState)] != nil) {
_backgroundColors[@(storageState)] = backgroundColor;
[self updateAlphaAndBackgroundColorAnimated:NO];
}
}
#pragma mark - Image Tint Color
- (void)setTintColor:(UIColor *)tintColor {
// Use of both tintColor and imageTintColor:forState: results in confusing results. We are using a
// last call wins stratgy to allow for both to still be used.
[_imageTintColors removeAllObjects];
[self updateImageTintColor];
_imageTintStatefulAPIEnabled = NO;
[super setTintColor:tintColor];
}
- (nullable UIColor *)imageTintColorForState:(UIControlState)state {
return _imageTintColors[@(state)] ?: _imageTintColors[@(UIControlStateNormal)];
}
- (void)setImageTintColor:(nullable UIColor *)imageTintColor forState:(UIControlState)state {
_imageTintColors[@(state)] = imageTintColor;
_imageTintStatefulAPIEnabled = YES;
[self updateImageTintColor];
}
- (void)updateImageTintColor {
if (!_imageTintStatefulAPIEnabled) {
return;
}
self.imageView.tintColor = [self imageTintColorForState:self.state];
}
#pragma mark - Elevations
- (CGFloat)elevationForState:(UIControlState)state {
NSNumber *elevation = _userElevations[@(state)];
if (state != UIControlStateNormal && (elevation == nil)) {
elevation = _userElevations[@(UIControlStateNormal)];
}
if (elevation != nil) {
return (CGFloat)[elevation doubleValue];
}
return 0;
}
- (void)setElevation:(CGFloat)elevation forState:(UIControlState)state {
_userElevations[@(state)] = @(elevation);
MDCShadowElevation newElevation = [self elevationForState:self.state];
// If no change to the current elevation, don't perform updates
if (MDCCGFloatEqual(newElevation, self.mdc_currentElevation)) {
return;
}
_currentElevation = newElevation;
if (gEnablePerformantShadow) {
[self updateShadow];
} else {
self.layer.elevation = newElevation;
}
[self mdc_elevationDidChange];
// The elevation of the normal state controls whether this button is flat or not, and flat buttons
// have different background color requirements than raised buttons.
// TODO(ajsecord): Move to MDCFlatButton and update this comment.
if (state == UIControlStateNormal) {
[self updateAlphaAndBackgroundColorAnimated:NO];
}
}
- (void)updateShadow {
if (_shapedLayer.shapeGenerator == nil) {
MDCShadow *shadow = [self.shadowsCollection shadowForElevation:self.mdc_currentElevation];
shadow = [[MDCShadowBuilder
builderWithColor:[self shadowColorForState:self.state] ?: MDCShadowColor()
opacity:shadow.opacity
radius:shadow.radius
offset:shadow.offset
spread:shadow.spread] build];
MDCConfigureShadowForView(self, shadow);
} else {
MDCShadow *shadow = [self.shadowsCollection shadowForElevation:self.mdc_currentElevation];
shadow = [[MDCShadowBuilder
builderWithColor:[self shadowColorForState:self.state] ?: MDCShadowColor()
opacity:shadow.opacity
radius:shadow.radius
offset:shadow.offset
spread:shadow.spread] build];
MDCConfigureShadowForViewWithPath(self, shadow, self.layer.shadowPath);
}
}
#pragma mark - Border Color
- (UIColor *)borderColorForState:(UIControlState)state {
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
state = state & ~UIControlStateDisabled;
}
return _borderColors[@(state)] ?: _borderColors[@(UIControlStateNormal)];
}
- (void)setBorderColor:(UIColor *)borderColor forState:(UIControlState)state {
UIControlState storageState = state;
// If the `.highlighted` flag is set, turn off the `.disabled` flag
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
storageState = state & ~UIControlStateDisabled;
}
// Only update the backing dictionary if:
// 1. The `state` argument is the same as the "storage" state, OR
// 2. There is already a value in the "storage" state.
if (storageState == state || _borderColors[@(storageState)] != nil) {
_borderColors[@(storageState)] = borderColor;
[self updateBorderColor];
}
}
#pragma mark - Border Width
- (CGFloat)borderWidthForState:(UIControlState)state {
// If the `.highlighted` flag is set, turn off the `.disabled` flag
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
state = state & ~UIControlStateDisabled;
}
NSNumber *borderWidth = _borderWidths[@(state)];
if (borderWidth != nil) {
return (CGFloat)borderWidth.doubleValue;
}
return (CGFloat)[_borderWidths[@(UIControlStateNormal)] doubleValue];
}
- (void)setBorderWidth:(CGFloat)borderWidth forState:(UIControlState)state {
UIControlState storageState = state;
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
storageState = state & ~UIControlStateDisabled;
}
// Only update the backing dictionary if:
// 1. The `state` argument is the same as the "storage" state, OR
// 2. There is already a value in the "storage" state.
if (storageState == state || _backgroundColors[@(storageState)] != nil) {
_borderWidths[@(state)] = @(borderWidth);
[self updateBorderWidth];
}
}
- (void)updateBorderWidth {
NSNumber *width = _borderWidths[@(self.state)];
if ((width == nil) && self.state != UIControlStateNormal) {
// We fall back to UIControlStateNormal if there is no value for the current state.
width = _borderWidths[@(UIControlStateNormal)];
}
if (gEnablePerformantShadow) {
_shapedLayer.shapedBorderWidth = (width != nil) ? (CGFloat)width.doubleValue : 0;
} else {
self.layer.shapedBorderWidth = (width != nil) ? (CGFloat)width.doubleValue : 0;
}
}
#pragma mark - Title Font
- (nonnull UIFont *)titleFontForState:(UIControlState)state {
// If the `.highlighted` flag is set, turn off the `.disabled` flag
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
state = state & ~UIControlStateDisabled;
}
UIFont *font = _fonts[@(state)] ?: _fonts[@(UIControlStateNormal)];
if (!font) {
// TODO(#2709): Have a single source of truth for fonts
// Migrate to [UIFont standardFont] when possible
font = [MDCTypography buttonFont];
}
if (_mdc_adjustsFontForContentSizeCategory) {
// Dynamic type is enabled so apply scaling
if (font.mdc_scalingCurve) {
font = [font mdc_scaledFontForTraitEnvironment:self];
} else {
font = [font mdc_fontSizedForMaterialTextStyle:MDCFontTextStyleButton
scaledForDynamicType:YES];
}
}
return font;
}
- (void)setTitleFont:(nullable UIFont *)font forState:(UIControlState)state {
UIControlState storageState = state;
// If the `.highlighted` flag is set, turn off the `.disabled` flag
if ((state & UIControlStateHighlighted) == UIControlStateHighlighted) {
storageState = state & ~UIControlStateDisabled;
}
// Only update the backing dictionary if:
// 1. The `state` argument is the same as the "storage" state, OR
// 2. There is already a value in the "storage" state.
if (storageState == state || _fonts[@(storageState)] != nil) {
_fonts[@(storageState)] = font;
[self updateTitleFont];
}
}
#pragma mark - MaterialElevation
- (CGFloat)mdc_currentElevation {
return _currentElevation;
}
- (MDCShadowsCollection *)shadowsCollection {
if (!_shadowsCollection) {
_shadowsCollection = MDCShadowsCollectionDefault();
}
return _shadowsCollection;
}
#pragma mark - Private methods
/**
The background color that a user would see for this button. If self.backgroundColor is not
transparent, then returns that. Otherwise, returns self.underlyingColorHint.
@note If self.underlyingColorHint is not set, then this method will return nil.
*/
- (UIColor *)effectiveBackgroundColor {
UIColor *backgroundColor = [self backgroundColorForState:self.state];
if (![self isTransparentColor:backgroundColor]) {
return backgroundColor;
} else {
return self.underlyingColorHint;
}
}
/** Returns YES if the color is not transparent and is a "dark" color. */
- (BOOL)isDarkColor:(UIColor *)color {
// TODO: have a components/private/ColorCalculations/MDCColorCalculations.h|m
// return ![self isTransparentColor:color] && [QTMColorGroup luminanceOfColor:color] < 0.5;
return ![self isTransparentColor:color];
}
/** Returns YES if the color is transparent (including a nil color). */
- (BOOL)isTransparentColor:(UIColor *)color {
return !color || [color isEqual:[UIColor clearColor]] || CGColorGetAlpha(color.CGColor) == 0;
}
- (void)touchDragEnter:(__unused MDCButton *)button forEvent:(UIEvent *)event {
[self handleBeginTouches:event.allTouches];
}
- (void)touchDragExit:(__unused MDCButton *)button forEvent:(UIEvent *)event {
CGPoint location = [self locationFromTouches:event.allTouches];
[self evaporateInkToPoint:location];
}
- (void)handleBeginTouches:(NSSet *)touches {
[_inkView startTouchBeganAnimationAtPoint:[self locationFromTouches:touches] completion:nil];
}
- (CGPoint)locationFromTouches:(NSSet *)touches {
UITouch *touch = [touches anyObject];
return [touch locationInView:self];
}
- (void)evaporateInkToPoint:(CGPoint)toPoint {
[_inkView startTouchEndedAnimationAtPoint:toPoint completion:nil];
}
- (UIBezierPath *)boundingPath {
CGSize cornerRadii = CGSizeMake(self.layer.cornerRadius, self.layer.cornerRadius);
return [UIBezierPath bezierPathWithRoundedRect:self.bounds
byRoundingCorners:(UIRectCorner)self.layer.maskedCorners
cornerRadii:cornerRadii];
}
- (UIEdgeInsets)defaultContentEdgeInsets {
return UIEdgeInsetsMake(8, 16, 8, 16);
}
- (BOOL)shouldHaveOpaqueBackground {
BOOL isFlatButton = MDCCGFloatIsExactlyZero([self elevationForState:UIControlStateNormal]);
return !isFlatButton;
}
- (void)updateAlphaAndBackgroundColorAnimated:(BOOL)animated {
void (^animations)(void) = ^{
// Set super to avoid overwriting _enabledAlpha
super.alpha = self.enabled ? self->_enabledAlpha : self.disabledAlpha;
[self updateBackgroundColor];
};
if (animated) {
[UIView animateWithDuration:MDCButtonAnimationDuration animations:animations];
} else {
animations();
}
}
- (void)updateBackgroundColor {
// When shapeGenerator is unset then self.layer.shapedBackgroundColor sets the layer's
// backgroundColor. Whereas when shapeGenerator is set the sublayer's fillColor is set.
if (gEnablePerformantShadow) {
_shapedLayer.shapedBackgroundColor = [self backgroundColorForState:self.state];
} else {
self.layer.shapedBackgroundColor = [self backgroundColorForState:self.state];
}
[self updateDisabledTitleColor];
}
- (void)updateDisabledTitleColor {
// We only want to automatically set a disabled title color if the user hasn't already provided a
// value.
if (_hasCustomDisabledTitleColor) {
return;
}
// Disabled buttons have very low opacity, so we full-opacity text color here to make the text
// readable. Also, even for non-flat buttons with opaque backgrounds, the correct background color
// to examine is the underlying color, since disabled buttons are so transparent.
BOOL darkBackground = [self isDarkColor:[self underlyingColorHint]];
// We call super here to distinguish between automatic title color assignments and that of users.
[super setTitleColor:darkBackground ? [UIColor whiteColor] : [UIColor blackColor]
forState:UIControlStateDisabled];
}
- (void)setTitleColor:(UIColor *)color forState:(UIControlState)state {
[super setTitleColor:color forState:state];
if (state == UIControlStateDisabled) {
_hasCustomDisabledTitleColor = color != nil;
if (!_hasCustomDisabledTitleColor) {
[self updateDisabledTitleColor];
}
}
}
- (void)updateBorderColor {
UIColor *color = _borderColors[@(self.state)];
if (!color && self.state != UIControlStateNormal) {
// We fall back to UIControlStateNormal if there is no value for the current state.
color = _borderColors[@(UIControlStateNormal)];
}
if (gEnablePerformantShadow) {
_shapedLayer.shapedBorderColor = color ?: NULL;
} else {
self.layer.shapedBorderColor = color ?: NULL;
}
}
- (void)updateTitleFont {
if (!self.enableTitleFontForState) {
return;
}
self.titleLabel.font = [self titleFontForState:self.state];
[self setNeedsLayout];
}
- (void)setShapeGenerator:(id<MDCShapeGenerating>)shapeGenerator {
if (!UIEdgeInsetsEqualToEdgeInsets(_visibleAreaInsets, UIEdgeInsetsZero) ||
self.centerVisibleArea) {
// When visibleAreaInsets or centerVisibleArea is set, custom shapeGenerater is not allow
// to be set through setter.
return;
}
[self configureLayerWithShapeGenerator:shapeGenerator];
}
- (void)configureLayerWithShapeGenerator:(id<MDCShapeGenerating>)shapeGenerator {
if (shapeGenerator) {
self.layer.shadowPath = nil;
} else {
if (gEnablePerformantShadow) {
MDCShadow *shadow = [self.shadowsCollection shadowForElevation:self.mdc_currentElevation];
shadow = [[MDCShadowBuilder
builderWithColor:[self shadowColorForState:self.state] ?: MDCShadowColor()
opacity:shadow.opacity
radius:shadow.radius
offset:shadow.offset
spread:shadow.spread] build];
MDCConfigureShadowForView(self, shadow);
} else {
self.layer.shadowPath = [self boundingPath].CGPath;
}
}
if (gEnablePerformantShadow) {
_shapedLayer.shapeGenerator = shapeGenerator;
} else {
self.layer.shapeGenerator = shapeGenerator;
}
// The imageView is added very early in the lifecycle of a UIButton, therefore we need to move
// the colorLayer behind the imageView otherwise the image will not show.
// Because the inkView needs to go below the imageView, but above the colorLayer
// we need to have the colorLayer be at the back
if (gEnablePerformantShadow) {
[_shapedLayer.colorLayer removeFromSuperlayer];
if (self.enableRippleBehavior) {
[self.layer insertSublayer:_shapedLayer.colorLayer below:self.rippleView.layer];
} else {
[self.layer insertSublayer:_shapedLayer.colorLayer below:self.inkView.layer];
}
} else {
[self.layer.colorLayer removeFromSuperlayer];
if (self.enableRippleBehavior) {
[self.layer insertSublayer:self.layer.colorLayer below:self.rippleView.layer];
} else {
[self.layer insertSublayer:self.layer.colorLayer below:self.inkView.layer];
}
}
[self updateBackgroundColor];
[self updateInkForShape];
}
- (id<MDCShapeGenerating>)shapeGenerator {
if (gEnablePerformantShadow) {
return _shapedLayer.shapeGenerator;
}
return self.layer.shapeGenerator;
}
- (void)updateInkForShape {
CGRect boundingBox = CGPathGetBoundingBox(gEnablePerformantShadow ? _shapedLayer.shapeLayer.path
: self.layer.shapeLayer.path);
self.inkView.maxRippleRadius =
(CGFloat)(hypot(CGRectGetHeight(boundingBox), CGRectGetWidth(boundingBox)) / 2 + 10);
self.inkView.layer.masksToBounds = NO;
self.rippleView.layer.masksToBounds = NO;
}
#pragma mark - Dynamic Type
- (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 updateTitleFont];
}
- (void)contentSizeCategoryDidChange:(__unused NSNotification *)notification {
[self updateTitleFont];
[self sizeToFit];
}
#pragma mark - Deprecations
- (void)setUnderlyingColor:(UIColor *)underlyingColor {
[self setUnderlyingColorHint:underlyingColor];
}
#pragma mark - Visible area
- (void)setCenterVisibleArea:(BOOL)centerVisibleArea {
if (_centerVisibleArea == centerVisibleArea) {
return;
}
_centerVisibleArea = centerVisibleArea;
if (!centerVisibleArea && UIEdgeInsetsEqualToEdgeInsets(_visibleAreaInsets, UIEdgeInsetsZero)) {
self.shapeGenerator = nil;
if (_cornerRadiusObserverAdded) {
[self.layer removeObserver:self
forKeyPath:NSStringFromSelector(@selector(cornerRadius))
context:kKVOContextCornerRadius];
_cornerRadiusObserverAdded = NO;
}
} else {
UIEdgeInsets visibleAreaInsets = self.visibleAreaInsets;
MDCRectangleShapeGenerator *shapeGenerator =
[self generateShapeWithCornerRadius:self.layer.cornerRadius
visibleAreaInsets:visibleAreaInsets];
[self configureLayerWithShapeGenerator:shapeGenerator];
if (!_cornerRadiusObserverAdded) {
[self.layer addObserver:self
forKeyPath:NSStringFromSelector(@selector(cornerRadius))
options:NSKeyValueObservingOptionNew
context:kKVOContextCornerRadius];
_cornerRadiusObserverAdded = YES;
}
}
}
- (void)setVisibleAreaInsets:(UIEdgeInsets)visibleAreaInsets {
if (UIEdgeInsetsEqualToEdgeInsets(visibleAreaInsets, _visibleAreaInsets)) {
return;
}
_visibleAreaInsets = visibleAreaInsets;
if (UIEdgeInsetsEqualToEdgeInsets(visibleAreaInsets, UIEdgeInsetsZero) &&
!self.centerVisibleArea) {
self.shapeGenerator = nil;
if (_cornerRadiusObserverAdded) {
[self.layer removeObserver:self
forKeyPath:NSStringFromSelector(@selector(cornerRadius))
context:kKVOContextCornerRadius];
_cornerRadiusObserverAdded = NO;
}
} else {
MDCRectangleShapeGenerator *shapeGenerator =
[self generateShapeWithCornerRadius:self.layer.cornerRadius
visibleAreaInsets:visibleAreaInsets];
[self configureLayerWithShapeGenerator:shapeGenerator];
if (!_cornerRadiusObserverAdded) {
[self.layer addObserver:self
forKeyPath:NSStringFromSelector(@selector(cornerRadius))
options:NSKeyValueObservingOptionNew
context:kKVOContextCornerRadius];
_cornerRadiusObserverAdded = YES;
}
}
}
- (UIEdgeInsets)visibleAreaInsets {
if (!UIEdgeInsetsEqualToEdgeInsets(_visibleAreaInsets, UIEdgeInsetsZero)) {
// Use custom visibleAreaInsets value when user sets it.
return _visibleAreaInsets;
}
UIEdgeInsets visibleAreaInsets = UIEdgeInsetsZero;
if (self.centerVisibleArea) {
CGSize visibleAreaSize;
if (self.layoutTitleWithConstraints) {
visibleAreaSize =
[self systemLayoutSizeFittingSize:CGSizeMake(self.bounds.size.width,
UILayoutFittingCompressedSize.height)];
} else {
visibleAreaSize = [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
}
CGFloat additionalRequiredHeight =
MAX(0, CGRectGetHeight(self.bounds) - visibleAreaSize.height);
CGFloat additionalRequiredWidth = MAX(0, CGRectGetWidth(self.bounds) - visibleAreaSize.width);
visibleAreaInsets.top = ceil(additionalRequiredHeight * 0.5f);
visibleAreaInsets.bottom = additionalRequiredHeight - visibleAreaInsets.top;
visibleAreaInsets.left = ceil(additionalRequiredWidth * 0.5f);
visibleAreaInsets.right = additionalRequiredWidth - visibleAreaInsets.left;
}
return visibleAreaInsets;
}
- (UILayoutGuide *)visibleAreaLayoutGuide {
if (!_visibleAreaLayoutGuide) {
_visibleAreaLayoutGuide = [[UILayoutGuide alloc] init];
[self addLayoutGuide:_visibleAreaLayoutGuide];
_visibleAreaLayoutGuideView = [[UIView alloc] init];
_visibleAreaLayoutGuideView.userInteractionEnabled = NO;
[self insertSubview:_visibleAreaLayoutGuideView atIndex:0];
self.visibleAreaLayoutGuideView.frame =
UIEdgeInsetsInsetRect(self.bounds, self.visibleAreaInsets);
[_visibleAreaLayoutGuide.leftAnchor
constraintEqualToAnchor:_visibleAreaLayoutGuideView.leftAnchor]
.active = YES;
[_visibleAreaLayoutGuide.rightAnchor
constraintEqualToAnchor:_visibleAreaLayoutGuideView.rightAnchor]
.active = YES;
[_visibleAreaLayoutGuide.topAnchor
constraintEqualToAnchor:_visibleAreaLayoutGuideView.topAnchor]
.active = YES;
[_visibleAreaLayoutGuide.bottomAnchor
constraintEqualToAnchor:_visibleAreaLayoutGuideView.bottomAnchor]
.active = YES;
}
return _visibleAreaLayoutGuide;
}
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
if (context == kKVOContextCornerRadius) {
if ((!UIEdgeInsetsEqualToEdgeInsets(self.visibleAreaInsets, UIEdgeInsetsZero) ||
self.centerVisibleArea) &&
self.shapeGenerator) {
MDCRectangleShapeGenerator *shapeGenerator =
[self generateShapeWithCornerRadius:self.layer.cornerRadius
visibleAreaInsets:self.visibleAreaInsets];
[self configureLayerWithShapeGenerator:shapeGenerator];
}
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (MDCRectangleShapeGenerator *)generateShapeWithCornerRadius:(CGFloat)cornerRadius
visibleAreaInsets:(UIEdgeInsets)visibleAreaInsets {
MDCRectangleShapeGenerator *shapeGenerator = [[MDCRectangleShapeGenerator alloc] init];
MDCCornerTreatment *cornerTreatment =
[[MDCRoundedCornerTreatment alloc] initWithRadius:cornerRadius];
[shapeGenerator setCorners:cornerTreatment];
shapeGenerator.topLeftCornerOffset = CGPointMake(visibleAreaInsets.left, visibleAreaInsets.top);
shapeGenerator.topRightCornerOffset =
CGPointMake(-visibleAreaInsets.right, visibleAreaInsets.top);
shapeGenerator.bottomLeftCornerOffset =
CGPointMake(visibleAreaInsets.left, -visibleAreaInsets.bottom);
shapeGenerator.bottomRightCornerOffset =
CGPointMake(-visibleAreaInsets.right, -visibleAreaInsets.bottom);
return shapeGenerator;
}
#pragma mark Multi-line Minimum And Maximum Sizing Inference
- (BOOL)shouldInferMinimumAndMaximumSize {
return (self.inferMinimumAndMaximumSizeWhenMultiline && self.titleLabel.numberOfLines != 1 &&
self.titleLabel.text.length > 0);
}
- (void)setInferMinimumAndMaximumSizeWhenMultiline:(BOOL)inferMinimumAndMaximumSizeWhenMultiline {
_inferMinimumAndMaximumSizeWhenMultiline = inferMinimumAndMaximumSizeWhenMultiline;
if (!_inferMinimumAndMaximumSizeWhenMultiline) {
self.minimumSize = CGSizeZero;
self.maximumSize = CGSizeZero;
}
[self setNeedsLayout];
// Call -layoutIfNeeded in addition to -setNeedsLayout. If in a Manual Layout environment, the
// client will probably call -sizeToFit: after enabling this flag, like the docs suggest. We want
// the pending layout pass to happen before they are able to do that, so minimumSize and
// maximumSize are already set when it happens.
[self layoutIfNeeded];
}
- (void)inferMinimumAndMaximumSize {
CGSize buttonSize = self.bounds.size;
CGSize sizeShrunkFromVisibleAreaInsets =
CGSizeShrinkWithInsets(buttonSize, self.visibleAreaInsets);
CGSize sizeShrunkFromContentEdgeInsets =
CGSizeShrinkWithInsets(sizeShrunkFromVisibleAreaInsets, self.contentEdgeInsets);
CGSize boundingSizeForLabel = sizeShrunkFromContentEdgeInsets;
if ([self imageForState:self.state]) {
boundingSizeForLabel.width -= CGRectGetWidth(self.imageView.frame);
}
boundingSizeForLabel.height = CGFLOAT_MAX;
CGSize sizeThatFitsLabel = [self.titleLabel sizeThatFits:boundingSizeForLabel];
CGSize sizeShrunkFromContentEdgeInsetsWithNewHeight =
CGSizeMake(sizeShrunkFromContentEdgeInsets.width, sizeThatFitsLabel.height);
CGSize sizeExpandedFromContentEdgeInsets =
CGSizeExpandWithInsets(sizeShrunkFromContentEdgeInsetsWithNewHeight, self.contentEdgeInsets);
CGSize sizeExpandedFromVisibleAreaInsets =
CGSizeExpandWithInsets(sizeExpandedFromContentEdgeInsets, self.visibleAreaInsets);
self.minimumSize = sizeExpandedFromVisibleAreaInsets;
self.maximumSize = sizeExpandedFromVisibleAreaInsets;
if (!CGSizeEqualToSize(sizeExpandedFromVisibleAreaInsets,
self.lastRecordedIntrinsicContentSize)) {
[self invalidateIntrinsicContentSize];
}
}
#pragma mark - Enabling multi-line layout
- (void)setLayoutTitleWithConstraints:(BOOL)layoutTitleWithConstraints {
if (_layoutTitleWithConstraints == layoutTitleWithConstraints) {
return;
}
_layoutTitleWithConstraints = layoutTitleWithConstraints;
if (_layoutTitleWithConstraints) {
self.titleTopConstraint = [self.titleLabel.topAnchor constraintEqualToAnchor:self.topAnchor];
self.titleBottomConstraint =
[self.titleLabel.bottomAnchor constraintEqualToAnchor:self.bottomAnchor];
self.titleLeadingConstraint =
[self.titleLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor];
self.titleTrailingConstraint =
[self.titleLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor];
self.titleTopConstraint.active = YES;
self.titleBottomConstraint.active = YES;
self.titleLeadingConstraint.active = YES;
self.titleTrailingConstraint.active = YES;
[self.titleLabel setContentHuggingPriority:UILayoutPriorityRequired
forAxis:UILayoutConstraintAxisHorizontal];
[self.titleLabel setContentHuggingPriority:UILayoutPriorityRequired
forAxis:UILayoutConstraintAxisVertical];
[self updateTitleLabelConstraint];
} else {
self.titleTopConstraint.active = NO;
self.titleBottomConstraint.active = NO;
self.titleLeadingConstraint.active = NO;
self.titleTrailingConstraint.active = NO;
self.titleTopConstraint = nil;
self.titleBottomConstraint = nil;
self.titleLeadingConstraint = nil;
self.titleTrailingConstraint = nil;
}
}
- (void)updateTitleLabelConstraint {
self.titleTopConstraint.constant = self.contentEdgeInsets.top;
self.titleBottomConstraint.constant = -self.contentEdgeInsets.bottom;
self.titleLeadingConstraint.constant = self.contentEdgeInsets.left;
self.titleTrailingConstraint.constant = -self.contentEdgeInsets.right;
}
- (void)setContentEdgeInsets:(UIEdgeInsets)contentEdgeInsets {
[super setContentEdgeInsets:contentEdgeInsets];
if (self.layoutTitleWithConstraints) {
[self updateTitleLabelConstraint];
}
}
#pragma mark - Performant Shadow Toggle
+ (void)setEnablePerformantShadow:(BOOL)enable {
gEnablePerformantShadow = enable;
}
+ (BOOL)enablePerformantShadow {
return gEnablePerformantShadow;
}
#pragma mark - CALayerDelegate
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)key {
if (gEnablePerformantShadow && layer == self.layer && M3CIsMDCShadowPathKey(key)) {
return M3CShadowPathActionForLayer(layer);
}
return [super actionForLayer:layer forKey:key];
}
@end