diff --git a/Sources/YMatterType/Typography/Typography+Font.swift b/Sources/YMatterType/Typography/Typography+Font.swift index c6682af..ea49916 100644 --- a/Sources/YMatterType/Typography/Typography+Font.swift +++ b/Sources/YMatterType/Typography/Typography+Font.swift @@ -27,7 +27,7 @@ extension Typography { if !isFixed { let metrics = UIFontMetrics(forTextStyle: textStyle) - if let maximumPointSize = maximumPointSize { + if let maximumPointSize = getMaximumPointSize(maximumPointSize) { font = metrics.scaledFont( for: font, maximumPointSize: maximumPointSize, @@ -132,6 +132,32 @@ extension Typography { return generateLayout(maximumPointSize: maximumPointSize, compatibleWith: traitCollection) } + + /// Maximum point size (if any). + /// + /// Calculated from `maximumScaleFactor` (if any) multiplied by `fontSize` or else `nil` + public var maximumPointSize: CGFloat? { + guard let maximumScaleFactor = maximumScaleFactor else { + return nil + } + + return maximumScaleFactor * fontSize + } + + /// Returns the minimum of the point size (if any) or `maximumPointSize` (if any). + /// - Parameter pointSize: optional point size to evaluate + /// - Returns: the minimum of point size or maximumPointSize + internal func getMaximumPointSize(_ pointSize: CGFloat?) -> CGFloat? { + guard let maximumPointSize = maximumPointSize else { + return pointSize + } + + guard let pointSize = pointSize else { + return maximumPointSize + } + + return min(pointSize, maximumPointSize) + } } private extension Typography { diff --git a/Sources/YMatterType/Typography/Typography+Mutators.swift b/Sources/YMatterType/Typography/Typography+Mutators.swift index 15218e6..f7847d4 100644 --- a/Sources/YMatterType/Typography/Typography+Mutators.swift +++ b/Sources/YMatterType/Typography/Typography+Mutators.swift @@ -27,6 +27,7 @@ public extension Typography { textCase: textCase, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: isFixed ) } @@ -58,6 +59,7 @@ public extension Typography { textCase: textCase, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: isFixed ) } @@ -79,6 +81,7 @@ public extension Typography { textCase: textCase, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: isFixed ) } @@ -100,6 +103,7 @@ public extension Typography { textCase: textCase, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: isFixed ) } @@ -119,6 +123,7 @@ public extension Typography { textCase: textCase, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: true ) } @@ -140,6 +145,7 @@ public extension Typography { textCase: textCase, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: isFixed ) } @@ -161,6 +167,7 @@ public extension Typography { textCase: value, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: isFixed ) } @@ -182,6 +189,29 @@ public extension Typography { textCase: textCase, textDecoration: value, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, + isFixed: isFixed + ) + } + + /// Returns a copy of the Typography but with the new `maximumScaleFactor` applied. + /// - Parameter value: the maximum scale factor to apply + /// - Returns: an updated copy of the Typography + func maximumScaleFactor(_ value: CGFloat?) -> Typography { + if maximumScaleFactor == value { return self } + + return Typography( + fontFamily: fontFamily, + fontWeight: fontWeight, + fontSize: fontSize, + lineHeight: lineHeight, + letterSpacing: letterSpacing, + paragraphIndent: paragraphIndent, + paragraphSpacing: paragraphSpacing, + textCase: textCase, + textDecoration: textDecoration, + textStyle: textStyle, + maximumScaleFactor: value, isFixed: isFixed ) } diff --git a/Sources/YMatterType/Typography/Typography.swift b/Sources/YMatterType/Typography/Typography.swift index 836276d..23a50b0 100644 --- a/Sources/YMatterType/Typography/Typography.swift +++ b/Sources/YMatterType/Typography/Typography.swift @@ -31,6 +31,11 @@ public struct Typography { /// The text style (e.g. Body or Title) that this font most closely represents. /// Used for Dynamic Type scaling of the font public let textStyle: UIFont.TextStyle + /// Maximum scale factor to apply for this typography. `nil` means no limit. + /// + /// Will not be considered if `isFixed == true`. + /// Do not set to `1.0`, but set `isFixed = true` to disable Dynamic Type scaling. + public let maximumScaleFactor: CGFloat? /// Whether this font is fixed in size or should be scaled through Dynamic Type public let isFixed: Bool @@ -54,6 +59,7 @@ public struct Typography { /// - textCase: text case to apply (defaults to `.none`) /// - textDecoration: text decoration to apply (defaults to `.none`) /// - textStyle: text style to use for scaling (defaults to `.body`) + /// - maximumScaleFactor: maximum scale factor to apply (defaults to `nil`) /// - isFixed: `true` if this font should never scale, `false` if it should scale (defaults to `.false`) public init( fontFamily: FontFamily, @@ -66,6 +72,7 @@ public struct Typography { textCase: TextCase = .none, textDecoration: TextDecoration = .none, textStyle: UIFont.TextStyle = .body, + maximumScaleFactor: CGFloat? = nil, isFixed: Bool = false ) { self.fontFamily = fontFamily @@ -78,6 +85,7 @@ public struct Typography { self.textCase = textCase self.textDecoration = textDecoration self.textStyle = textStyle + self.maximumScaleFactor = maximumScaleFactor self.isFixed = isFixed } @@ -94,6 +102,7 @@ public struct Typography { /// - textCase: text case to apply (defaults to `.none`) /// - textDecoration: text decoration to apply (defaults to `.none`) /// - textStyle: text style to use for scaling (defaults to `.body`) + /// - maximumScaleFactor: maximum scale factor to apply (defaults to `nil`) /// - isFixed: `true` if this font should never scale, `false` if it should scale (defaults to `.false`) public init( familyName: String, @@ -107,6 +116,7 @@ public struct Typography { textCase: TextCase = .none, textDecoration: TextDecoration = .none, textStyle: UIFont.TextStyle = .body, + maximumScaleFactor: CGFloat? = nil, isFixed: Bool = false ) { self.init( @@ -120,6 +130,7 @@ public struct Typography { textCase: textCase, textDecoration: textDecoration, textStyle: textStyle, + maximumScaleFactor: maximumScaleFactor, isFixed: isFixed ) } diff --git a/Tests/YMatterTypeTests/Typography/TypogaphyTests.swift b/Tests/YMatterTypeTests/Typography/TypogaphyTests.swift index 0f80564..459a2a5 100644 --- a/Tests/YMatterTypeTests/Typography/TypogaphyTests.swift +++ b/Tests/YMatterTypeTests/Typography/TypogaphyTests.swift @@ -50,6 +50,7 @@ final class TypogaphyTests: XCTestCase { // Confirm default init parameter values XCTAssertEqual(typography.letterSpacing, 0) XCTAssertEqual(typography.textStyle, UIFont.TextStyle.body) + XCTAssertNil(typography.maximumScaleFactor) XCTAssertFalse(typography.isFixed) } } diff --git a/Tests/YMatterTypeTests/Typography/Typography+FontTests.swift b/Tests/YMatterTypeTests/Typography/Typography+FontTests.swift index b045334..fa77ae9 100644 --- a/Tests/YMatterTypeTests/Typography/Typography+FontTests.swift +++ b/Tests/YMatterTypeTests/Typography/Typography+FontTests.swift @@ -87,7 +87,7 @@ final class TypographyFontTests: XCTestCase { // The returned font should not exceed the requested maximum XCTAssertEqual(layout.font.pointSize, $0.fontSize * 2) - // The returned line height should not exceed th requested maximum + // The returned line height should not exceed the requested maximum XCTAssertEqual(layout.lineHeight, $0.lineHeight * 2) // baselineOffset should be >= 0 and pixel-aligned XCTAssertGreaterThanOrEqual(layout.baselineOffset, 0) @@ -117,7 +117,37 @@ final class TypographyFontTests: XCTestCase { // The returned font should not exceed the requested maximum XCTAssertEqual(layout.font.pointSize, size.fontSize * scaleFactor) - // The returned line height should not exceed th requested maximum + // The returned line height should not exceed the requested maximum + XCTAssertEqual(layout.lineHeight, size.lineHeight * scaleFactor) + // baselineOffset should be >= 0 and pixel-aligned + XCTAssertGreaterThanOrEqual(layout.baselineOffset, 0) + XCTAssertEqual(layout.baselineOffset.ceiled(), layout.baselineOffset) + } + } + } + + func testIntrinsicMaximumScaleFactor() { + let fontFamily = DefaultFontFamily(familyName: "Menlo") + let traits = UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge) + + sizes.forEach { size in + scaleFactors.forEach { scaleFactor in + // Given we build a typography with a built-in max scale factor + let typography = Typography( + fontFamily: fontFamily, + fontWeight: .bold, + fontSize: size.fontSize, + lineHeight: size.lineHeight, + maximumScaleFactor: scaleFactor + ) + + // When we request a layout without specifying any further max + // (at the largest supported Dynamic Type size) + let layout = typography.generateLayout(compatibleWith: traits) + + // The returned font should not exceed the requested maximum + XCTAssertEqual(layout.font.pointSize, size.fontSize * scaleFactor) + // The returned line height should not exceed the requested maximum XCTAssertEqual(layout.lineHeight, size.lineHeight * scaleFactor) // baselineOffset should be >= 0 and pixel-aligned XCTAssertGreaterThanOrEqual(layout.baselineOffset, 0) @@ -157,6 +187,64 @@ final class TypographyFontTests: XCTestCase { } } #endif + + func test_maximumPointSize() { + // Given + let factors: [CGFloat?] = [nil, 1.5, 2.0, 2.5] + + factors.forEach { + let pointSize = CGFloat(Int.random(in: 10...32)) + let sut = Typography( + fontFamily: AppleSDGothicNeoInfo(), + fontWeight: .bold, + fontSize: pointSize, + lineHeight: ceil(pointSize * 1.4), + maximumScaleFactor: $0 + ) + + let maximumPointSize = sut.maximumPointSize + if let factor = $0 { + XCTAssertEqual(maximumPointSize, pointSize * factor) + } else { + XCTAssertNil(maximumPointSize) + } + } + } + + func test_getMaximumPointSize_withoutMaximumScaleFactor() { + // Given + let sut = Typography( + fontFamily: AppleSDGothicNeoInfo(), + fontWeight: .bold, + fontSize: 12, + lineHeight: 24 + ) + let maximumPointSize = CGFloat(Int.random(in: 16...48)) + + // Then + XCTAssertNil(sut.maximumPointSize) + XCTAssertNil(sut.getMaximumPointSize(nil)) + XCTAssertEqual(sut.getMaximumPointSize(maximumPointSize), maximumPointSize) + } + + func test_getMaximumPointSize_withMaximumScaleFactor() { + // Given + let sut = Typography( + fontFamily: AppleSDGothicNeoInfo(), + fontWeight: .bold, + fontSize: 12, + lineHeight: 24, + maximumScaleFactor: 2 + ) + let lowerPointSize = CGFloat(Int.random(in: 13..<24)) + let higherPointSize = CGFloat(Int.random(in: 25...48)) + + // Then + XCTAssertEqual(sut.maximumPointSize, 24) + XCTAssertEqual(sut.getMaximumPointSize(nil), sut.maximumPointSize) + XCTAssertEqual(sut.getMaximumPointSize(lowerPointSize), lowerPointSize) + XCTAssertEqual(sut.getMaximumPointSize(higherPointSize), sut.maximumPointSize) + } } struct AppleSDGothicNeoInfo: FontFamily { diff --git a/Tests/YMatterTypeTests/Typography/Typography+MutatorsTests.swift b/Tests/YMatterTypeTests/Typography/Typography+MutatorsTests.swift index 9c09db1..bc1b12c 100644 --- a/Tests/YMatterTypeTests/Typography/Typography+MutatorsTests.swift +++ b/Tests/YMatterTypeTests/Typography/Typography+MutatorsTests.swift @@ -101,6 +101,15 @@ final class TypographyMutatorsTests: XCTestCase { } } + func testMaximumScaleFactor() { + let factors: [CGFloat?] = [nil, 1.5, 2.0, 2.5] + types.forEach { + for factor in factors { + _test(original: $0, modified: $0.maximumScaleFactor(factor), maximumScaleFactor: factor) + } + } + } + private func _test( original: Typography, modified: Typography, @@ -111,7 +120,8 @@ final class TypographyMutatorsTests: XCTestCase { isFixed: Bool? = nil, letterSpacing: CGFloat? = nil, textCase: Typography.TextCase? = nil, - textDecoration: Typography.TextDecoration? = nil + textDecoration: Typography.TextDecoration? = nil, + maximumScaleFactor: CGFloat? = nil ) { let familyName = familyName ?? original.fontFamily.familyName let weight = weight ?? original.fontWeight @@ -121,6 +131,7 @@ final class TypographyMutatorsTests: XCTestCase { let kerning = letterSpacing ?? original.letterSpacing let textCase = textCase ?? original.textCase let textDecoration = textDecoration ?? original.textDecoration + let maximumScaleFactor = maximumScaleFactor ?? original.maximumScaleFactor // familyName, fontWeight, fontSize, lineHeight, isFixed, // letterSpacing, textCase, and textDecoration should be as expected @@ -132,6 +143,7 @@ final class TypographyMutatorsTests: XCTestCase { XCTAssertEqual(modified.letterSpacing, kerning) XCTAssertEqual(modified.textCase, textCase) XCTAssertEqual(modified.textDecoration, textDecoration) + XCTAssertEqual(modified.maximumScaleFactor, maximumScaleFactor) // the other variables should be the same XCTAssertEqual(modified.textStyle, original.textStyle)