| // 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 |