diff --git a/CHANGELOG.md b/CHANGELOG.md index a9856c8..78672a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ x.y.z Release Notes (yyyy-MM-dd) ============================================================= +2.1.0 Release Notes (2026-05-27) +============================================================= + +### Added + +* Re-added the `minimumWidth` property, now including the horizontal `contentInset` padding, to help external layout systems size the button around its content. + +### Fixed + +* An issue where the title label could wrap onto two lines after `sizeToFit` was called against an already-narrow frame. + 2.0.0 Release Notes (2026-01-22) ============================================================= diff --git a/TORoundedButton.podspec b/TORoundedButton.podspec index d7c5888..d39782d 100644 --- a/TORoundedButton.podspec +++ b/TORoundedButton.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TORoundedButton' - s.version = '2.0.0' + s.version = '2.1.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'A high-performance button control with rounded corners for iOS.' s.homepage = 'https://github.com/TimOliver/TORoundedButton' diff --git a/TORoundedButton/TORoundedButton.h b/TORoundedButton/TORoundedButton.h index 90e75bf..f75d9cc 100644 --- a/TORoundedButton/TORoundedButton.h +++ b/TORoundedButton/TORoundedButton.h @@ -134,6 +134,11 @@ IB_DESIGNABLE @interface TORoundedButton : UIControl /// A callback handler triggered each time the button is tapped. @property (nonatomic, copy) void (^tappedHandler)(void); +/// The smallest width this button can be while still fitting its content on a single +/// line, including the horizontal `contentInset` padding. Useful for external layout +/// systems (such as alert controllers) sizing themselves around the button. +@property (nonatomic, readonly) CGFloat minimumWidth; + /// 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; diff --git a/TORoundedButton/TORoundedButton.m b/TORoundedButton/TORoundedButton.m index 31fd842..9b101f4 100644 --- a/TORoundedButton/TORoundedButton.m +++ b/TORoundedButton/TORoundedButton.m @@ -292,6 +292,12 @@ - (void)layoutSubviews { // Lay out the title label if (!_titleLabel) { return; } + // Seed the label with the available content width first; otherwise a stale, + // too-narrow frame makes sizeToFit wrap the text onto multiple lines even + // when there is plenty of horizontal room. + CGRect labelFrame = _titleLabel.frame; + labelFrame.size = contentBounds.size; + _titleLabel.frame = labelFrame; [_titleLabel sizeToFit]; _titleLabel.center = (CGPoint){ .x = CGRectGetMidX(_contentView.bounds), @@ -330,6 +336,13 @@ - (CGSize)sizeThatFits:(CGSize)size { return newSize; } +- (CGFloat)minimumWidth { + // Measure at a large but finite size so the content lays out on a single line + // without tripping Core Text overflow handling (which a literal CGFLOAT_MAX can, + // once sizeThatFits: subtracts the horizontal padding from it). + return [self sizeThatFits:(CGSize){1.0e6, 1.0e6}].width; +} + #pragma mark - Interaction - - (void)_didTouchDownInside { diff --git a/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme b/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme index 435fa67..83521bb 100644 --- a/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme +++ b/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme @@ -26,7 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> #import "TORoundedButton.h" +@interface TORoundedButtonTestDelegate : NSObject +@property (nonatomic, assign) NSInteger tapCount; +@property (nonatomic, weak) TORoundedButton *lastButton; +@end + +@implementation TORoundedButtonTestDelegate +- (void)roundedButtonDidTap:(TORoundedButton *)button { + self.tapCount += 1; + self.lastButton = button; +} +@end + @interface TORoundedButtonExampleTests : XCTestCase @end @@ -49,4 +61,405 @@ - (void)testButtonInteraction [self waitForExpectations:@[expectation] timeout:0.5f]; } +#pragma mark - Sizing + +- (void)testMinimumWidthIncludesContentInset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; + UILabel *reference = [self referenceLabelForButton:button]; + CGFloat textWidth = [reference sizeThatFits:CGSizeMake(1.0e6, 1.0e6)].width; + CGFloat expected = textWidth + button.contentInset.left + button.contentInset.right; + XCTAssertEqualWithAccuracy(button.minimumWidth, expected, 1.0); +} + +- (void)testMinimumWidthGrowsWithLongerText { + TORoundedButton *shortButton = [[TORoundedButton alloc] initWithText:@"Hi"]; + TORoundedButton *longButton = [[TORoundedButton alloc] initWithText:@"A much longer button title"]; + XCTAssertGreaterThan(longButton.minimumWidth, shortButton.minimumWidth); +} + +- (void)testMinimumWidthGrowsWithContentInset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; + CGFloat before = button.minimumWidth; + button.contentInset = UIEdgeInsetsMake(15, 40, 15, 40); + CGFloat after = button.minimumWidth; + // Left+right inset delta drives the change: (40-15) + (40-15) = 50pt. + XCTAssertEqualWithAccuracy(after - before, 50.0, 1.0); +} + +#pragma mark - Helpers + +/// A standalone label matching the button's private title label font and text, for +/// measuring its natural *single-line* size (this label keeps the default +/// numberOfLines == 1) without coupling tests to exact pixel values. +- (UILabel *)referenceLabelForButton:(TORoundedButton *)button { + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + UILabel *reference = [[UILabel alloc] init]; + reference.font = titleLabel.font; + reference.text = titleLabel.text; + return reference; +} + +/// Reads the button's private `_isTapped` flag via KVC. +- (BOOL)isTappedForButton:(TORoundedButton *)button { + return [[button valueForKey:@"isTapped"] boolValue]; +} + +#pragma mark - Layout Regression + +- (void)testTitleLabelDoesNotWrapWhenButtonIsWideEnough { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Reasonably Long Title"]; + button.frame = CGRectMake(0, 0, 400, 52); // easily wide enough for one line + + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + + // Natural single-line height (a fresh label defaults to numberOfLines == 1). + UILabel *reference = [self referenceLabelForButton:button]; + [reference sizeToFit]; + CGFloat singleLineHeight = reference.frame.size.height; + + // Force the buggy precondition: a too-narrow label frame before layout. + CGRect narrow = titleLabel.frame; + narrow.size.width = 10.0; // narrower than the longest word + titleLabel.frame = narrow; + + [button setNeedsLayout]; + [button layoutIfNeeded]; + + // ±2pt slack absorbs CGRectIntegral rounding and font-metric variation. + XCTAssertEqualWithAccuracy(titleLabel.frame.size.height, singleLineHeight, 2.0, + @"Title label wrapped to multiple lines despite adequate button width"); +} + +#pragma mark - Init & Defaults + +- (void)testInitUsesDefaultFrameSize { + TORoundedButton *button = [[TORoundedButton alloc] init]; + XCTAssertEqualWithAccuracy(button.bounds.size.width, 288.0, 0.001); + XCTAssertEqualWithAccuracy(button.bounds.size.height, 52.0, 0.001); +} + +- (void)testInitWithTextSetsText { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Confirm"]; + XCTAssertEqualObjects(button.text, @"Confirm"); +} + +- (void)testInitWithContentViewSetsContentView { + UIView *custom = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 40)]; + TORoundedButton *button = [[TORoundedButton alloc] initWithContentView:custom]; + XCTAssertEqualObjects(button.contentView, custom); +} + +#pragma mark - Text & Appearance + +- (void)testTextRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] init]; + button.text = @"Hello"; + XCTAssertEqualObjects(button.text, @"Hello"); +} + +- (void)testAttributedTextRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] init]; + NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:@"Styled" + attributes:@{ NSForegroundColorAttributeName : [UIColor blueColor] }]; + button.attributedText = attributed; + + // Both the text and the attributes we set should survive the round-trip. We check a + // specific attribute we set rather than full-object equality, since UILabel augments + // the string with its own default attributes (so == against the original can fail). + XCTAssertEqualObjects(button.attributedText.string, @"Styled"); + UIColor *foreground = [button.attributedText attribute:NSForegroundColorAttributeName + atIndex:0 + effectiveRange:NULL]; + XCTAssertEqualObjects(foreground, [UIColor blueColor]); +} + +- (void)testTextColorRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.textColor = [UIColor redColor]; + // The getter returns the stored UIColor unchanged, so -isEqual: is the right check. + XCTAssertEqualObjects(button.textColor, [UIColor redColor]); +} + +- (void)testTextFontRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIFont *font = [UIFont systemFontOfSize:22.0]; + button.textFont = font; + XCTAssertEqualObjects(button.textFont, font); +} + +- (void)testTextPointSizeSetsFontSize { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.textPointSize = 28.0; + XCTAssertEqualWithAccuracy(button.textFont.pointSize, 28.0, 0.001); +} + +- (void)testContentInsetRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.contentInset = UIEdgeInsetsMake(10, 20, 10, 20); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(button.contentInset, UIEdgeInsetsMake(10, 20, 10, 20))); +} + +#pragma mark - Behavior + +- (void)testTappedHandlerFiresOnTouchUpInside { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + __block NSInteger count = 0; + button.tappedHandler = ^{ count += 1; }; + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqual(count, 1); +} + +- (void)testDelegateReceivesTap { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + TORoundedButtonTestDelegate *delegate = [TORoundedButtonTestDelegate new]; + button.delegate = delegate; + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqual(delegate.tapCount, 1); + XCTAssertEqualObjects(delegate.lastButton, button); +} + +- (void)testDisabledReducesContainerAlpha { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIView *container = [button valueForKey:@"containerView"]; + XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); + button.enabled = NO; + // setEnabled: hard-codes the disabled alpha to 0.4; this locks that value. + XCTAssertEqualWithAccuracy(container.alpha, 0.4, 0.001); + button.enabled = YES; + XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); +} + +- (void)testOverrideContentViewHidesAndRestoresContentView { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIView *override = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + + button.overrideContentView = override; + XCTAssertEqualObjects(button.overrideContentView, override); + XCTAssertTrue(button.contentView.hidden); + XCTAssertEqualObjects(override.superview, [button valueForKey:@"containerView"]); + + button.overrideContentView = nil; + XCTAssertNil(button.overrideContentView); + XCTAssertFalse(button.contentView.hidden); +} + +- (void)testBackgroundStyleBlurUsesVisualEffectView { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + // Changing backgroundStyle rebuilds the background view synchronously, so this + // holds even without a superview (where the view would otherwise be created lazily). + button.backgroundStyle = TORoundedButtonBackgroundStyleBlur; + XCTAssertEqual(button.backgroundStyle, TORoundedButtonBackgroundStyleBlur); + UIView *backgroundView = [button valueForKey:@"backgroundView"]; + XCTAssertTrue([backgroundView isKindOfClass:[UIVisualEffectView class]]); +} + +- (void)testSizeToFitResizesButtonToFitContent { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.frame = CGRectMake(0, 0, 1000, 200); + [button sizeToFit]; + + // sizeToFit should shrink the over-sized button down to its content: the label's + // single-line width plus the horizontal contentInset. The expected width is anchored + // to an independent reference measurement (not the button's own minimumWidth, which + // would make this circular since both route through sizeThatFits:). + UILabel *reference = [self referenceLabelForButton:button]; + CGFloat expectedWidth = [reference sizeThatFits:CGSizeMake(1.0e6, 1.0e6)].width + + button.contentInset.left + button.contentInset.right; + XCTAssertEqualWithAccuracy(button.bounds.size.width, expectedWidth, 1.0); + + // And it genuinely shrank from the over-sized starting frame in both dimensions. + XCTAssertLessThan(button.bounds.size.width, 1000.0); + XCTAssertLessThan(button.bounds.size.height, 200.0); +} + +#pragma mark - Accessors + +- (void)testContentViewRoundTripAndNilResets { + TORoundedButton *button = [[TORoundedButton alloc] init]; + UIView *custom = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 80, 30)]; + button.contentView = custom; + XCTAssertEqualObjects(button.contentView, custom); + + // contentView is null_resettable: setting nil restores a fresh, non-nil view. + button.contentView = nil; + XCTAssertNotNil(button.contentView); + XCTAssertNotEqualObjects(button.contentView, custom); +} + +- (void)testTintColorRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tintColor = [UIColor purpleColor]; + // The getter returns the explicitly-set tint color unchanged. + XCTAssertEqualObjects(button.tintColor, [UIColor purpleColor]); +} + +- (void)testTappedTintColorRoundTripResetsBrightnessOffset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedTintColor = [UIColor greenColor]; + XCTAssertEqualObjects(button.tappedTintColor, [UIColor greenColor]); + // Setting an explicit tapped tint disables the brightness-derived color. + XCTAssertEqualWithAccuracy(button.tappedTintColorBrightnessOffset, 0.0, 0.001); +} + +- (void)testTappedTintColorBrightnessOffsetRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedTintColorBrightnessOffset = 0.5; + XCTAssertEqualWithAccuracy(button.tappedTintColorBrightnessOffset, 0.5, 0.001); +} + +- (void)testTappedTextAlphaRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedTextAlpha = 0.5; + XCTAssertEqualWithAccuracy(button.tappedTextAlpha, 0.5, 0.001); +} + +- (void)testTappedButtonScaleRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedButtonScale = 0.8; + XCTAssertEqualWithAccuracy(button.tappedButtonScale, 0.8, 0.001); +} + +- (void)testTapAnimationDurationsRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tapDownAnimationDuration = 0.2; + button.tapUpAnimationDuration = 0.6; + XCTAssertEqualWithAccuracy(button.tapDownAnimationDuration, 0.2, 0.001); + XCTAssertEqualWithAccuracy(button.tapUpAnimationDuration, 0.6, 0.001); +} + +- (void)testCornerRadiusRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.cornerRadius = 20.0; + XCTAssertEqualWithAccuracy(button.cornerRadius, 20.0, 0.001); +#ifdef __IPHONE_26_0 + if (@available(iOS 26.0, *)) { + // On iOS 26, setting cornerRadius also rebuilds the corner configuration. + XCTAssertNotNil(button.cornerConfiguration); + } +#endif +} + +- (void)testImpactStyleRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.impactStyle = TORoundedButtonImpactStyleHeavy; + XCTAssertEqual(button.impactStyle, TORoundedButtonImpactStyleHeavy); + button.impactStyle = TORoundedButtonImpactStyleNone; + XCTAssertEqual(button.impactStyle, TORoundedButtonImpactStyleNone); +} + +- (void)testBlurStyleRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + // Switch to the blur background so the style is actually applied to the effect view. + button.backgroundStyle = TORoundedButtonBackgroundStyleBlur; + button.blurStyle = UIBlurEffectStyleLight; + XCTAssertEqual(button.blurStyle, UIBlurEffectStyleLight); +} + +- (void)testDelegateRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + TORoundedButtonTestDelegate *delegate = [TORoundedButtonTestDelegate new]; + button.delegate = delegate; + XCTAssertEqualObjects(button.delegate, delegate); +} + +- (void)testTappedHandlerRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + XCTAssertNil(button.tappedHandler); + button.tappedHandler = ^{}; + XCTAssertNotNil(button.tappedHandler); +} + +#ifdef __IPHONE_26_0 +- (void)testCornerConfigurationRoundTrip { + if (@available(iOS 26.0, *)) { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UICornerConfiguration *config = + [UICornerConfiguration configurationWithUniformRadius:[UICornerRadius fixedRadius:8.0]]; + button.cornerConfiguration = config; + // Pointer equality: the setter stores the config directly without copying. + XCTAssertEqualObjects(button.cornerConfiguration, config); + } +} + +- (void)testGlassStyleRoundTrip { + if (@available(iOS 26.0, *)) { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + // Toggle away from and back to glass so the glass effect view exists and the + // style is actually applied (the default is already glass on iOS 26). + button.backgroundStyle = TORoundedButtonBackgroundStyleSolid; + button.backgroundStyle = TORoundedButtonBackgroundStyleGlass; + button.glassStyle = UIGlassEffectStyleClear; + XCTAssertEqual(button.glassStyle, UIGlassEffectStyleClear); + } +} +#endif + +#pragma mark - Touch & Animation + +- (void)testTouchSequenceTogglesTappedState { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + XCTAssertFalse([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchDown]; // _didTouchDownInside + XCTAssertTrue([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchDragExit]; // _didDragOutside + XCTAssertFalse([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchDragEnter]; // _didDragInside + XCTAssertTrue([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; // _didTouchUpInside:event: + XCTAssertFalse([self isTappedForButton:button]); +} + +- (void)testTouchCancelResetsTappedState { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + [button sendActionsForControlEvents:UIControlEventTouchDown]; + XCTAssertTrue([self isTappedForButton:button]); + [button sendActionsForControlEvents:UIControlEventTouchCancel]; // _didDragOutside + XCTAssertFalse([self isTappedForButton:button]); +} + +- (void)testTouchDownScalesContainerThenRestoresOnTouchUp { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + UIView *container = [button valueForKey:@"containerView"]; + + [button sendActionsForControlEvents:UIControlEventTouchDown]; + // Container scales down to tappedButtonScale (default 0.97); .a is the x-scale factor. + XCTAssertEqualWithAccuracy(container.transform.a, button.tappedButtonScale, 0.0001); + + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqualWithAccuracy(container.transform.a, 1.0, 0.0001); +} + +- (void)testTouchDimsLabelWhenTappedTextAlphaSet { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + button.tappedTextAlpha = 0.4; // a value < 1 enables the alpha-dim path (1.0 disables it) + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + + [button sendActionsForControlEvents:UIControlEventTouchDown]; + XCTAssertEqualWithAccuracy(titleLabel.alpha, 0.4, 0.0001); + + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqualWithAccuracy(titleLabel.alpha, 1.0, 0.0001); +} + +- (void)testTappedBackgroundColorPathForSolidStyle { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + button.backgroundStyle = TORoundedButtonBackgroundStyleSolid; + button.tappedTintColor = [UIColor redColor]; + UIView *backgroundView = [button valueForKey:@"backgroundView"]; + + // For a solid background the tapped tint is applied directly as the background color. + [button sendActionsForControlEvents:UIControlEventTouchDown]; + XCTAssertTrue([self isTappedForButton:button]); + XCTAssertEqualObjects(backgroundView.backgroundColor, [UIColor redColor]); + + // Releasing reverts the background away from the tapped tint. + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertFalse([self isTappedForButton:button]); + XCTAssertNotEqualObjects(backgroundView.backgroundColor, [UIColor redColor]); +} + @end diff --git a/TORoundedButtonFramework/Info.plist b/TORoundedButtonFramework/Info.plist index bfc2cc2..4cea8ff 100644 --- a/TORoundedButtonFramework/Info.plist +++ b/TORoundedButtonFramework/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 2.0.0 + 2.1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION)