| #import <Foundation/Foundation.h> |
| #import <QuartzCore/CALayer.h> |
| #import <UIKit/UIKit.h> |
| |
| #import "M3CButton.h" |
| #import "M3CIconAttributes.h" |
| #import "M3CVisualBackgroundView.h" |
| #import "M3CAnimationActions.h" |
| #import "MDCShadow.h" |
| #import "MDCShadowsCollection.h" |
| |
| NS_ASSUME_NONNULL_BEGIN |
| |
| // Minimum touch size recommended by Apple: |
| // https://developer.apple.com/design/human-interface-guidelines/accessibility#Mobility |
| static const CGFloat kMinimumTouchTarget = 44.f; |
| |
| @interface M3CButton () { |
| NSMutableDictionary<NSNumber *, UIColor *> *_backgroundColors; |
| NSMutableDictionary<NSNumber *, UIColor *> *_tintColors; |
| NSMutableDictionary<NSNumber *, UIColor *> *_borderColors; |
| NSMutableDictionary<NSNumber *, MDCShadow *> *_shadows; |
| NSMutableDictionary<NSNumber *, UIFont *> *_fonts; |
| NSMutableDictionary<NSNumber *, M3CIconAttributes *> *_symbolFonts; |
| NSMutableDictionary<NSNumber *, NSNumber *> *_cornerRadius; |
| NSMutableDictionary<NSNumber *, NSNumber *> *_pressedCornerRadius; |
| NSMutableDictionary<NSNumber *, NSValue *> *_imageEdgeInsetsForSize; |
| NSMutableDictionary<NSNumber *, NSValue *> *_edgeInsetsWithImageAndTitleForSize; |
| NSMutableDictionary<NSNumber *, NSValue *> *_edgeInsetsWithImageForSize; |
| NSMutableDictionary<NSNumber *, NSValue *> *_edgeInsetsWithTitleForSize; |
| CGSize _visualContentSize; |
| CGFloat _symbolSize; |
| BOOL _customInsetAvailable; |
| BOOL _buttonSizeSet; |
| } |
| |
| @property(nonatomic, assign) M3CButtonSize buttonSize API_AVAILABLE(ios(15.0)); |
| |
| /** |
| The visual representation of the background. |
| |
| @note Generally has no side effects and is an extra subview hierarchy. But, in instances |
| where touch targets are not met, this replaces the background while the background remains the |
| touch target size but changes to clear. |
| */ |
| @property(nonatomic, strong, nonnull) M3CVisualBackgroundView *visualBackground; |
| |
| // Used only when layoutTitleWithConstraints is enabled. |
| @property(nonatomic, strong, nullable) NSLayoutConstraint *titleTopConstraint; |
| @property(nonatomic, strong, nullable) NSLayoutConstraint *titleBottomConstraint; |
| @property(nonatomic, strong, nullable) NSLayoutConstraint *titleLeadingConstraint; |
| @property(nonatomic, strong, nullable) NSLayoutConstraint *titleTrailingConstraint; |
| |
| @end |
| |
| @implementation M3CButton |
| |
| - (instancetype)init { |
| self = [super init]; |
| if (self) { |
| [self initCommon]; |
| } |
| return self; |
| } |
| |
| - (instancetype)initWithFrame:(CGRect)frame { |
| self = [super initWithFrame:frame]; |
| if (self) { |
| [self initCommon]; |
| } |
| return self; |
| } |
| |
| - (instancetype)initWithFrame:(CGRect)frame primaryAction:(nullable UIAction *)primaryAction { |
| self = [super initWithFrame:frame primaryAction:primaryAction]; |
| if (self) { |
| [self initCommon]; |
| } |
| return self; |
| } |
| |
| - (nullable instancetype)initWithCoder:(NSCoder *)aDecoder { |
| self = [super initWithCoder:aDecoder]; |
| [self doesNotRecognizeSelector:_cmd]; |
| return self; |
| } |
| |
| - (void)initCommon { |
| self.animationDuration = 0.3f; |
| self.minimumHeight = kMinimumTouchTarget; |
| self.minimumWidth = kMinimumTouchTarget; |
| _borderColors = [NSMutableDictionary dictionary]; |
| _shadows = [NSMutableDictionary dictionary]; |
| _fonts = [NSMutableDictionary dictionary]; |
| _symbolFonts = [NSMutableDictionary dictionary]; |
| _cornerRadius = [NSMutableDictionary dictionary]; |
| _pressedCornerRadius = [NSMutableDictionary dictionary]; |
| _imageEdgeInsetsForSize = [NSMutableDictionary dictionary]; |
| _edgeInsetsWithImageAndTitleForSize = [NSMutableDictionary dictionary]; |
| _edgeInsetsWithImageForSize = [NSMutableDictionary dictionary]; |
| _edgeInsetsWithTitleForSize = [NSMutableDictionary dictionary]; |
| _customInsetAvailable = NO; |
| _visualBackground = [[M3CVisualBackgroundView alloc] init]; |
| _visualBackground.exclusiveTouch = NO; |
| _visualBackground.userInteractionEnabled = NO; |
| _visualContentSize = CGSizeZero; |
| |
| if (!_backgroundColors) { |
| // _backgroundColors may have already been initialized by setting the backgroundColor setter. |
| _backgroundColors = [NSMutableDictionary dictionary]; |
| } |
| |
| if (!_tintColors) { |
| // _tintColors may have already been initialized by setting the tintColor setter. |
| _tintColors = [NSMutableDictionary dictionary]; |
| } |
| |
| #if (!defined(TARGET_OS_TV) || TARGET_OS_TV == 0) |
| // Block users from activating multiple buttons simultaneously by default. |
| self.exclusiveTouch = YES; |
| #endif |
| [self updateColors]; |
| } |
| |
| - (void)setButtonSize:(M3CButtonSize)buttonSize { |
| _buttonSizeSet = YES; |
| _buttonSize = buttonSize; |
| |
| [self addSubview:self.visualBackground]; |
| [self sendSubviewToBack:self.visualBackground]; |
| [self updateInsets]; |
| [self updateCorners]; |
| [self updateFont]; |
| [self updateSymbolFont]; |
| [self updateShadows]; |
| [self updateColors]; |
| } |
| |
| // Colors |
| - (void)setBackgroundColor:(nullable UIColor *)color forState:(UIControlState)state { |
| _backgroundColors[@(state)] = color; |
| [self updateColors]; |
| } |
| |
| - (void)setTintColor:(nullable UIColor *)color forState:(UIControlState)state { |
| _tintColors[@(state)] = color; |
| [self updateColors]; |
| } |
| |
| - (void)setBorderColor:(nullable UIColor *)color forState:(UIControlState)state { |
| _borderColors[@(state)] = color; |
| [self updateColors]; |
| } |
| |
| - (void)setShadow:(nullable MDCShadow *)shadow forState:(UIControlState)state { |
| _shadows[@(state)] = shadow; |
| [self updateColors]; |
| [self updateShadows]; |
| } |
| |
| - (void)setFont:(UIFont *)font forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _fonts[@(size)] = font; |
| |
| [self updateFont]; |
| } |
| |
| - (void)setSymbolSize:(CGFloat)symbolSize |
| textStyle:(UIFontTextStyle)textStyle |
| forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _symbolFonts[@(size)] = [[M3CIconAttributes alloc] initWithTextStyle:textStyle |
| pointSize:symbolSize]; |
| |
| [self updateSymbolFont]; |
| } |
| |
| - (void)setCornerRadius:(CGFloat)cornerRadius forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| NSNumber *cornerRadiusValue = [NSNumber numberWithFloat:cornerRadius]; |
| _cornerRadius[@(size)] = cornerRadiusValue; |
| |
| NSNumber *currentCornerRadius = _cornerRadius[@(self.buttonSize)] ?: cornerRadiusValue; |
| |
| if (_buttonSizeSet && !(currentCornerRadius == nil)) { |
| [self updateCorners]; |
| } |
| } |
| |
| - (void)setPressedCornerRadius:(CGFloat)cornerRadius |
| forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _pressedCornerRadius[@(size)] = [NSNumber numberWithFloat:cornerRadius]; |
| } |
| |
| - (void)setImageEdgeInsets:(UIEdgeInsets)imageEdgeInsets |
| forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _customInsetAvailable = NO; |
| |
| _imageEdgeInsetsForSize[@(size)] = [NSValue valueWithUIEdgeInsets:imageEdgeInsets]; |
| [self updateInsets]; |
| } |
| |
| - (void)setMinimumHeight:(CGFloat)minimumHeight { |
| _minimumHeight = minimumHeight; |
| [self setNeedsLayout]; |
| } |
| |
| - (void)setMinimumWidth:(CGFloat)minimumWidth { |
| _minimumWidth = minimumWidth; |
| [self setNeedsLayout]; |
| } |
| |
| /** |
| * A color used as the button's @c backgroundColor for @c state. |
| * |
| * @param state The state. |
| * @return The background color. |
| */ |
| - (nullable UIColor *)backgroundColorForState:(UIControlState)state { |
| return _backgroundColors[@(state)] ?: _backgroundColors[@(UIControlStateNormal)]; |
| } |
| |
| /** |
| * A color used as the button's @c tintColor for @c state. |
| * |
| * @param state The state. |
| * @return The tint color. |
| */ |
| - (nullable UIColor *)tintColorForState:(UIControlState)state { |
| return _tintColors[@(state)] ?: _tintColors[@(UIControlStateNormal)]; |
| } |
| |
| /** |
| * A color used as the button's @c borderColor for @c state. |
| * |
| * @param state The state. |
| * @return The border color. |
| */ |
| - (nullable UIColor *)borderColorForState:(UIControlState)state { |
| return _borderColors[@(state)] ?: _borderColors[@(UIControlStateNormal)]; |
| } |
| |
| /** |
| * A MDCShadow used as the button's @c shadow for @c state. |
| * |
| * @param state The state. |
| * @return The shadow. |
| */ |
| - (MDCShadow *)shadowForState:(UIControlState)state { |
| return _shadows[@(state)] ?: _shadows[@(UIControlStateNormal)]; |
| } |
| |
| - (void)updateImageColorForState:(UIControlState)state { |
| UIColor *color = [self tintColorForState:state]; |
| self.tintColor = color; |
| if (self.currentImage != nil && color != nil && |
| self.currentImage.renderingMode == UIImageRenderingModeAlwaysTemplate) { |
| [self setImage:[self.currentImage imageWithTintColor:color] forState:state]; |
| } |
| } |
| |
| - (void)updateFont { |
| UIFont *currentFont = self.titleLabel.font; |
| |
| if (@available(iOS 15.0, *)) { |
| currentFont = _fonts[@(self.buttonSize)]; |
| if (_buttonSizeSet && !(currentFont == nil)) { |
| self.titleLabel.font = currentFont; |
| } |
| } |
| |
| self.titleLabel.font = currentFont; |
| } |
| |
| - (void)updateSymbolFont { |
| M3CIconAttributes *currentAttributes = nil; |
| |
| if (@available(iOS 15.0, *)) { |
| currentAttributes = _symbolFonts[@(self.buttonSize)]; |
| if (_buttonSizeSet && currentAttributes != nil) { |
| _symbolSize = [[UIFontMetrics metricsForTextStyle:currentAttributes.textStyle] |
| scaledValueForValue:currentAttributes.pointSize |
| compatibleWithTraitCollection:self.traitCollection]; |
| self.imageView.bounds = CGRectMake(0, 0, _symbolSize, _symbolSize); |
| } |
| } |
| } |
| |
| - (void)updateCGColors { |
| if (_buttonSizeSet) { |
| self.visualBackground.layer.borderColor = [self borderColorForState:self.state].CGColor; |
| self.layer.borderColor = UIColor.clearColor.CGColor; |
| } else { |
| self.layer.borderColor = [self borderColorForState:self.state].CGColor; |
| } |
| } |
| |
| - (void)updateColors { |
| if (_buttonSizeSet) { |
| self.visualBackground.backgroundColor = [self backgroundColorForState:self.state]; |
| self.backgroundColor = UIColor.clearColor; |
| } else { |
| self.backgroundColor = [self backgroundColorForState:self.state]; |
| } |
| |
| [self updateImageColorForState:self.state]; |
| [self updateCGColors]; |
| } |
| |
| - (void)updateShadows { |
| MDCShadow *shadow = [self shadowForState:self.state]; |
| shadow = [[MDCShadowBuilder builderWithColor:shadow.color |
| opacity:shadow.opacity |
| radius:shadow.radius |
| offset:shadow.offset |
| spread:shadow.spread] build]; |
| if (_buttonSizeSet) { |
| MDCShadow *emptyShadow = [[MDCShadowBuilder builderWithColor:UIColor.clearColor |
| opacity:0 |
| radius:0 |
| offset:CGSizeZero |
| spread:0] build]; |
| MDCConfigureShadowForView(self, emptyShadow); |
| MDCConfigureShadowForView(self.visualBackground, shadow); |
| } else { |
| MDCConfigureShadowForView(self, shadow); |
| } |
| } |
| |
| - (void)setImageEdgeInsetsWithImageAndTitle:(UIEdgeInsets)imageEdgeInsetsWithImageAndTitle |
| forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _customInsetAvailable = NO; |
| |
| _imageEdgeInsetsForSize[@(size)] = |
| [NSValue valueWithUIEdgeInsets:imageEdgeInsetsWithImageAndTitle]; |
| [self updateInsets]; |
| } |
| |
| - (void)setEdgeInsetsWithImageAndTitle:(UIEdgeInsets)edgeInsetsWithImageAndTitle |
| forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _customInsetAvailable = NO; |
| |
| _edgeInsetsWithImageAndTitleForSize[@(size)] = |
| [NSValue valueWithUIEdgeInsets:edgeInsetsWithImageAndTitle]; |
| [self updateInsets]; |
| } |
| |
| - (void)setEdgeInsetsWithImage:(UIEdgeInsets)edgeInsetsWithImage |
| forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _customInsetAvailable = NO; |
| |
| _edgeInsetsWithImageForSize[@(size)] = [NSValue valueWithUIEdgeInsets:edgeInsetsWithImage]; |
| [self updateInsets]; |
| } |
| |
| - (void)setEdgeInsetsWithTitle:(UIEdgeInsets)edgeInsetsWithTitle |
| forSize:(M3CButtonSize)size API_AVAILABLE(ios(15.0)) { |
| _customInsetAvailable = NO; |
| |
| _edgeInsetsWithTitleForSize[@(size)] = [NSValue valueWithUIEdgeInsets:edgeInsetsWithTitle]; |
| [self updateInsets]; |
| } |
| |
| - (void)updateCorners { |
| // Default to the existing corner radius. |
| NSNumber *currentCornerRadius = [NSNumber numberWithFloat:self.layer.cornerRadius]; |
| if (@available(iOS 15.0, *)) { |
| if (self.isHighlighted) { |
| currentCornerRadius = _pressedCornerRadius[@(self.buttonSize)]; |
| } else { |
| currentCornerRadius = _cornerRadius[@(self.buttonSize)]; |
| } |
| } |
| |
| self.visualBackground.layer.cornerRadius = [currentCornerRadius floatValue]; |
| self.visualBackground.layer.cornerCurve = kCACornerCurveCircular; |
| self.layer.cornerRadius = [currentCornerRadius floatValue]; |
| self.layer.cornerCurve = kCACornerCurveCircular; |
| } |
| |
| - (void)setImageEdgeInsetsWithImageAndTitle:(UIEdgeInsets)imageEdgeInsetsWithImageAndTitle { |
| _imageEdgeInsetsWithImageAndTitle = imageEdgeInsetsWithImageAndTitle; |
| _customInsetAvailable = NO; |
| [self updateInsets]; |
| [self updateShadows]; |
| } |
| |
| - (void)setEdgeInsetsWithImageAndTitle:(UIEdgeInsets)edgeInsetsWithImageAndTitle { |
| _edgeInsetsWithImageAndTitle = edgeInsetsWithImageAndTitle; |
| _customInsetAvailable = NO; |
| [self updateInsets]; |
| [self updateShadows]; |
| } |
| |
| - (void)setEdgeInsetsWithImageOnly:(UIEdgeInsets)edgeInsetsWithImageOnly { |
| _edgeInsetsWithImageOnly = edgeInsetsWithImageOnly; |
| _customInsetAvailable = NO; |
| [self updateInsets]; |
| [self updateShadows]; |
| } |
| |
| - (void)setEdgeInsetsWithTitleOnly:(UIEdgeInsets)edgeInsetsWithTitleOnly { |
| _edgeInsetsWithTitleOnly = edgeInsetsWithTitleOnly; |
| _customInsetAvailable = NO; |
| [self updateInsets]; |
| [self updateShadows]; |
| } |
| |
| - (void)setContentEdgeInsets:(UIEdgeInsets)edgeInsets { |
| [super setContentEdgeInsets:edgeInsets]; |
| _customInsetAvailable = YES; |
| |
| // Update constraint constants for multiline layout. |
| if (self.layoutTitleWithConstraints) { |
| [self updateTitleLabelConstraint]; |
| } |
| } |
| |
| - (void)updateInsets { |
| if (!_customInsetAvailable) { |
| BOOL hasTitle = self.currentTitle.length > 0 || self.currentAttributedTitle.length > 0; |
| BOOL hasImage = self.currentImage.size.width > 0; |
| if (hasImage && hasTitle) { |
| if (@available(iOS 15.0, *)) { |
| if (_buttonSizeSet) { |
| self.contentEdgeInsets = |
| [_edgeInsetsWithImageAndTitleForSize[@(self.buttonSize)] UIEdgeInsetsValue]; |
| self.imageEdgeInsets = [_imageEdgeInsetsForSize[@(self.buttonSize)] UIEdgeInsetsValue]; |
| } else { |
| self.contentEdgeInsets = self.edgeInsetsWithImageAndTitle; |
| self.imageEdgeInsets = self.imageEdgeInsetsWithImageAndTitle; |
| } |
| } else { |
| self.contentEdgeInsets = self.edgeInsetsWithImageAndTitle; |
| self.imageEdgeInsets = self.imageEdgeInsetsWithImageAndTitle; |
| } |
| } else if (hasImage) { |
| if (@available(iOS 15.0, *)) { |
| if (_buttonSizeSet) { |
| self.contentEdgeInsets = |
| [_edgeInsetsWithImageForSize[@(self.buttonSize)] UIEdgeInsetsValue]; |
| // Please add an imageEdgeInsetsWithImageOnly to specify a non zero value. |
| self.imageEdgeInsets = UIEdgeInsetsZero; |
| } else { |
| self.contentEdgeInsets = self.edgeInsetsWithImageOnly; |
| // Please add an imageEdgeInsetsWithImageOnly to specify a non zero value. |
| self.imageEdgeInsets = UIEdgeInsetsZero; |
| } |
| } else { |
| self.contentEdgeInsets = self.edgeInsetsWithImageOnly; |
| // Please add an imageEdgeInsetsWithImageOnly to specify a non zero value. |
| self.imageEdgeInsets = UIEdgeInsetsZero; |
| } |
| } else if (hasTitle) { |
| if (@available(iOS 15.0, *)) { |
| if (_buttonSizeSet) { |
| self.contentEdgeInsets = |
| [_edgeInsetsWithTitleForSize[@(self.buttonSize)] UIEdgeInsetsValue]; |
| // Please add an imageEdgeInsetsWithTitleOnly to specify a non zero value. |
| self.imageEdgeInsets = UIEdgeInsetsZero; |
| } else { |
| self.contentEdgeInsets = self.edgeInsetsWithTitleOnly; |
| // Please add an imageEdgeInsetsWithTitleOnly to specify a non zero value. |
| self.imageEdgeInsets = UIEdgeInsetsZero; |
| } |
| } else { |
| self.contentEdgeInsets = self.edgeInsetsWithTitleOnly; |
| // Please add an imageEdgeInsetsWithTitleOnly to specify a non zero value. |
| self.imageEdgeInsets = UIEdgeInsetsZero; |
| } |
| } |
| _customInsetAvailable = NO; |
| } |
| } |
| |
| - (void)setBorderWidth:(CGFloat)borderWidth { |
| if (_buttonSizeSet) { |
| self.visualBackground.layer.borderWidth = borderWidth; |
| } else { |
| self.layer.borderWidth = borderWidth; |
| } |
| } |
| |
| - (CGFloat)borderWidth { |
| if (_buttonSizeSet) { |
| return self.visualBackground.layer.borderWidth; |
| } |
| return self.layer.borderWidth; |
| } |
| |
| - (void)setTextCanWrap:(BOOL)textCanWrap { |
| if (_textCanWrap != textCanWrap) { |
| self.titleLabel.lineBreakMode = |
| textCanWrap ? NSLineBreakByWordWrapping : NSLineBreakByTruncatingMiddle; |
| self.titleLabel.numberOfLines = textCanWrap ? 0 : 1; |
| |
| _textCanWrap = textCanWrap; |
| } |
| } |
| |
| #pragma mark - UIButton |
| - (void)setEnabled:(BOOL)enabled { |
| [super setEnabled:enabled]; |
| [self updateColors]; |
| } |
| |
| - (void)setHighlighted:(BOOL)highlighted { |
| BOOL animated = highlighted ? NO : YES; |
| if (@available(iOS 15.0, *)) { |
| if (_buttonSizeSet && _pressedCornerRadius[@(self.buttonSize)] != nil) { |
| // If there is a custom value present for the pressed state animate into and out of the new |
| // corner radius. |
| animated = YES; |
| } |
| } |
| [self setHighlighted:highlighted animated:animated]; |
| } |
| |
| - (void)setHighlighted:(BOOL)highlighted animated:(BOOL)animated { |
| [super setHighlighted:highlighted]; |
| void (^animations)(void) = ^{ |
| [self updateColors]; |
| if (@available(iOS 15.0, *)) { |
| if (_buttonSizeSet) { |
| [self updateCorners]; |
| [self updateShadows]; |
| } |
| } |
| }; |
| |
| if (animated) { |
| [UIView animateWithDuration:_animationDuration animations:animations]; |
| } else { |
| animations(); |
| } |
| } |
| |
| - (void)setSelected:(BOOL)selected { |
| [super setSelected:selected]; |
| [self updateColors]; |
| [self updateInsets]; |
| } |
| |
| - (void)setTitle:(nullable NSString *)title forState:(UIControlState)state { |
| [super setTitle:title forState:state]; |
| [self updateInsets]; |
| |
| // If the button size is set, then the visual background is a subview of the button. If a client |
| // sets the button size then the title we need to make sure that the title is not obscured by the |
| // visual background. |
| if (_buttonSizeSet) { |
| [self sendSubviewToBack:self.visualBackground]; |
| } |
| } |
| |
| - (void)setAttributedTitle:(nullable NSAttributedString *)title forState:(UIControlState)state { |
| [super setAttributedTitle:title forState:state]; |
| [self updateInsets]; |
| } |
| |
| - (void)setImage:(nullable UIImage *)image forState:(UIControlState)state { |
| [super setImage:image forState:state]; |
| [self updateInsets]; |
| |
| // If the button size is set, then the visual background is a subview of the button. If a client |
| // sets the button size then the image we need to make sure that the image is not obscured by the |
| // visual background. |
| if (_buttonSizeSet) { |
| [self sendSubviewToBack:self.visualBackground]; |
| } |
| } |
| |
| - (void)didMoveToSuperview { |
| [super didMoveToSuperview]; |
| |
| // If the image or title are set before the button is part of the heirarchy, then the visual |
| // background will be on top of the image or title. |
| if (_buttonSizeSet) { |
| [self sendSubviewToBack:self.visualBackground]; |
| } |
| } |
| |
| - (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection { |
| [super traitCollectionDidChange:previousTraitCollection]; |
| [self updateCGColors]; |
| [self updateSymbolFont]; |
| |
| if (@available(iOS 15.0, *)) { |
| _visualContentSize = [self explicitSize]; |
| } |
| [self invalidateIntrinsicContentSize]; |
| [self setNeedsLayout]; |
| } |
| |
| - (void)layoutSubviews { |
| [super layoutSubviews]; |
| [self setCapsuleCornersBasedOn:self.frame.size]; |
| [self updateSymbolFont]; |
| |
| if (_buttonSizeSet) { |
| if (_visualContentSize.width < kMinimumTouchTarget || |
| _visualContentSize.height < kMinimumTouchTarget) { |
| self.visualBackground.frame = |
| CGRectMake(MAX(0, (kMinimumTouchTarget - _visualContentSize.width) / 2), |
| MAX(0, (kMinimumTouchTarget - _visualContentSize.height) / 2), |
| _visualContentSize.width, _visualContentSize.height); |
| } else { |
| self.visualBackground.frame = self.bounds; |
| } |
| } |
| [self updateShadows]; |
| } |
| |
| - (void)setCapsuleCornersBasedOn:(CGSize)size { |
| if (self.isCapsuleShape) { |
| if (@available(iOS 15.0, *)) { |
| if (_buttonSizeSet) { |
| if (self.isHighlighted && _pressedCornerRadius[@(self.buttonSize)] != nil) { |
| self.visualBackground.layer.cornerRadius = |
| [_pressedCornerRadius[@(self.buttonSize)] floatValue]; |
| self.visualBackground.layer.cornerCurve = kCACornerCurveCircular; |
| self.layer.cornerRadius = self.visualBackground.layer.cornerRadius; |
| self.layer.cornerCurve = self.visualBackground.layer.cornerCurve; |
| } else { |
| CGFloat height = MIN(size.height, _visualContentSize.height); |
| CGFloat width = MIN(size.width, _visualContentSize.width); |
| self.visualBackground.layer.cornerRadius = MIN(height, width) / 2; |
| self.visualBackground.layer.cornerCurve = kCACornerCurveCircular; |
| self.layer.cornerRadius = self.visualBackground.layer.cornerRadius; |
| self.layer.cornerCurve = self.visualBackground.layer.cornerCurve; |
| } |
| } else { |
| self.layer.cornerRadius = size.height / 2; |
| self.layer.cornerCurve = kCACornerCurveCircular; |
| } |
| } else { |
| self.layer.cornerRadius = size.height / 2; |
| self.layer.cornerCurve = kCACornerCurveCircular; |
| } |
| } |
| } |
| |
| - (CGSize)clampToMinimumSize:(CGSize)size { |
| size.height = MAX(size.height, _minimumHeight); |
| |
| if (_buttonSizeSet) { |
| size.width = MAX(size.width, _minimumWidth); |
| } else { |
| size.width = MAX(MAX(size.height, size.width), _minimumWidth); |
| } |
| return size; |
| } |
| #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; |
| } |
| |
| - (CGSize)sizeBasedOnLabel { |
| CGFloat textWidth = self.titleLabel.preferredMaxLayoutWidth; |
| if (textWidth <= 0) { |
| self.titleLabel.preferredMaxLayoutWidth = self.frame.size.width - self.contentEdgeInsets.left - |
| self.contentEdgeInsets.right - |
| self.imageView.frame.size.width; |
| } |
| CGSize titleLabelSize = self.titleLabel.intrinsicContentSize; |
| self.titleLabel.preferredMaxLayoutWidth = textWidth; |
| return CGSizeMake( |
| ceil(titleLabelSize.width) + self.contentEdgeInsets.left + self.contentEdgeInsets.right + |
| self.imageView.frame.size.width, |
| titleLabelSize.height + self.contentEdgeInsets.top + self.contentEdgeInsets.bottom); |
| } |
| |
| #pragma mark - Overrides |
| |
| - (CGSize)intrinsicContentSize { |
| [self updateInsets]; |
| CGSize size; |
| if ([self textCanWrap]) { |
| size = [self sizeBasedOnLabel]; |
| } else { |
| size = [super intrinsicContentSize]; |
| } |
| CGSize clampToMinimumSize = [self clampToMinimumSize:size]; |
| if (_buttonSizeSet) { |
| if (@available(iOS 15.0, *)) { |
| size = [self explicitSize]; |
| } |
| _visualContentSize = size; |
| |
| // The MAX function only takes two inputs but we need the max of clampToMinimumSize, size, and |
| // kMinimumTouchTarget. |
| CGSize minimumVisualSize = CGSizeMake(MAX(clampToMinimumSize.width, size.width), |
| MAX(clampToMinimumSize.height, size.height)); |
| return CGSizeMake(MAX(kMinimumTouchTarget, minimumVisualSize.width), |
| MAX(kMinimumTouchTarget, minimumVisualSize.height)); |
| } else { |
| return clampToMinimumSize; |
| } |
| } |
| |
| - (CGSize)sizeThatFits:(CGSize)size { |
| CGSize newSize; |
| if ([self textCanWrap]) { |
| newSize = [self sizeBasedOnLabel]; |
| } else { |
| newSize = [super sizeThatFits:size]; |
| } |
| CGSize clampToMinimumSize = [self clampToMinimumSize:newSize]; |
| [self setCapsuleCornersBasedOn:clampToMinimumSize]; |
| if (_buttonSizeSet) { |
| if (@available(iOS 15.0, *)) { |
| newSize = [self explicitSize]; |
| } |
| _visualContentSize = newSize; |
| |
| // The MAX function only takes two inputs but we need the max of clampToMinimumSize, size, and |
| // kMinimumTouchTarget. |
| CGSize minimumVisualSize = CGSizeMake(MAX(clampToMinimumSize.width, newSize.width), |
| MAX(clampToMinimumSize.height, newSize.height)); |
| return CGSizeMake(MAX(kMinimumTouchTarget, minimumVisualSize.width), |
| MAX(kMinimumTouchTarget, minimumVisualSize.height)); |
| } else { |
| return clampToMinimumSize; |
| } |
| } |
| |
| - (CGSize)explicitSize API_AVAILABLE(ios(15.0)) { |
| [self updateInsets]; |
| BOOL hasTitle = self.currentTitle.length > 0 || self.currentAttributedTitle.length > 0; |
| BOOL hasImage = self.currentImage != nil; |
| CGSize size = [super intrinsicContentSize]; |
| CGFloat imageEdgeInsetsWidth = self.imageEdgeInsets.left + self.imageEdgeInsets.right; |
| CGFloat contentEdgeInsetsWidth = self.contentEdgeInsets.left + self.contentEdgeInsets.right; |
| CGFloat imageEdgeInsetsHeight = self.imageEdgeInsets.top + self.imageEdgeInsets.bottom; |
| CGFloat contentEdgeInsetsHeight = self.contentEdgeInsets.top + self.contentEdgeInsets.bottom; |
| if (hasTitle && hasImage) { |
| CGFloat iconWidth = imageEdgeInsetsWidth + _symbolSize; |
| CGFloat titleWidth = contentEdgeInsetsWidth + self.titleLabel.intrinsicContentSize.width; |
| CGFloat iconHeight = imageEdgeInsetsHeight + _symbolSize; |
| CGFloat titleHeight = contentEdgeInsetsHeight + self.titleLabel.intrinsicContentSize.height; |
| size = CGSizeMake(iconWidth + titleWidth, MAX(iconHeight, titleHeight)); |
| } else if (hasImage) { |
| // If only using an image we set the contentEdgeInsets rather than imageEdgeInsets. |
| CGFloat width = contentEdgeInsetsWidth + _symbolSize; |
| CGFloat height = contentEdgeInsetsHeight + _symbolSize; |
| size = CGSizeMake(width, height); |
| } else if (hasTitle) { |
| CGFloat width = contentEdgeInsetsWidth + self.titleLabel.intrinsicContentSize.width; |
| CGFloat height = contentEdgeInsetsHeight + self.titleLabel.intrinsicContentSize.height; |
| size = CGSizeMake(width, height); |
| } |
| |
| return size; |
| } |
| |
| #pragma mark - CALayerDelegate |
| |
| - (nullable id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)key { |
| if (layer == self.layer && M3CIsMDCShadowPathKey(key)) { |
| // Provide a custom action for the view's layer's shadow path only. |
| return M3CShadowPathActionForLayer(layer); |
| } |
| |
| return [super actionForLayer:layer forKey:key]; |
| } |
| |
| @end |
| |
| NS_ASSUME_NONNULL_END |