blob: 0770f701fc50a3136b6965e0bfa805c1c711d4af [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 <MDFTextAccessibility/MDFTextAccessibility.h>
#import "MaterialInk.h"
#import "MaterialMath.h"
#import "MaterialRipple.h"
#import "MaterialShadowElevations.h"
#import "MaterialShadowLayer.h"
#import "MaterialTypography.h"
#import "private/MDCButton+Subclassing.h"
// 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/
// Specified in Material Guidelines
// https://material.io/guidelines/layout/metrics-keylines.html#metrics-keylines-touch-target-size
static const CGFloat MDCButtonMinimumTouchTargetHeight = 48;
static const CGFloat MDCButtonMinimumTouchTargetWidth = 48;
static const CGFloat MDCButtonDefaultCornerRadius = 2.0;
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;
// 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];
}
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 accessibility settings.
NSMutableDictionary<NSNumber *, NSString *> *_nontransformedTitles;
NSString *_accessibilityLabelExplicitValue;
BOOL _mdc_adjustsFontForContentSizeCategory;
}
@property(nonatomic, strong, readonly, nonnull) MDCStatefulRippleView *rippleView;
@property(nonatomic, strong) MDCInkView *inkView;
@property(nonatomic, readonly, strong) MDCShapedShadowLayer *layer;
@end
@implementation MDCButton
@synthesize mdc_overrideBaseElevation = _mdc_overrideBaseElevation;
@synthesize mdc_elevationDidChangeBlock = _mdc_elevationDidChangeBlock;
@dynamic layer;
+ (Class)layerClass {
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)] = 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;
_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;
_adjustsFontForContentSizeCategoryWhenScaledFontIsUnavailable = YES;
_mdc_overrideBaseElevation = -1;
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;
#if (!defined(TARGET_OS_TV) || TARGET_OS_TV == 0)
self.showsTouchWhenHighlighted = NO;
#endif
self.layer.cornerRadius = MDCButtonDefaultCornerRadius;
if (!self.layer.shapeGenerator) {
self.layer.shadowPath = [self boundingPath].CGPath;
}
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.
_inkView = [[MDCInkView alloc] initWithFrame:self.bounds];
_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];
_rippleView.rippleColor = [UIColor colorWithWhite:1 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];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIContentSizeCategoryDidChangeNotification
object:nil];
}
- (void)setUnderlyingColorHint:(UIColor *)underlyingColorHint {
_underlyingColorHint = underlyingColorHint;
[self updateAlphaAndBackgroundColorAnimated:NO];
}
- (void)setAlpha:(CGFloat)alpha {
if (self.enabled) {
_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.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;
CGFloat offsetY = contentCenterPoint.y - boundsCenterPoint.y;
_inkView.frame =
CGRectMake(offsetX, offsetY, CGRectGetWidth(self.bounds), CGRectGetHeight(self.bounds));
} else {
CGRect bounds = CGRectStandardize(self.bounds);
_inkView.frame = bounds;
self.rippleView.frame = bounds;
}
self.titleLabel.frame = MDCRectAlignToScale(self.titleLabel.frame, [UIScreen mainScreen].scale);
}
- (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 guidlines 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 superSize = [super sizeThatFits:size];
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);
}
return superSize;
}
- (CGSize)intrinsicContentSize {
return [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
}
- (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 {
[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 string] 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];
}
NSString *label = _accessibilityLabelExplicitValue;
if ([label length]) {
return label;
}
label = _nontransformedTitles[@(self.state)];
if ([label length]) {
return label;
}
label = _nontransformedTitles[@(UIControlStateNormal)];
if ([label length]) {
return label;
}
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;
}
- (UIColor *)inkColor {
return _inkView.inkColor;
}
- (void)setInkColor:(UIColor *)inkColor {
_inkView.inkColor = inkColor;
[self.rippleView setRippleColor:inkColor forState:MDCRippleStateHighlighted];
}
- (void)setInkMaxRippleRadius:(CGFloat)inkMaxRippleRadius {
_inkMaxRippleRadius = inkMaxRippleRadius;
_inkView.maxRippleRadius = inkMaxRippleRadius;
self.rippleView.maximumRadius = inkMaxRippleRadius;
}
- (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.layer.elevation, newElevation)) {
return;
}
[CATransaction begin];
[CATransaction setAnimationDuration:MDCButtonAnimationDuration];
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 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
- (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.layer.elevation)) {
return;
}
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];
}
}
#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)];
}
self.layer.shapedBorderWidth = (width != nil) ? (CGFloat)width.doubleValue : 0;
}
#pragma mark - Title Font
- (nullable 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 {
if (self.adjustsFontForContentSizeCategoryWhenScaledFontIsUnavailable) {
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 [self elevationForState:self.state];
}
#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 {
return [UIBezierPath bezierPathWithRoundedRect:self.bounds cornerRadius:self.layer.cornerRadius];
}
- (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) = ^{
self.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.
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)];
}
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 (shapeGenerator) {
self.layer.shadowPath = nil;
} else {
self.layer.shadowPath = [self boundingPath].CGPath;
}
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
[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 {
return self.layer.shapeGenerator;
}
- (void)updateInkForShape {
CGRect boundingBox = CGPathGetBoundingBox(self.layer.shapeLayer.path);
self.inkView.maxRippleRadius =
(CGFloat)(MDCHypot(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];
}
@end