diff --git a/TORoundedButton/TORoundedButton.h b/TORoundedButton/TORoundedButton.h index f129257..70ade14 100644 --- a/TORoundedButton/TORoundedButton.h +++ b/TORoundedButton/TORoundedButton.h @@ -27,28 +27,42 @@ NS_ASSUME_NONNULL_BEGIN NS_SWIFT_NAME(RoundedButton) IB_DESIGNABLE @interface TORoundedButton : UIControl -/// The text that is displayed in center of the button (Default is "Button"). -@property (nonatomic, copy) IBInspectable NSString *text; - -/// The attributed string used in the label of this button. See `UILabel.attributedText` documentation for full details (Default is nil). -@property (nonatomic, copy, nullable) NSAttributedString *attributedText; - /// The radius of the corners of this button (Default is 12.0f). @property (nonatomic, assign) IBInspectable CGFloat cornerRadius; +/// The hosting container that manages all of the foreground views in this button. +/// You can either add your custom views to this view by default, or you can set +/// this property to your own custom UIView subclass in order to more efficiently manage sizing and layout. +@property (nonatomic, strong, null_resettable) UIView *contentView; + +/// The amount of inset padding between the content view and the edges of the button. +/// (Default value is 15 points inset from each edge). +@property (nonatomic, assign) UIEdgeInsets contentInset; + +/// The text that is displayed in center of the button (Default is nil). +@property (nonatomic, copy, nullable) IBInspectable NSString *text; + +/// The attributed string used in the label of this button. +/// See `UILabel.attributedText` documentation for full details (Default is nil). +@property (nonatomic, copy, nullable) NSAttributedString *attributedText; + /// The color of the text in this button (Default is white). @property (nonatomic, strong) IBInspectable UIColor *textColor; -/// When tapped, the level of transparency that the text label animates to. (Defaults to off with 1.0f). -@property (nonatomic, assign) IBInspectable CGFloat tappedTextAlpha; - -/// The font of the text in the button (Default is size UIFontTextStyleBody with bold). +/// The font of the text in the button +/// (Default is size UIFontTextStyleBody with bold). @property (nonatomic, strong) UIFont *textFont; -/// Because IB cannot handle fonts, this can alternatively be used to set the font size. (Default is off with 0.0). +/// Because IB cannot handle fonts, this can alternatively be used to set the font size. +/// (Default is off with 0.0). @property (nonatomic, assign) IBInspectable CGFloat textPointSize; -/// Taking the default button background color apply a brightness offset for the tapped color (Default is -0.1f. Set 0.0 for off). +/// When tapped, the level of transparency that the text label animates to. +/// (Defaults to off with 1.0f). +@property (nonatomic, assign) IBInspectable CGFloat tappedTextAlpha; + +/// Taking the default button background color apply a brightness offset for the tapped color +/// (Default is -0.1f. Set 0.0 for off). @property (nonatomic, assign) IBInspectable CGFloat tappedTintColorBrightnessOffset; /// If desired, explicity set the background color of the button when tapped (Default is nil). @@ -60,15 +74,35 @@ IB_DESIGNABLE @interface TORoundedButton : UIControl /// The duration of the tapping cross-fade animation (Default is 0.4f). @property (nonatomic, assign) CGFloat tapAnimationDuration; -/// Given the current size of the text label, the smallest horizontal width in which this button can scale. -@property (nonatomic, readonly) CGFloat minimumWidth; - /// A callback handler triggered each time the button is tapped. @property (nonatomic, copy) void (^tappedHandler)(void); -/// Create a new instance of a button with the provided text shown in the center. The size will be 288 points wide, and 50 tall. +/// Create a new instance of a button that can be further configured with either text or custom subviews. +/// The size will be 288 points wide, and 50 tall by default. +- (instancetype)init; + +/// Create a new instance of a button that can be further configured with either text or custom subviews. +- (instancetype)initWithFrame:(CGRect)frame; + +/// Create a new instance of a button with the provided text shown in the center. +/// The size will be 288 points wide, and 50 tall. - (instancetype)initWithText:(NSString *)text; +/// Create a new instance of a button with the provided view set as the hosting content view. +- (instancetype)initWithContentView:(__kindof UIView *)contentView; + +/// Resizes the button to fit the bounding size of all of the subviews in `contentView`, plus content insetting. +/// If subclassing this class, override this method for custom size control (Dont't forget to include content insetting). +/// If the content view only contains one subview (like the title label), or a custom content view is supplied, this will also be forwarded to it. +/// If the content vieww contains multiple subviews, their bounding size will be calculated and then applied to this button. +- (void)sizeToFit; + +/// Calculates and returns the appropriate minimum size this button needs to be to fit into the provided size. +/// If subclassing this class, override this method for custom size control (Dont't forget to include content insetting). +/// If the content view only contains one subview (like the title label), or a custom content view is supplied, this will also be forwarded to it. +/// If the content vieww contains multiple subviews, their bounding size will be calculated and then applied to this button. +- (CGSize)sizeThatFits:(CGSize)size; + @end NS_ASSUME_NONNULL_END diff --git a/TORoundedButton/TORoundedButton.m b/TORoundedButton/TORoundedButton.m index 542552f..75687f3 100644 --- a/TORoundedButton/TORoundedButton.m +++ b/TORoundedButton/TORoundedButton.m @@ -41,10 +41,10 @@ @implementation TORoundedButton { or not because the state can change before blocks complete. */ BOOL _isTapped; - /** A container view that holds all of the content view and performs the clipping. */ + /** A hosting container holding all of the view content that tap animations are applied to. */ UIView *_containerView; - /** The title label displaying the text in the center of the button. */ + /** If `text` is set, the internally managed title label to show it. */ UILabel *_titleLabel; /** A background view that displays the rounded box behind the button text. */ @@ -53,18 +53,14 @@ @implementation TORoundedButton { #pragma mark - View Creation - -- (instancetype)initWithText:(NSString *)text { - if (self = [super initWithFrame:(CGRect){0,0, 288.0f, 50.0f}]) { - [self _roundedButtonCommonInit]; - _titleLabel.text = text; - [_titleLabel sizeToFit]; - } - +- (instancetype)init { + if (self = [self initWithFrame:(CGRect){0,0, 288.0f, 50.0f}]) { } return self; } - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { + _contentView = [UIView new]; [self _roundedButtonCommonInit]; } @@ -73,7 +69,27 @@ - (instancetype)initWithFrame:(CGRect)frame { - (instancetype)initWithCoder:(NSCoder *)aDecoder { if (self = [super initWithCoder:aDecoder]) { + _contentView = [UIView new]; + [self _roundedButtonCommonInit]; + } + + return self; +} + +- (instancetype)initWithContentView:(__kindof UIView *)contentView { + if (self = [super initWithFrame:contentView.bounds]) { + _contentView = contentView; + [self _roundedButtonCommonInit]; + } + return self; +} + +- (instancetype)initWithText:(NSString *)text { + if (self = [super initWithFrame:(CGRect){0,0, 288.0f, 50.0f}]) { [self _roundedButtonCommonInit]; + [self _makeTitleLabelIfNeeded]; + _titleLabel.text = text; + [_titleLabel sizeToFit]; } return self; @@ -86,11 +102,12 @@ - (void)_roundedButtonCommonInit TOROUNDEDBUTTON_OBJC_DIRECT { _tapAnimationDuration = (_tapAnimationDuration > FLT_EPSILON) ?: 0.4f; _tappedButtonScale = (_tappedButtonScale > FLT_EPSILON) ?: 0.97f; _tappedTintColorBrightnessOffset = !TO_ROUNDED_BUTTON_FLOAT_IS_ZERO(_tappedTintColorBrightnessOffset) ?: -0.15f; + _contentInset = (UIEdgeInsets){15.0, 15.0, 15.0, 15.0}; // Set the tapped tint color if we've set to dynamically calculate it [self _updateTappedTintColorForTintColor]; - // Create the container view that manages the image view and text + // Create the container view that holds all of the views for animations. _containerView = [[UIView alloc] initWithFrame:self.bounds]; _containerView.backgroundColor = [UIColor clearColor]; _containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; @@ -108,14 +125,24 @@ - (void)_roundedButtonCommonInit TOROUNDEDBUTTON_OBJC_DIRECT { #endif [_containerView addSubview:_backgroundView]; - // Create the title label that will display the button text - UIFont *buttonFont = [UIFont systemFontOfSize:17.0f weight:UIFontWeightBold]; - if (@available(iOS 11.0, *)) { - // Apply resizable button metrics to font - UIFontMetrics *metrics = [[UIFontMetrics alloc] initForTextStyle:UIFontTextStyleBody]; - buttonFont = [metrics scaledFontForFont:buttonFont]; - } - + // The foreground content view + [_containerView addSubview:_contentView]; + + // Create action events for all possible interactions with this control + [self addTarget:self action:@selector(_didTouchDownInside) forControlEvents:UIControlEventTouchDown|UIControlEventTouchDownRepeat]; + [self addTarget:self action:@selector(_didTouchUpInside) forControlEvents:UIControlEventTouchUpInside]; + [self addTarget:self action:@selector(_didDragOutside) forControlEvents:UIControlEventTouchDragExit|UIControlEventTouchCancel]; + [self addTarget:self action:@selector(_didDragInside) forControlEvents:UIControlEventTouchDragEnter]; +} + +- (void)_makeTitleLabelIfNeeded TOROUNDEDBUTTON_OBJC_DIRECT { + if (_titleLabel) { return; } + + // Make the font bold, and opt it into Dynamic Type sizing + UIFontMetrics *const metrics = [[UIFontMetrics alloc] initForTextStyle:UIFontTextStyleBody]; + UIFont *const buttonFont = [metrics scaledFontForFont:[UIFont systemFontOfSize:17.0f weight:UIFontWeightBold]]; + + // Configure the title label _titleLabel = [[UILabel alloc] initWithFrame:CGRectZero]; _titleLabel.textAlignment = NSTextAlignmentCenter; _titleLabel.textColor = [UIColor whiteColor]; @@ -124,24 +151,60 @@ - (void)_roundedButtonCommonInit TOROUNDEDBUTTON_OBJC_DIRECT { _titleLabel.backgroundColor = [self _labelBackgroundColor]; _titleLabel.text = @"Button"; _titleLabel.numberOfLines = 0; - [_containerView addSubview:_titleLabel]; - - // Create action events for all possible interactions with this control - [self addTarget:self action:@selector(_didTouchDownInside) forControlEvents:UIControlEventTouchDown|UIControlEventTouchDownRepeat]; - [self addTarget:self action:@selector(_didTouchUpInside) forControlEvents:UIControlEventTouchUpInside]; - [self addTarget:self action:@selector(_didDragOutside) forControlEvents:UIControlEventTouchDragExit|UIControlEventTouchCancel]; - [self addTarget:self action:@selector(_didDragInside) forControlEvents:UIControlEventTouchDragEnter]; + [_contentView addSubview:_titleLabel]; } -#pragma mark - View Displaying - +#pragma mark - View Layout - - (void)layoutSubviews { [super layoutSubviews]; + const CGSize boundsSize = self.bounds.size; + _contentView.frame = (CGRect){ + .origin.x = _contentInset.left, + .origin.y = _contentInset.top, + .size.width = boundsSize.width - (_contentInset.left + _contentInset.right), + .size.height = boundsSize.height - (_contentInset.top + _contentInset.bottom), + }; + // Configure the button text - [_titleLabel sizeToFit]; - _titleLabel.center = _containerView.center; - _titleLabel.frame = CGRectIntegral(_titleLabel.frame); + if (_titleLabel) { + [_titleLabel sizeToFit]; + _titleLabel.center = (CGPoint){ + .x = CGRectGetMidX(_contentView.bounds), + .y = CGRectGetMidY(_contentView.bounds) + }; + _titleLabel.frame = CGRectIntegral(_titleLabel.frame); + } +} + +- (void)sizeToFit { [super sizeToFit]; } + +- (CGSize)sizeThatFits:(CGSize)size { + const CGFloat horizontalPadding = (_contentInset.left + _contentInset.right); + const CGFloat verticalPadding = (_contentInset.top + _contentInset.bottom); + const CGSize contentSize = CGSizeMake(size.width - horizontalPadding, size.height - verticalPadding); + CGSize newSize = CGSizeZero; + + // Check to see if the content view was overridden with custom class that implements its own sizing method. + const BOOL isMethodOverridden = [_contentView methodForSelector:@selector(sizeThatFits:)] != + [UIView instanceMethodForSelector:@selector(sizeThatFits:)]; + if (isMethodOverridden) { + newSize = [_contentView sizeThatFits:size]; + } else if (_contentView.subviews.count == 1) { + // When there is 1 view, we can reliably scale the whole view around it. + newSize = [_contentView.subviews.firstObject sizeThatFits:contentSize]; + } else if (_contentView.subviews.count > 1) { + // For multiple subviews, work out the bounds of all of the views and scale the button to fit + for (UIView *view in _contentView.subviews) { + newSize.width = MAX(CGRectGetMaxX(view.frame), newSize.width); + newSize.height = MAX(CGRectGetMaxY(view.frame), newSize.height); + } + } + + newSize.width += horizontalPadding; + newSize.height += verticalPadding; + return newSize; } - (void)tintColorDidChange { @@ -323,7 +386,18 @@ - (void)_setButtonScaledTappedAnimated:(BOOL)animated TOROUNDEDBUTTON_OBJC_DIREC #pragma mark - Public Accessors - +- (void)setContentView:(UIView *)contentView { + if (_contentView == contentView) { return; } + + _titleLabel = nil; + [_contentView removeFromSuperview]; + _contentView = contentView ?: [UIView new]; + [self addSubview:_contentView]; + [self setNeedsLayout]; +} + - (void)setAttributedText:(NSAttributedString *)attributedText { + [self _makeTitleLabelIfNeeded]; _titleLabel.attributedText = attributedText; [_titleLabel sizeToFit]; [self setNeedsLayout]; @@ -332,6 +406,7 @@ - (void)setAttributedText:(NSAttributedString *)attributedText { - (NSAttributedString *)attributedText { return _titleLabel.attributedText; } - (void)setText:(NSString *)text { + [self _makeTitleLabelIfNeeded]; _titleLabel.text = text; [_titleLabel sizeToFit]; [self setNeedsLayout]; @@ -397,10 +472,6 @@ - (void)setEnabled:(BOOL)enabled { _containerView.alpha = enabled ? 1 : 0.4; } -- (CGFloat)minimumWidth { - return _titleLabel.frame.size.width; -} - #pragma mark - Graphics Handling - - (UIColor *)_brightnessAdjustedColorWithColor:(UIColor *)color amount:(CGFloat)amount TOROUNDEDBUTTON_OBJC_DIRECT { diff --git a/TORoundedButtonExample/Base.lproj/Main.storyboard b/TORoundedButtonExample/Base.lproj/Main.storyboard index e0a4987..c555c26 100644 --- a/TORoundedButtonExample/Base.lproj/Main.storyboard +++ b/TORoundedButtonExample/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -17,19 +17,16 @@ - - + + + - - - - diff --git a/TORoundedButtonExample/ViewController.m b/TORoundedButtonExample/ViewController.m index 37fec05..d41b8b0 100644 --- a/TORoundedButtonExample/ViewController.m +++ b/TORoundedButtonExample/ViewController.m @@ -20,16 +20,24 @@ - (void)viewDidLoad { // Hide the tapped label self.tappedLabel.alpha = 0.0f; + __weak typeof(self) weakSelf = self; + self.button.tappedHandler = ^{ + [weakSelf playFadeAnimationOnView:weakSelf.tappedLabel]; + }; + // Uncomment this line for an attributed string example // self.button.attributedText = [[self class] makeExampleAttributedString]; // Uncomment to apply an alpha value to the button // self.button.tintColor = [self.view.tintColor colorWithAlphaComponent:0.4]; - __weak typeof(self) weakSelf = self; - self.button.tappedHandler = ^{ - [weakSelf playFadeAnimationOnView:weakSelf.tappedLabel]; - }; + // Uncomment to have the button shrink to wrap the text + // [self.button sizeToFit]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + self.button.center = self.view.center; } - (void)playFadeAnimationOnView:(UIView *)view diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index e7b78de..7b0f2b7 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -27,23 +27,6 @@ - (void)testDefaultValues XCTAssertEqual(button.tappedButtonScale, 0.97f); } -- (void)testMinimimumWidth -{ - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Long Button Name"]; - - // Manually - UILabel *titleLabel = nil; - for (UIView *subview in button.subviews.firstObject.subviews) { - if ([subview isKindOfClass:[UILabel class]]) { - titleLabel = (UILabel *)subview; - break; - } - } - - XCTAssert(button.minimumWidth > 0.0f); - XCTAssertEqual(titleLabel.frame.size.width, button.minimumWidth); -} - - (void)testButtonInteraction { TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Long Button Name"];