├── .github ├── pull_request_template.md └── workflows │ ├── main.yml │ └── run_linter_and_unit_tests.yml ├── .gitignore ├── .jazzy.yaml ├── .spi.yml ├── .swiftlint.yml ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── YMatterType.xcscheme ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── YMatterType │ ├── Elements │ ├── Representables │ │ └── TypographyLabelRepresentable.swift │ ├── TextStyleLabel.swift │ ├── TypographyButton.swift │ ├── TypographyLabel.swift │ ├── TypographyTextField.swift │ └── TypographyTextView.swift │ ├── Extensions │ ├── Foundation │ │ ├── NSAttributedString+baseAttributes.swift │ │ ├── NSAttributedString+textCase.swift │ │ ├── String+textCase.swift │ │ └── String+textSize.swift │ └── UIKit │ │ ├── NSParagraphStyle+lineSpacing.swift │ │ ├── UIFont+register.swift │ │ ├── UITraitCollection+breakpoint.swift │ │ └── UITraitCollection+fontAppearance.swift │ ├── Typography │ ├── FontFamily │ │ ├── DefaultFontFamily.swift │ │ ├── DefaultFontFamilyFactory.swift │ │ ├── FontFamily.swift │ │ ├── FontFamilyFactory.swift │ │ ├── HelveticaNeueFontFamily.swift │ │ └── SystemFontFamily.swift │ ├── Typography+Enums.swift │ ├── Typography+Font.swift │ ├── Typography+Mutators.swift │ ├── Typography+System.swift │ ├── Typography.swift │ └── TypographyLayout.swift │ └── YMatterType+Logging.swift └── Tests └── YMatterTypeTests ├── Assets └── Fonts │ └── NotoSans-Regular.ttf ├── Elements ├── ElementBreakpointAdjustedTests.swift ├── ElementColorAdjustedTests.swift ├── ElementFontAdjustedTests.swift ├── Mocks │ ├── MockButton.swift │ ├── MockLabel.swift │ ├── MockTextField.swift │ └── MockTextView.swift ├── Representables │ └── TypographyLabelRepresentableTests.swift ├── TextStyleLabelTests.swift ├── TypographyButtonTests.swift ├── TypographyElementTests.swift ├── TypographyLabelTests.swift ├── TypographyTextFieldTests.swift └── TypographyTextViewTests.swift ├── Extensions ├── Foundation │ ├── BaseStringTestCase.swift │ ├── NSAttributedString+baseAttributesTests.swift │ ├── NSAttributedString+textCaseTests.swift │ ├── String+textCaseTests.swift │ └── String+textSizeTests.swift └── UIKit │ ├── NSParagraphStyle+lineSpacingTests.swift │ ├── UIFont+registerTests.swift │ ├── UITraitCollection+breakpointTests.swift │ ├── UITraitCollection+fontAppearanceTests.swift │ └── UITraitCollection+traits.swift ├── Helpers ├── CGFloat+rounded.swift ├── NSAttributedString+hasAttribute.swift ├── Typography+YMatterTypeTests.swift ├── UIEdgeInsets+vertical.swift ├── UIFont+load.swift ├── UITraitCollection+default.swift ├── XCTestCase+MemoryLeakTracking.swift └── XCTestCase+TypographyEquatable.swift ├── Typography ├── FontFamily │ ├── DefaultFontFamilyFactoryTests.swift │ ├── DefaultFontFamilyTests.swift │ ├── FontFamilyTests.swift │ ├── HelveticaNeueFontFamilyTests.swift │ └── SystemFontFamilyTests.swift ├── TypogaphyTests.swift ├── Typography+EnumTests.swift ├── Typography+FontTests.swift ├── Typography+MutatorsTests.swift ├── Typography+SystemTests.swift └── TypographyLayoutTests.swift └── YMatterType+LoggingTests.swift /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Introduction ## 2 | 3 | In layman's terms, provide a brief introduction to the issue. No more than 3 sentences. 4 | 5 | ## Purpose ## 6 | 7 | Explain the purpose of this pull request. Include a link to the issue being addressed. 8 | 9 | ## Scope ## 10 | 11 | Detail the scope of the pull request, i.e. what changed. 12 | 13 | // Optional sections 14 | 15 | ## Discussion ## 16 | 17 | Any discussion about approach, challenges, etc. that you feel is relevant. 18 | 19 | ## Out of Scope ## 20 | 21 | Call out any known issues that are purposely not addressed in this pull request. 22 | 23 | ## 📱 Screenshots ## 24 | 25 | For UI work, please include before/after screenshots hosted in a 2-column table for easy side-by-side comparison. 26 | 27 | ## 🎬 Video ## 28 | 29 | Same as Screenshots above. 30 | 31 | ## 📈 Coverage ## 32 | 33 | ##### Code ##### 34 | 35 | Include a snapshot of the Code Coverage report generated when you ran the full unit test suite. 36 | 37 | ##### Documentation ##### 38 | 39 | Include a snapshot of the documentation coverage report when you ran jazzy locally to generate documentation. We require 100% documentation coverage of all `public` interfaces. -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: PublishDocumentation 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "deploy_docs" 17 | deploy_docs: 18 | # The type of runner that the job will run on 19 | runs-on: macos-12 20 | 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 24 | - uses: actions/checkout@v1 25 | - name: Publish Jazzy Docs 26 | uses: steven0351/publish-jazzy-docs@v1 27 | with: 28 | personal_access_token: ${{ secrets.ACCESS_TOKEN }} 29 | config: .jazzy.yaml 30 | -------------------------------------------------------------------------------- /.github/workflows/run_linter_and_unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run linter and unit tests 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-12 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set Xcode version 21 | run: | 22 | ls -l /Applications | grep 'Xcode' 23 | sudo xcode-select -s /Applications/Xcode_14.0.1.app 24 | 25 | - name: Lint code using SwiftLint 26 | run: swiftlint --strict --reporter github-actions-logging 27 | 28 | - name: Build iOS 29 | run: | 30 | xcodebuild -scheme YMatterType -sdk iphonesimulator16.0 -destination 'platform=iOS Simulator,name=iPhone 14' build-for-testing 31 | 32 | - name: Run tests iOS 33 | run: | 34 | xcodebuild -scheme YMatterType -sdk iphonesimulator16.0 -destination 'platform=iOS Simulator,name=iPhone 14' test-without-building 35 | - name: Build tvOS 36 | run: | 37 | xcodebuild -scheme YMatterType -sdk appletvsimulator16.0 -destination 'platform=tvOS Simulator,name=Apple TV 4K (2nd generation)' build-for-testing 38 | 39 | - name: Run tests tvOS 40 | run: | 41 | xcodebuild -scheme YMatterType -sdk appletvsimulator16.0 -destination 'platform=tvOS Simulator,name=Apple TV 4K (2nd generation)' test-without-building 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by MacOS 2 | .DS_Store 3 | 4 | # Xcode 5 | # 6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 7 | 8 | ## User settings 9 | *.xcuserstate 10 | *.xcuserdatad 11 | *.xcuserdata 12 | xcschememanagement.plist 13 | */xcuserdata/* 14 | *.xcbkptlist 15 | *.xcworkspacedata 16 | IDEWorkspaceChecks.plist 17 | 18 | ## Obj-C/Swift specific 19 | *.hmap 20 | 21 | ## App packaging 22 | *.ipa 23 | *.dSYM.zip 24 | *.dSYM 25 | 26 | ## Playgrounds 27 | timeline.xctimeline 28 | playground.xcworkspace 29 | 30 | # Swift Package Manager 31 | # 32 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 33 | # Packages/ 34 | # Package.pins 35 | # Package.resolved 36 | # *.xcodeproj 37 | # 38 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 39 | # hence it is not needed unless you have added a package configuration file to your project 40 | # .swiftpm 41 | 42 | .build/ 43 | 44 | # CocoaPods 45 | # 46 | # We recommend against adding the Pods directory to your .gitignore. However 47 | # you should judge for yourself, the pros and cons are mentioned at: 48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 49 | # 50 | */Pods/* 51 | # 52 | # Add this line if you want to avoid checking in source code from the Xcode workspace 53 | # *.xcworkspace 54 | 55 | # Carthage 56 | # 57 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 58 | # Carthage/Checkouts 59 | 60 | Carthage/Build/ 61 | 62 | # Accio dependency management 63 | Dependencies/ 64 | .accio/ 65 | 66 | # fastlane 67 | # 68 | # It is recommended to not store the screenshots in the git repo. 69 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 70 | # For more information about the recommended setup visit: 71 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 72 | 73 | fastlane/report.xml 74 | fastlane/Preview.html 75 | fastlane/screenshots/**/*.png 76 | fastlane/test_output 77 | 78 | # Code Injection 79 | # 80 | # After new code Injection tools there's a generated folder /iOSInjectionProject 81 | # https://github.com/johnno1962/injectionforxcode 82 | 83 | iOSInjectionProject/ 84 | 85 | ## Docs 86 | /docs 87 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | author: 'Y Media Labs' 2 | author_url: https://yml.co 3 | min_acl: public 4 | hide_documentation_coverage: false 5 | theme: fullwidth 6 | output: ./docs 7 | documentation: ./*.md 8 | swift_build_tool: xcodebuild 9 | module: YMatterType 10 | xcodebuild_arguments: 11 | - -scheme 12 | - YMatterType 13 | - -sdk 14 | - iphonesimulator 15 | - -destination 16 | - 'platform=iOS Simulator,name=iPhone 13' 17 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | documentation_targets: [YMatterType] 6 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # By default, SwiftLint uses a set of sensible default rules you can adjust: 2 | disabled_rules: # rule identifiers turned on by default to exclude from running 3 | 4 | - multiple_closures_with_trailing_closure 5 | 6 | opt_in_rules: # some rules are turned off by default, so you need to opt-in 7 | - contains_over_first_not_nil 8 | - empty_count 9 | - first_where 10 | - force_unwrapping 11 | - implicit_return 12 | - missing_docs 13 | - multiline_arguments 14 | - multiline_arguments_brackets 15 | - multiline_function_chains 16 | - multiline_literal_brackets 17 | - multiline_parameters 18 | - multiline_parameters_brackets 19 | - operator_whitespace 20 | - prohibited_interface_builder 21 | - unneeded_parentheses_in_closure_argument 22 | - vertical_whitespace_closing_braces 23 | - vertical_whitespace_opening_braces 24 | 25 | excluded: # paths to ignore during linting. Takes precedence over `included`. 26 | - Pods 27 | - docs 28 | - .build 29 | 30 | # configurable rules can be customized from this configuration file 31 | # binary rules can set their severity level 32 | 33 | cyclomatic_complexity: 34 | ignores_case_statements: true 35 | 36 | identifier_name: 37 | min_length: 1 38 | 39 | trailing_whitespace: 40 | ignores_empty_lines: true 41 | 42 | function_parameter_count: 43 | warning: 6 44 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/YMatterType.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 49 | 55 | 56 | 57 | 58 | 59 | 69 | 70 | 76 | 77 | 83 | 84 | 85 | 86 | 88 | 89 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "YMatterType", 7 | defaultLocalization: "en", 8 | platforms: [ 9 | .iOS(.v14), 10 | .tvOS(.v14) 11 | ], 12 | products: [ 13 | .library( 14 | name: "YMatterType", 15 | targets: ["YMatterType"] 16 | ) 17 | ], 18 | targets: [ 19 | .target( 20 | name: "YMatterType", 21 | dependencies: [] 22 | ), 23 | .testTarget( 24 | name: "YMatterTypeTests", 25 | dependencies: ["YMatterType"], 26 | resources: [.copy("Assets")] 27 | ) 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Sources/YMatterType/Elements/Representables/TypographyLabelRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyLabelRepresentable.swift 3 | // YMatterType 4 | // 5 | // Created by Virginia Pujols on 1/27/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A wrapper for a UIKit TypographyLabel view that allows to integrate that view into a SwiftUI view hierarchy. 12 | struct TypographyLabelRepresentable { 13 | /// The type of view to present. 14 | typealias UIViewType = TypographyLabel 15 | 16 | /// The text that the label displays. 17 | var text: String 18 | 19 | /// Typography to be used for this label's text 20 | var typography: Typography 21 | 22 | /// A closure that gets called on the init and refresh of the View 23 | var configureTextStyleLabel: ((TypographyLabel) -> Void)? 24 | } 25 | 26 | extension TypographyLabelRepresentable: UIViewRepresentable { 27 | /// Creates the view object and configures its initial state. 28 | /// 29 | /// - Parameter context: A context structure containing information about 30 | /// the current state of the system. 31 | /// 32 | /// - Returns: `TypographyLabel` view configured with the provided information. 33 | func makeUIView(context: Context) -> TypographyLabel { 34 | getLabel() 35 | } 36 | 37 | func getLabel() -> TypographyLabel { 38 | let label = TypographyLabel(typography: typography) 39 | label.text = text 40 | 41 | label.numberOfLines = 1 42 | 43 | label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 44 | label.setContentCompressionResistancePriority(.defaultLow, for: .vertical) 45 | 46 | configureTextStyleLabel?(label) 47 | return label 48 | } 49 | 50 | /// Updates the state of the specified view with new information from 51 | /// SwiftUI. 52 | /// 53 | /// - Parameters: 54 | /// - uiView: `TypographyLabel` view. 55 | /// - context: A context structure containing information about the current 56 | /// state of the system. 57 | func updateUIView(_ uiView: TypographyLabel, context: Context) { 58 | updateLabel(uiView) 59 | } 60 | 61 | func updateLabel(_ label: TypographyLabel) { 62 | label.typography = typography 63 | label.text = text 64 | configureTextStyleLabel?(label) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/YMatterType/Elements/TextStyleLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextStyleLabel.swift 3 | // YMatterType 4 | // 5 | // Created by Virginia Pujols on 1/11/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A singe line text label that supports `Typography` for SwiftUI. 12 | public struct TextStyleLabel { 13 | /// The text that the label displays. 14 | var text: String 15 | 16 | /// Typography to be used for this label's text 17 | var typography: Typography 18 | 19 | /// A closure that gets called on the init and refresh of the View 20 | /// This closure allows you to provide additional configuration to the `TypographyLabel` 21 | var configureTextStyleLabel: ((TypographyLabel) -> Void)? 22 | 23 | /// Initializes a `TextStyleLabel` instance with the specified parameters 24 | /// - Parameters: 25 | /// - text: The text that the label displays 26 | /// - typography: Typography to be used for this label's text 27 | /// - configuration: A closure that gets called on the init and refresh of the View 28 | public init( 29 | _ text: String, 30 | typography: Typography, 31 | configuration: ((TypographyLabel) -> Void)? = nil 32 | ) { 33 | self.text = text 34 | self.typography = typography 35 | self.configureTextStyleLabel = configuration 36 | } 37 | } 38 | 39 | extension TextStyleLabel: View { 40 | /// The content and behavior of the view. 41 | public var body: some View { 42 | getLabel() 43 | .fixedSize(horizontal: false, vertical: true) 44 | } 45 | 46 | func getLabel() -> some View { 47 | TypographyLabelRepresentable( 48 | text: text, 49 | typography: typography, 50 | configureTextStyleLabel: configureTextStyleLabel 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/YMatterType/Elements/TypographyLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyLabel.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/26/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// A text label that supports `Typography`. 12 | /// You can optionally set `maximumPointSize` or `maximumScaleFactor` to set a cap on Dynamic Type scaling. 13 | open class TypographyLabel: UILabel { 14 | /// The current typographical layout 15 | public private(set) var layout: TypographyLayout! 16 | 17 | /// Typography to be used for this label's text 18 | public var typography: Typography { 19 | didSet { 20 | adjustFonts() 21 | } 22 | } 23 | 24 | /// (Optional) maximum point size when scaling the font. 25 | /// 26 | /// Value should be greater than Typography.fontSize. 27 | /// `nil` means no maximum for scaling. 28 | /// Has no effect for fixed Typography. 29 | /// 30 | /// If you wish to set a specific maximum scale factor instead of a fixed maximum point size, 31 | /// set `maximumScaleFactor` instead. 32 | /// `maximumScaleFactor` will be used if both properties are non-nil. 33 | public var maximumPointSize: CGFloat? { 34 | didSet { 35 | if !typography.isFixed && maximumPointSize != oldValue { 36 | adjustFonts() 37 | } 38 | } 39 | } 40 | 41 | /// (Optional) maximum scale factor to use when scaling the font. 42 | /// 43 | /// Value should be greater than 1. 44 | /// `nil` means no maximum scale factor. 45 | /// Has no effect for fixed Typography. 46 | /// 47 | /// If you wish to set a specific maximum point size instead of a scale factor, set `maximumPointSize` instead. 48 | /// Takes precedence over `maximumPointSize` if both properties are non-nil. 49 | public var maximumScaleFactor: CGFloat? { 50 | didSet { 51 | if !typography.isFixed && maximumScaleFactor != oldValue { 52 | adjustFonts() 53 | } 54 | } 55 | } 56 | 57 | /// Initializes a label using the specified `Typography` 58 | /// - Parameter typography: the font information to use 59 | required public init(typography: Typography) { 60 | self.typography = typography 61 | super.init(frame: .zero) 62 | build() 63 | } 64 | 65 | /// Initializes a label using the default Typography `Typography.systemLabel` 66 | required public init?(coder: NSCoder) { 67 | self.typography = Typography.systemLabel 68 | super.init(coder: coder) 69 | build() 70 | } 71 | 72 | private enum TextSetMode { 73 | case text 74 | case attributedText 75 | } 76 | 77 | private var textSetMode: TextSetMode = .text 78 | 79 | /// :nodoc: 80 | override public var text: String! { 81 | get { super.text } 82 | set { 83 | // When text is set, we may need to re-style it as attributedText 84 | // with the correct paragraph style to achieve the desired line height. 85 | textSetMode = .text 86 | styleText(newValue) 87 | } 88 | } 89 | 90 | /// :nodoc: 91 | override public var attributedText: NSAttributedString? { 92 | get { super.attributedText } 93 | set { 94 | // When text is set, we may need to re-style it as attributedText 95 | // with the correct paragraph style to achieve the desired line height. 96 | textSetMode = .attributedText 97 | styleAttributedText(newValue) 98 | } 99 | } 100 | 101 | /// :nodoc: 102 | override public var textAlignment: NSTextAlignment { 103 | didSet { 104 | if textAlignment != oldValue { 105 | // Text alignment can be part of our paragraph style, so we may need to 106 | // re-style when changed 107 | restyleText() 108 | } 109 | } 110 | } 111 | 112 | /// :nodoc: 113 | override public var lineBreakMode: NSLineBreakMode { 114 | didSet { 115 | if lineBreakMode != oldValue { 116 | // Line break mode can be part of our paragraph style, so we may need to 117 | // re-style when changed 118 | restyleText() 119 | } 120 | } 121 | } 122 | 123 | /// :nodoc: 124 | override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 125 | super.traitCollectionDidChange(previousTraitCollection) 126 | 127 | if traitCollection.hasDifferentFontAppearance(comparedTo: previousTraitCollection) { 128 | adjustFonts() 129 | } 130 | 131 | if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { 132 | adjustColors() 133 | } 134 | 135 | if traitCollection.hasDifferentBreakpoint(comparedTo: previousTraitCollection) { 136 | adjustBreakpoint() 137 | } 138 | } 139 | 140 | /// Call this if you've made a change that would require text to be re-styled. (Normally this is not necessary). 141 | /// Override this if you need to do something additional when preferred content size 142 | /// or legibility weight has changed 143 | open func adjustFonts() { 144 | layout = typography.generateLayout( 145 | maximumScaleFactor: maximumScaleFactor, 146 | maximumPointSize: maximumPointSize, 147 | compatibleWith: traitCollection 148 | ) 149 | font = layout.font 150 | restyleText() 151 | } 152 | 153 | /// Override this if you have colors that will not automatically adjust to 154 | /// Light / Dark mode, etc. This can be the case for CGColor or 155 | /// non-template images (or backgroundImages). 156 | open func adjustColors() { 157 | // override to handle any color changes 158 | } 159 | 160 | /// Override this if you have typography that might change at different breakpoints. 161 | /// You should check `.window?.bounds.size` for potential changes. 162 | open func adjustBreakpoint() { 163 | // override to handle any changes based on breakpoint 164 | } 165 | } 166 | 167 | private extension TypographyLabel { 168 | func build() { 169 | configure() 170 | adjustFonts() 171 | adjustColors() 172 | adjustBreakpoint() 173 | } 174 | 175 | func configure() { 176 | adjustsFontForContentSizeCategory = true 177 | } 178 | 179 | func restyleText() { 180 | if textSetMode == .text { 181 | styleText(text) 182 | } else { 183 | styleAttributedText(attributedText) 184 | } 185 | } 186 | 187 | func styleText(_ newValue: String!) { 188 | defer { invalidateIntrinsicContentSize() } 189 | guard let layout = layout, 190 | let newValue = newValue else { 191 | // We don't need to use attributed text 192 | super.attributedText = nil 193 | super.text = newValue 194 | return 195 | } 196 | 197 | // Set attributed text to match typography 198 | super.attributedText = layout.styleText(newValue, lineMode: lineMode) 199 | } 200 | 201 | func styleAttributedText(_ newValue: NSAttributedString?) { 202 | defer { invalidateIntrinsicContentSize() } 203 | guard let layout = layout, 204 | let newValue = newValue else { 205 | // We don't need any additional styling 206 | super.attributedText = newValue 207 | return 208 | } 209 | 210 | // Modify attributed text to match typography 211 | super.attributedText = layout.styleAttributedText(newValue, lineMode: lineMode) 212 | } 213 | 214 | var lineMode: Typography.LineMode { 215 | .multi(alignment: textAlignment, lineBreakMode: lineBreakMode) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /Sources/YMatterType/Elements/TypographyTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyTextField.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 12/10/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// A text label that supports `Typography`. 12 | /// You can optionally set `maximumPointSize` or `maximumScaleFactor` to set a cap on Dynamic Type scaling. 13 | open class TypographyTextField: UITextField { 14 | /// The current typographical layout 15 | public private(set) var layout: TypographyLayout! { 16 | didSet { 17 | invalidateIntrinsicContentSize() 18 | } 19 | } 20 | 21 | /// Typography to be used for this label's text 22 | public var typography: Typography { 23 | didSet { 24 | adjustFonts() 25 | } 26 | } 27 | 28 | /// Default text insets (values vary by platform) 29 | public static var defaultTextInsets: UIEdgeInsets = { 30 | #if os(tvOS) 31 | UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) 32 | #else 33 | .zero 34 | #endif 35 | }() 36 | 37 | /// Insets to apply around the functional area of the `UITextField`. 38 | public var textInsets: UIEdgeInsets = TypographyTextField.defaultTextInsets { 39 | didSet { 40 | if textInsets != oldValue { 41 | invalidateIntrinsicContentSize() 42 | } 43 | } 44 | } 45 | 46 | /// (Optional) maximum point size when scaling the font. 47 | /// 48 | /// Value should be greater than Typography.fontSize. 49 | /// `nil` means no maximum for scaling. 50 | /// Has no effect for fixed Typography. 51 | /// 52 | /// If you wish to set a specific maximum scale factor instead of a fixed maximum point size, 53 | /// set `maximumScaleFactor` instead. 54 | /// `maximumScaleFactor` will be used if both properties are non-nil. 55 | public var maximumPointSize: CGFloat? { 56 | didSet { 57 | if !typography.isFixed && maximumPointSize != oldValue { 58 | adjustFonts() 59 | } 60 | } 61 | } 62 | 63 | /// (Optional) maximum scale factor to use when scaling the font. 64 | /// 65 | /// Value should be greater than 1. 66 | /// `nil` means no maximum scale factor. 67 | /// Has no effect for fixed Typography. 68 | /// 69 | /// If you wish to set a specific maximum point size instead of a scale factor, set `maximumPointSize` instead. 70 | /// Takes precedence over `maximumPointSize` if both properties are non-nil. 71 | public var maximumScaleFactor: CGFloat? { 72 | didSet { 73 | if !typography.isFixed && maximumScaleFactor != oldValue { 74 | adjustFonts() 75 | } 76 | } 77 | } 78 | 79 | /// Initializes a label using the specified `Typography` 80 | /// - Parameter typography: the font information to use 81 | required public init(typography: Typography) { 82 | self.typography = typography 83 | super.init(frame: .zero) 84 | build() 85 | } 86 | 87 | /// Initializes a text field using the default Typography `Typography.systemLabel` 88 | required public init?(coder: NSCoder) { 89 | self.typography = Typography.systemLabel 90 | super.init(coder: coder) 91 | build() 92 | } 93 | 94 | private enum TextSetMode { 95 | case text 96 | case attributedText 97 | } 98 | 99 | private var textSetMode: TextSetMode = .text 100 | 101 | /// :nodoc: 102 | override public var text: String! { 103 | get { super.text } 104 | set { 105 | // When text is set, we may need to re-style it as attributedText 106 | // with the correct paragraph style to achieve the desired line height. 107 | textSetMode = .text 108 | styleText(newValue) 109 | } 110 | } 111 | 112 | /// :nodoc: 113 | override public var attributedText: NSAttributedString? { 114 | get { super.attributedText } 115 | set { 116 | // When text is set, we may need to re-style it as attributedText 117 | // with the correct paragraph style to achieve the desired line height. 118 | textSetMode = .attributedText 119 | styleAttributedText(newValue) 120 | } 121 | } 122 | 123 | /// :nodoc: 124 | override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 125 | super.traitCollectionDidChange(previousTraitCollection) 126 | 127 | if traitCollection.hasDifferentFontAppearance(comparedTo: previousTraitCollection) { 128 | adjustFonts() 129 | } 130 | 131 | if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { 132 | adjustColors() 133 | } 134 | 135 | if traitCollection.hasDifferentBreakpoint(comparedTo: previousTraitCollection) { 136 | adjustBreakpoint() 137 | } 138 | } 139 | 140 | /// Call this if you've made a change that would require text to be re-styled. (Normally this is not necessary). 141 | /// Override this if you need to do something additional when preferred content size 142 | /// or legibility weight has changed 143 | open func adjustFonts() { 144 | layout = typography.generateLayout( 145 | maximumScaleFactor: maximumScaleFactor, 146 | maximumPointSize: maximumPointSize, 147 | compatibleWith: traitCollection 148 | ) 149 | font = layout.font 150 | restyleText() 151 | } 152 | 153 | /// Override this if you have colors that will not automatically adjust to 154 | /// Light / Dark mode, etc. This can be the case for CGColor or 155 | /// non-template images (or backgroundImages). 156 | open func adjustColors() { 157 | // override to handle any color changes 158 | } 159 | 160 | /// Override this if you have typography that might change at different breakpoints. 161 | /// You should check `.window?.bounds.size` for potential changes. 162 | open func adjustBreakpoint() { 163 | // override to handle any changes based on breakpoint 164 | } 165 | 166 | /// :nodoc 167 | open override var intrinsicContentSize: CGSize { 168 | var size = super.intrinsicContentSize 169 | let minHeight = layout.lineHeight + textInsets.top + textInsets.bottom 170 | if size.height < minHeight { 171 | size.height = minHeight 172 | } 173 | return size 174 | } 175 | 176 | /// :nodoc 177 | open override func textRect(forBounds bounds: CGRect) -> CGRect { 178 | #if os(tvOS) 179 | bounds.inset(by: textInsets) 180 | #else 181 | super.textRect(forBounds: bounds).inset(by: textInsets) 182 | #endif 183 | } 184 | 185 | /// :nodoc 186 | open override func editingRect(forBounds bounds: CGRect) -> CGRect { 187 | #if os(tvOS) 188 | bounds.inset(by: textInsets) 189 | #else 190 | super.editingRect(forBounds: bounds).inset(by: textInsets) 191 | #endif 192 | } 193 | 194 | /// :nodoc 195 | open override func clearButtonRect(forBounds bounds: CGRect) -> CGRect { 196 | super.clearButtonRect(forBounds: bounds).offsetBy(dx: -textInsets.right, dy: 0) 197 | } 198 | 199 | /// :nodoc 200 | open override func leftViewRect(forBounds bounds: CGRect) -> CGRect { 201 | super.leftViewRect(forBounds: bounds).offsetBy(dx: textInsets.left, dy: 0) 202 | } 203 | 204 | /// :nodoc 205 | open override func rightViewRect(forBounds bounds: CGRect) -> CGRect { 206 | super.rightViewRect(forBounds: bounds).offsetBy(dx: -textInsets.right, dy: 0) 207 | } 208 | } 209 | 210 | private extension TypographyTextField { 211 | func build() { 212 | configure() 213 | adjustFonts() 214 | adjustColors() 215 | adjustBreakpoint() 216 | } 217 | 218 | func configure() { 219 | adjustsFontForContentSizeCategory = true 220 | } 221 | 222 | func restyleText() { 223 | if textSetMode == .text { 224 | styleText(text) 225 | } else { 226 | styleAttributedText(attributedText) 227 | } 228 | } 229 | 230 | func styleText(_ newValue: String!) { 231 | defer { invalidateIntrinsicContentSize() } 232 | guard let layout = layout, 233 | let newValue = newValue, 234 | layout.needsStylingForSingleLine else { 235 | // We don't need to use attributed text 236 | super.attributedText = nil 237 | super.text = newValue 238 | return 239 | } 240 | 241 | // Set attributed text to match typography 242 | super.attributedText = layout.styleText(newValue, lineMode: lineMode) 243 | } 244 | 245 | func styleAttributedText(_ newValue: NSAttributedString?) { 246 | defer { invalidateIntrinsicContentSize() } 247 | guard let layout = layout, 248 | let newValue = newValue else { 249 | // We don't need any additional styling 250 | super.attributedText = newValue 251 | return 252 | } 253 | 254 | // Modify attributed text to match typography 255 | super.attributedText = layout.styleAttributedText(newValue, lineMode: lineMode) 256 | } 257 | 258 | /// Line mode (text fields are always single line) 259 | var lineMode: Typography.LineMode { .single } 260 | } 261 | -------------------------------------------------------------------------------- /Sources/YMatterType/Elements/TypographyTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyTextView.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 12/17/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// A text view that supports `Typography`. 12 | /// 13 | /// You can optionally set `maximumPointSize` or `maximumScaleFactor` to set a cap on Dynamic Type scaling. 14 | /// - Note: By default UITextView has no intrinsic height (because content can scroll). 15 | /// If you are looking to essentially have an interactive label with link support, 16 | /// you need to set `isScrollEnabled = false` and `isEditable = false`. 17 | open class TypographyTextView: UITextView { 18 | /// The current typographical layout 19 | public private(set) var layout: TypographyLayout! 20 | 21 | /// Typography to be used for this text view's text 22 | public var typography: Typography { 23 | didSet { 24 | adjustFonts() 25 | } 26 | } 27 | 28 | /// (Optional) maximum point size when scaling the font. 29 | /// 30 | /// Value should be greater than Typography.fontSize. 31 | /// `nil` means no maximum for scaling. 32 | /// Has no effect for fixed Typography. 33 | /// 34 | /// If you wish to set a specific maximum scale factor instead of a fixed maximum point size, 35 | /// set `maximumScaleFactor` instead. 36 | /// `maximumScaleFactor` will be used if both properties are non-nil. 37 | public var maximumPointSize: CGFloat? { 38 | didSet { 39 | if !typography.isFixed && maximumPointSize != oldValue { 40 | adjustFonts() 41 | } 42 | } 43 | } 44 | 45 | /// (Optional) maximum scale factor to use when scaling the font. 46 | /// 47 | /// Value should be greater than 1. 48 | /// `nil` means no maximum scale factor. 49 | /// Has no effect for fixed Typography. 50 | /// 51 | /// If you wish to set a specific maximum point size instead of a scale factor, set `maximumPointSize` instead. 52 | /// Takes precedence over `maximumPointSize` if both properties are non-nil. 53 | public var maximumScaleFactor: CGFloat? { 54 | didSet { 55 | if !typography.isFixed && maximumScaleFactor != oldValue { 56 | adjustFonts() 57 | } 58 | } 59 | } 60 | 61 | /// Initializes a text view using the specified `Typography` 62 | /// - Parameter typography: the font information to use 63 | required public init(typography: Typography) { 64 | self.typography = typography 65 | super.init(frame: .zero, textContainer: nil) 66 | build() 67 | } 68 | 69 | /// Initializes a text view using the default Typography `Typography.systemLabel` 70 | required public init?(coder: NSCoder) { 71 | self.typography = Typography.systemLabel 72 | super.init(coder: coder) 73 | build() 74 | } 75 | 76 | private enum TextSetMode { 77 | case text 78 | case attributedText 79 | } 80 | 81 | private var textSetMode: TextSetMode = .text 82 | 83 | /// :nodoc: 84 | override public var text: String! { 85 | get { super.text } 86 | set { 87 | // When text is set, we may need to re-style it as attributedText 88 | // with the correct paragraph style to achieve the desired line height. 89 | textSetMode = .text 90 | styleText(newValue) 91 | } 92 | } 93 | 94 | /// :nodoc: 95 | override public var attributedText: NSAttributedString? { 96 | get { super.attributedText } 97 | set { 98 | // When text is set, we may need to re-style it as attributedText 99 | // with the correct paragraph style to achieve the desired line height. 100 | textSetMode = .attributedText 101 | styleAttributedText(newValue) 102 | } 103 | } 104 | 105 | /// :nodoc: 106 | override public var textAlignment: NSTextAlignment { 107 | didSet { 108 | if textAlignment != oldValue { 109 | // Text alignment can be part of our paragraph style, so we may need to 110 | // re-style when changed 111 | restyleText() 112 | } 113 | } 114 | } 115 | 116 | /// :nodoc: 117 | override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 118 | super.traitCollectionDidChange(previousTraitCollection) 119 | 120 | if traitCollection.hasDifferentFontAppearance(comparedTo: previousTraitCollection) { 121 | adjustFonts() 122 | } 123 | 124 | if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { 125 | adjustColors() 126 | } 127 | 128 | if traitCollection.hasDifferentBreakpoint(comparedTo: previousTraitCollection) { 129 | adjustBreakpoint() 130 | } 131 | } 132 | 133 | /// Call this if you've made a change that would require text to be re-styled. (Normally this is not necessary). 134 | /// Override this if you need to do something additional when preferred content size 135 | /// or legibility weight has changed 136 | open func adjustFonts() { 137 | layout = typography.generateLayout( 138 | maximumScaleFactor: maximumScaleFactor, 139 | maximumPointSize: maximumPointSize, 140 | compatibleWith: traitCollection 141 | ) 142 | font = layout.font 143 | restyleText() 144 | } 145 | 146 | /// Override this if you have colors that will not automatically adjust to 147 | /// Light / Dark mode, etc. This can be the case for CGColor or 148 | /// non-template images (or backgroundImages). 149 | open func adjustColors() { 150 | // override to handle any color changes 151 | } 152 | 153 | /// Override this if you have typography that might change at different breakpoints. 154 | /// You should check `.window?.bounds.size` for potential changes. 155 | open func adjustBreakpoint() { 156 | // override to handle any changes based on breakpoint 157 | } 158 | } 159 | 160 | private extension TypographyTextView { 161 | func build() { 162 | configure() 163 | adjustFonts() 164 | adjustColors() 165 | adjustBreakpoint() 166 | } 167 | 168 | func configure() { 169 | if #available(iOS 16.0, *) { 170 | // Starting with iOS 16, UITextView uses TextKit 2 for layout, 171 | // which does not behave properly with our attributed text 172 | // setting line height. Accessing `layoutManager` forces the text view 173 | // to render using the TextKit 1 layout manager. 174 | // It would be more efficient to initialize via `UITextView.init(usingTextLayoutManager: false)`, 175 | // but that is impossible because it is not a designated initializer. 176 | // So for now we have the following solution. 177 | _ = layoutManager 178 | } 179 | adjustsFontForContentSizeCategory = true 180 | textContainerInset = .zero // Typography should provide sufficient insets 181 | } 182 | 183 | func restyleText() { 184 | if textSetMode == .text { 185 | styleText(text) 186 | } else { 187 | styleAttributedText(attributedText) 188 | } 189 | } 190 | 191 | func styleText(_ newValue: String!) { 192 | defer { invalidateIntrinsicContentSize() } 193 | guard let layout = layout, 194 | let newValue = newValue else { 195 | // We don't need to use attributed text 196 | super.attributedText = nil 197 | super.text = newValue 198 | return 199 | } 200 | 201 | // Set attributed text to match typography 202 | super.attributedText = layout.styleText(newValue, lineMode: lineMode) 203 | } 204 | 205 | func styleAttributedText(_ newValue: NSAttributedString?) { 206 | defer { invalidateIntrinsicContentSize() } 207 | guard let layout = layout, 208 | let newValue = newValue else { 209 | // We don't need any additional styling 210 | super.attributedText = newValue 211 | return 212 | } 213 | 214 | // Modify attributed text to match typography 215 | super.attributedText = layout.styleAttributedText(newValue, lineMode: lineMode) 216 | } 217 | 218 | /// Line mode (single or multi) 219 | var lineMode: Typography.LineMode { 220 | .multi(alignment: textAlignment, lineBreakMode: nil) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/Foundation/NSAttributedString+baseAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+baseAttributes.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 3/2/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSAttributedString { 12 | /// Returns an attributed string consisting of the attributed string's text with the specified attributes applied 13 | /// to the entire range and then the attributed string's attributes copied on top of that. 14 | /// - Parameter baseAttributes: the attributes to apply to the entire range 15 | /// - Returns: An attributed string with the universal attributes applied beneath the attributed string's 16 | /// own attributes 17 | public func attributedString(with baseAttributes: [NSAttributedString.Key: Any]) -> NSAttributedString { 18 | // First we create a new attributed string from the plain text and the base attributes 19 | let updated = NSMutableAttributedString(string: string, attributes: baseAttributes) 20 | 21 | // Then we iterate through all the attributes and apply them to `updated` 22 | let rangeAll = NSRange(location: 0, length: length) 23 | enumerateAttributes( 24 | in: rangeAll, 25 | options: [] 26 | ) { attributes, range, _ in 27 | // But we don't wish to reapply our base attributes 28 | if range != rangeAll || attributes.keys != baseAttributes.keys { 29 | updated.addAttributes(attributes, range: range) 30 | } 31 | } 32 | 33 | return updated 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/Foundation/NSAttributedString+textCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+textCase.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 5/3/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSAttributedString { 12 | /// Applies a text case to an attributed string. 13 | /// 14 | /// Applying `capitalized` (Title Case) may be problematic because it is applied in fragments 15 | /// according to the attributes. So "John" could become "JoHn" if an attribute were applied only to 16 | /// first two or last two letters. But we have to do it this way because capitalization operations are not 17 | /// guaranteed to be symmetrical. e.g. "straße".uppercased() == "STRASSE". 18 | /// - Parameter textCase: the text case to apply 19 | /// - Returns: the updated attributed string 20 | public func textCase(_ textCase: Typography.TextCase) -> NSAttributedString { 21 | guard textCase != .none else { return self } 22 | 23 | let modified = NSMutableAttributedString(attributedString: self) 24 | 25 | modified.enumerateAttributes(in: NSRange(location: 0, length: length), options: []) { _, range, _ in 26 | modified.replaceCharacters( 27 | in: range, 28 | with: (string as NSString).substring(with: range).textCase(textCase) 29 | ) 30 | } 31 | 32 | return modified 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/Foundation/String+textCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+textCase.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 5/2/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | /// Applies a text case to the string. 13 | /// - Parameter textCase: the text case to apply 14 | /// - Returns: the updated string 15 | public func textCase(_ textCase: Typography.TextCase) -> String { 16 | switch textCase { 17 | case .none: 18 | return self 19 | case .lowercase: 20 | return localizedLowercase 21 | case .uppercase: 22 | return localizedUppercase 23 | case .capitalize: 24 | return localizedCapitalized 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/Foundation/String+textSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+textSize.swift 3 | // YMatterType 4 | // 5 | // Created by Sahil Saini on 27/03/23. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension String { 12 | /// Calculates the size of the string rendered with the specified typography. 13 | /// - Parameters: 14 | /// - typography: the typography to be used to calculate the string size 15 | /// - traitCollection: the trait collection to apply 16 | /// - Returns: the size of the string 17 | public func size( 18 | withTypography typography: Typography, 19 | compatibleWith traitCollection: UITraitCollection? 20 | ) -> CGSize { 21 | let layout = typography.generateLayout(compatibleWith: traitCollection) 22 | let valueSize = self.size(withFont: layout.font) 23 | return CGSize( 24 | width: valueSize.width, 25 | height: max(valueSize.height, layout.lineHeight) 26 | ) 27 | } 28 | 29 | /// Calculates the size of the string rendered with the specified font. 30 | /// 31 | /// The returned size will be rounded up to the nearest pixel in both width and height. 32 | /// - Parameters: 33 | /// - font: the font to be used to calculate the string size 34 | /// - traitCollection: the trait collection to apply (used for `displayScale`) 35 | /// - Returns: the size of the string 36 | public func size( 37 | withFont font: UIFont, 38 | compatibleWith traitCollection: UITraitCollection? = nil 39 | ) -> CGSize { 40 | let scale: CGFloat 41 | if let displayScale = traitCollection?.displayScale, 42 | displayScale != 0.0 { 43 | scale = displayScale 44 | } else { 45 | scale = UIScreen.main.scale 46 | } 47 | 48 | let fontAttributes = [NSAttributedString.Key.font: font] 49 | let size = size(withAttributes: fontAttributes) 50 | return CGSize( 51 | width: ceil(size.width * scale) / scale, 52 | height: ceil(size.height * scale) / scale 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/UIKit/NSParagraphStyle+lineSpacing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSParagraphStyle+lineSpacing.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/23/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSParagraphStyle { 12 | /// Combines line spacing with the existing style 13 | /// - Parameter lineSpacing: the line spacing to use 14 | /// - Returns: Current paragraph style combined with line spacing 15 | public func styleWithLineSpacing(_ lineSpacing: CGFloat) -> NSParagraphStyle { 16 | let paragraphStyle = (mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() 17 | paragraphStyle.lineSpacing = lineSpacing 18 | return paragraphStyle 19 | } 20 | 21 | /// Combines line height multiple with the existing style 22 | /// - Parameter lineHeightMultiple: the line height multiple to use 23 | /// - Returns: Current paragraph style combined with line height multiple 24 | public func styleWithLineHeightMultiple(_ lineHeightMultiple: CGFloat) -> NSParagraphStyle { 25 | let paragraphStyle = (mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() 26 | paragraphStyle.lineHeightMultiple = lineHeightMultiple 27 | return paragraphStyle 28 | } 29 | 30 | /// Combines text alignment with the existing style 31 | /// - Parameter alignment: the text alignment to use 32 | /// - Returns: Current paragraph style combined with text alignment 33 | public func styleWithAlignment(_ alignment: NSTextAlignment) -> NSParagraphStyle { 34 | let paragraphStyle = (mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() 35 | paragraphStyle.alignment = alignment 36 | return paragraphStyle 37 | } 38 | 39 | /// Combines line height with the existing style 40 | /// - Parameter lineHeight: the line height to use 41 | /// - Returns: Current paragraph style combined with minimumLineHeight and maximumLineHeight both set to lineHeight 42 | 43 | /// Combines line height with the existing style 44 | /// - Parameters: 45 | /// - lineHeight: the line height to use 46 | /// - indent: the indent to use (ignored if `0`) 47 | /// - spacing: the spacing to use (ignored if `0`) 48 | /// - Returns: Current paragraph style combined with line height and (if non-zero) indent and spacing 49 | public func styleWithLineHeight( 50 | _ lineHeight: CGFloat, 51 | indent: CGFloat = 0, 52 | spacing: CGFloat = 0 53 | ) -> NSParagraphStyle { 54 | let paragraphStyle = (mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() 55 | paragraphStyle.minimumLineHeight = lineHeight 56 | paragraphStyle.maximumLineHeight = lineHeight 57 | if indent != 0 { 58 | paragraphStyle.firstLineHeadIndent = indent 59 | } 60 | if spacing != 0 { 61 | paragraphStyle.paragraphSpacing = spacing 62 | } 63 | return paragraphStyle 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/UIKit/UIFont+register.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIFont+register.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 5/25/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIFont { 12 | /// Error registering or unregistering a font 13 | public enum RegisterError: Error { 14 | /// URL to the font file could not be created. Check the file name, extension, subpath, and bundle. 15 | case urlNotFound 16 | } 17 | 18 | /// Registers a font file with the system Font Manager. 19 | /// - Parameters: 20 | /// - name: font file name (may also include subpath or extension) 21 | /// - ext: (optional) font file extension (e.g. "otf" or "ttf") if not already included in `name` 22 | /// - subpath: (optional) subpath to the font file within the bundle if not already included in `name` 23 | /// - bundle: bundle containing the font file 24 | /// - Throws: an error if the file cannot be registered (including if it is already registered). 25 | public static func register( 26 | name: String, 27 | fileExtension ext: String? = nil, 28 | subpath: String? = nil, 29 | bundle: Bundle 30 | ) throws { 31 | guard let fontURL = bundle.url( 32 | forResource: name, 33 | withExtension: ext, 34 | subdirectory: subpath 35 | ) else { 36 | throw RegisterError.urlNotFound 37 | } 38 | 39 | var error: Unmanaged? 40 | CTFontManagerRegisterFontsForURL(fontURL as CFURL, CTFontManagerScope.process, &error) 41 | if let error = error?.takeUnretainedValue() as? Error { 42 | throw error 43 | } 44 | } 45 | 46 | /// Unregisters a font file with the system Font Manager. 47 | /// 48 | /// Should be paired with succcessful call to `register(:)` 49 | /// - Parameters: 50 | /// - name: font file name (may also include subpath or extension) 51 | /// - ext: (optional) font file extension (e.g. "otf" or "ttf") if not already included in `name` 52 | /// - subpath: (optional) subpath to the font file within the bundle if not already included in `name` 53 | /// - bundle: bundle containing the font file 54 | /// - Throws: an error if the file cannot be unregistered (including if it was never registered or 55 | /// has already been successfully unregistered). 56 | public static func unregister( 57 | name: String, 58 | fileExtension ext: String? = nil, 59 | subpath: String? = nil, 60 | bundle: Bundle 61 | ) throws { 62 | guard let fontURL = bundle.url( 63 | forResource: name, 64 | withExtension: ext, 65 | subdirectory: subpath 66 | ) else { 67 | throw RegisterError.urlNotFound 68 | } 69 | 70 | var error: Unmanaged? 71 | CTFontManagerUnregisterFontsForURL(fontURL as CFURL, CTFontManagerScope.process, &error) 72 | if let error = error?.takeUnretainedValue() as? Error { 73 | throw error 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/UIKit/UITraitCollection+breakpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITraitCollection+breakpoint.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 3/18/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UITraitCollection { 12 | /// Return whether this trait collection, compared to a different trait collection, 13 | /// could represent a different breakpoint. 14 | /// 15 | /// Breakpoints are design specific, but a common one is at 600 points. Ideally looking at window size 16 | /// would be the way to detect every breakpoint change. 17 | /// 18 | /// If you need to be aware of when breakpoints might change, override 19 | /// `traitCollectionDidChange` in your view or view controller, and use this 20 | /// method to compare `self.traitCollection` with `previousTraitCollection`. 21 | /// 22 | /// Currently, a change in any of these traits could affect breakpoints: 23 | /// horizontalSizeClass, verticalSizeClass 24 | /// and more could be added in the future. 25 | /// - Parameter traitCollection: A trait collection that you want to compare to the current trait collection. 26 | /// - Returns: Returns a Boolean value indicating whether changing between the 27 | /// specified and current trait collections could affect breakpoints. 28 | public func hasDifferentBreakpoint(comparedTo traitCollection: UITraitCollection?) -> Bool { 29 | horizontalSizeClass != traitCollection?.horizontalSizeClass || 30 | verticalSizeClass != traitCollection?.verticalSizeClass 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/YMatterType/Extensions/UIKit/UITraitCollection+fontAppearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITraitCollection+fontAppearance.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/12/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UITraitCollection { 12 | /// Return whether this trait collection, compared to a different trait collection, 13 | /// could show a different appearance for fonts. 14 | /// 15 | /// If you need to be aware of when fonts might change, override 16 | /// `traitCollectionDidChange` in your view or view controller, and use this 17 | /// method to compare `self.traitCollection` with `previousTraitCollection`. 18 | /// 19 | /// Currently, a change in any of these traits could affect fonts: 20 | /// preferredContentSizeCategory, legibilityWeight 21 | /// and more could be added in the future. 22 | /// - Parameter traitCollection: A trait collection that you want to compare to the current trait collection. 23 | /// - Returns: Returns a Boolean value indicating whether changing between the 24 | /// specified and current trait collections would affect fonts. 25 | public func hasDifferentFontAppearance(comparedTo traitCollection: UITraitCollection?) -> Bool { 26 | preferredContentSizeCategory != traitCollection?.preferredContentSizeCategory || 27 | legibilityWeight != traitCollection?.legibilityWeight 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/FontFamily/DefaultFontFamily.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultFontFamily.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/23/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Information about a font family. Default implementation of FontFamily. 12 | public struct DefaultFontFamily: FontFamily { 13 | /// Suffix to use for italic family font names "Italic" 14 | public static let italicSuffix = "Italic" 15 | 16 | /// Font family root name, e.g. "AvenirNext" 17 | public let familyName: String 18 | 19 | /// Font style, e.g. regular or italic 20 | public let style: Typography.FontStyle 21 | 22 | /// Initialize a `DefaultFontFamily` object 23 | /// - Parameters: 24 | /// - familyName: font family name 25 | /// - style: font style (default = `.regular`) 26 | public init(familyName: String, style: Typography.FontStyle = .regular) { 27 | self.familyName = familyName 28 | self.style = style 29 | } 30 | 31 | /// Optional suffix to use for the font name. 32 | /// 33 | /// Used by `FontFamily.fontName(for:compatibleWith:)` 34 | /// e.g. "Italic" is a typical suffix for italic fonts. 35 | /// default = "" 36 | public var fontNameSuffix: String { 37 | (style == .italic) ? DefaultFontFamily.italicSuffix : "" 38 | } 39 | } 40 | 41 | /// Information about a font family. Default implementation of FontFamily. 42 | /// 43 | /// Renamed to `DefaultFontFamily` 44 | @available(*, deprecated, renamed: "DefaultFontFamily") 45 | public typealias FontInfo = DefaultFontFamily 46 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/FontFamily/DefaultFontFamilyFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultFontFamilyFactory.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 9/21/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Returns a `DefaultFontFamily` given a font family name and style. 12 | public struct DefaultFontFamilyFactory { 13 | /// Initializes a default font family factory 14 | public init() { } 15 | } 16 | 17 | extension DefaultFontFamilyFactory: FontFamilyFactory { 18 | /// Given a family name and style instantiates and returns an appropriate `FontFamily` to use. 19 | /// - Parameters: 20 | /// - familyName: font family name 21 | /// - style: font style 22 | /// - Returns: a font family matching the family name and style 23 | public func getFontFamily(familyName: String, style: Typography.FontStyle) -> FontFamily { 24 | if familyName == HelveticaNeueFontFamily.familyName { 25 | return HelveticaNeueFontFamily(style: style) 26 | } 27 | 28 | return DefaultFontFamily(familyName: familyName, style: style) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/FontFamily/FontFamily.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontFamily.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/23/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import os 11 | 12 | /// Information about a font family. When an app specifies a custom font, they will 13 | /// implement an instance of FontFamily to fully describe that font. 14 | public protocol FontFamily { 15 | /// Font family root name, e.g. "AvenirNext" 16 | var familyName: String { get } 17 | 18 | /// Optional suffix to use for the font name. 19 | /// 20 | /// Used by `FontFamily.fontName(for:compatibleWith:)` 21 | /// e.g. "Italic" is a typical suffix for italic fonts. 22 | /// default = "" 23 | var fontNameSuffix: String { get } 24 | 25 | /// All font weights supported by this font family (or those that you choose to bundle in your project). 26 | /// 27 | /// Defaults to all 9 font weights, but can be overridden in custom implementations of `FontFamily`. 28 | /// You must support at least one weight. 29 | /// This is used in the default implementation of `accessibilityBoldWeight(for:)` 30 | var supportedWeights: [Typography.FontWeight] { get } 31 | 32 | // The following four methods have default implementations that 33 | // can be overridden in custom implementations of `FontFamily`. 34 | 35 | /// Returns a font for the specified `weight` and `pointSize` that is compatible with the `traitCollection` 36 | /// - Parameters: 37 | /// - weight: desired font weight 38 | /// - pointSize: desired font point size 39 | /// - traitCollection: trait collection to consider (`UITraitCollection.legibilityWeight`). 40 | /// If `nil` then `UIAccessibility.isBoldTextEnabled` will be considered instead 41 | func font( 42 | for weight: Typography.FontWeight, 43 | pointSize: CGFloat, 44 | compatibleWith traitCollection: UITraitCollection? 45 | ) -> UIFont 46 | 47 | /// Generates a font name that can be used to initialize a `UIFont`. Not all fonts support all 9 weights. 48 | /// - Parameter weight: desired font weight 49 | /// - Parameter traitCollection: trait collection to consider (`UITraitCollection.legibilityWeight`). 50 | /// If `nil` then `UIAccessibility.isBoldTextEnabled` will be considered instead 51 | /// - Returns: The font name formulated from `familyName` and `weight` 52 | func fontName(for weight: Typography.FontWeight, compatibleWith traitCollection: UITraitCollection?) -> String 53 | 54 | /// Generates a weight name suffix as part of a full font name. Not all fonts support all 9 weights. 55 | /// - Parameter weight: desired font weight 56 | /// - Returns: The weight name to use 57 | func weightName(for weight: Typography.FontWeight) -> String 58 | 59 | /// Returns the alternate weight to use if user has requested a bold font. e.g. might convert `.regular` 60 | /// to `.semibold`. Not all fonts support all 9 weights. 61 | /// - Parameter weight: desired font weight 62 | /// - Returns: the alternate weight to use if user has requested a bold font. 63 | /// Should be heavier than weight if possible. 64 | func accessibilityBoldWeight(for weight: Typography.FontWeight) -> Typography.FontWeight 65 | } 66 | 67 | // MARK: - Default implementations 68 | 69 | /// Default implementations 70 | extension FontFamily { 71 | /// Returns no suffix 72 | public var fontNameSuffix: String { "" } 73 | 74 | /// Returns all weights 75 | public var supportedWeights: [Typography.FontWeight] { Typography.FontWeight.allCases } 76 | 77 | /// Generates the font to be used using `UIFont(name:size:)` and the name generated by 78 | /// `fontName(weight:traitCollection:)` 79 | public func font( 80 | for weight: Typography.FontWeight, 81 | pointSize: CGFloat, 82 | compatibleWith traitCollection: UITraitCollection? 83 | ) -> UIFont { 84 | let name = fontName(for: weight, compatibleWith: traitCollection) 85 | guard let font = UIFont(name: name, size: pointSize) else { 86 | // Fallback to system font and log a message. 87 | if YMatterType.isLoggingEnabled { 88 | YMatterType.fontLogger.warning("Custom font \(name) not properly installed.") 89 | } 90 | return Typography.systemFamily.font( 91 | for: weight, 92 | pointSize: pointSize, 93 | compatibleWith: traitCollection 94 | ) 95 | } 96 | return font 97 | } 98 | 99 | /// Generates the font name by combining family name, the weight name 100 | /// (potentially adjusted for Bold Text), and suffix. 101 | public func fontName( 102 | for weight: Typography.FontWeight, 103 | compatibleWith traitCollection: UITraitCollection? 104 | ) -> String { 105 | // Default font name formulation accounting for Accessibility Bold setting 106 | let useBoldFont = isBoldTextEnabled(compatibleWith: traitCollection) 107 | let actualWeight = useBoldFont ? accessibilityBoldWeight(for: weight) : weight 108 | let weightName = weightName(for: actualWeight) 109 | let suffix = fontNameSuffix 110 | if weightName.isEmpty && suffix.isEmpty { 111 | // don't use "-" if nothing will follow it 112 | return familyName 113 | } 114 | return "\(familyName)-\(weightName)\(suffix)" 115 | } 116 | 117 | /// Returns default name for each weight 118 | public func weightName(for weight: Typography.FontWeight) -> String { 119 | // Default font name suffix by weight 120 | switch weight { 121 | case .ultralight: 122 | return "ExtraLight" 123 | case .thin: 124 | return "Thin" 125 | case .light: 126 | return "Light" 127 | case .regular: 128 | return "Regular" 129 | case .medium: 130 | return "Medium" 131 | case .semibold: 132 | return "SemiBold" 133 | case .bold: 134 | return "Bold" 135 | case .heavy: 136 | return "ExtraBold" 137 | case .black: 138 | return "Black" 139 | } 140 | } 141 | 142 | /// Returns the next heavier supported weight (if any), otherwise the heaviest supported weight 143 | public func accessibilityBoldWeight(for weight: Typography.FontWeight) -> Typography.FontWeight { 144 | let weights = supportedWeights.sorted(by: { $0.rawValue < $1.rawValue }) 145 | // return the next heavier supported weight 146 | return weights.first(where: { $0.rawValue > weight.rawValue }) ?? weights.last ?? weight 147 | } 148 | 149 | /// Determines whether the accessibility Bold Text feature is enabled within the given trait collection. 150 | /// - Parameter traitCollection: the trait collection to evaluate (or nil to use system settings) 151 | /// - Returns: `true` if the accessibility Bold Text feature is enabled. 152 | /// 153 | /// If `traitCollection` is not `nil`, it checks for `legibilityWeight == .bold`. 154 | /// If `traitCollection` is `nil`, then it examines the system wide `UIAccessibility` setting of the same name. 155 | public func isBoldTextEnabled(compatibleWith traitCollection: UITraitCollection?) -> Bool { 156 | guard let traitCollection = traitCollection else { 157 | return UIAccessibility.isBoldTextEnabled 158 | } 159 | 160 | return traitCollection.legibilityWeight == .bold 161 | } 162 | } 163 | 164 | /// Information about a font family. When an app specifies a custom font, they will 165 | /// implement an instance of FontFamily to fully describe that font. 166 | /// 167 | /// Renamed to `FontFamily` 168 | @available(*, deprecated, renamed: "FontFamily") 169 | public typealias FontRepresentable = FontFamily 170 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/FontFamily/FontFamilyFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontFamilyFactory.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 9/21/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Returns a font family given a font family name and style. 12 | public protocol FontFamilyFactory { 13 | /// Given a family name and style returns a font family 14 | /// - Parameters: 15 | /// - familyName: font family name 16 | /// - style: font style 17 | /// - Returns: a font family matching the family name and style 18 | func getFontFamily(familyName: String, style: Typography.FontStyle) -> FontFamily 19 | } 20 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/FontFamily/HelveticaNeueFontFamily.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HelveticaNeueFontFamily.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 3/2/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public extension Typography { 12 | /// HelveticaNeue font family 13 | static let helveticaNeue = HelveticaNeueFontFamily(style: .regular) 14 | /// HelveticaNeue Italic font family 15 | static let helveticaNeueItalic = HelveticaNeueFontFamily(style: .italic) 16 | } 17 | 18 | /// Information about the Helvetica Neue family of fonts 19 | public struct HelveticaNeueFontFamily: FontFamily { 20 | /// Font family root name 21 | public static let familyName = "HelveticaNeue" 22 | /// Font family root name 23 | public var familyName: String { HelveticaNeueFontFamily.familyName } 24 | 25 | /// Font style, e.g. regular or italic 26 | public let style: Typography.FontStyle 27 | 28 | /// Initialize a `HelveticaNeueFontFamily` object 29 | /// - Parameter style: font style (default = `.regular`) 30 | public init(style: Typography.FontStyle = .regular) { 31 | self.style = style 32 | } 33 | 34 | /// HelveticaNeue supports 6 font weights (nothing heavier than bold) 35 | public var supportedWeights: [Typography.FontWeight] { 36 | [.ultralight, .thin, .light, .regular, .medium, .bold] 37 | } 38 | 39 | public func weightName(for weight: Typography.FontWeight) -> String { 40 | // Default font name suffix by weight 41 | switch weight { 42 | case .ultralight: 43 | return "UltraLight" 44 | case .thin: 45 | return "Thin" 46 | case .light: 47 | return "Light" 48 | case .regular: 49 | return "" // HelveticaNeue skips the weight name for regular 50 | case .medium: 51 | return "Medium" 52 | case .bold: 53 | return "Bold" 54 | case .semibold, .heavy, .black: 55 | return "" // HelveticaNeue does not support these weights 56 | } 57 | } 58 | 59 | /// Optional suffix to use for the font name. 60 | /// 61 | /// Used by `FontFamily.fontName(for:compatibleWith:)` 62 | /// e.g. "Italic" is a typical suffix for italic fonts. 63 | /// default = "" 64 | public var fontNameSuffix: String { 65 | (style == .italic) ? DefaultFontFamily.italicSuffix : "" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/FontFamily/SystemFontFamily.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemFontFamily.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 9/28/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public extension Typography.FontWeight { 12 | /// Conversion from FontWeight enum to UIFont.Weight struct 13 | var systemWeight: UIFont.Weight { 14 | switch self { 15 | case .ultralight: 16 | return .ultraLight 17 | case .thin: 18 | return .thin 19 | case .light: 20 | return .light 21 | case .regular: 22 | return .regular 23 | case .medium: 24 | return .medium 25 | case .semibold: 26 | return .semibold 27 | case .bold: 28 | return .bold 29 | case .heavy: 30 | return .heavy 31 | case .black: 32 | return .black 33 | } 34 | } 35 | } 36 | 37 | public extension Typography { 38 | /// Information about the system font family 39 | static let systemFamily: FontFamily = SystemFontFamily() 40 | } 41 | 42 | extension DefaultFontFamily { 43 | /// Information about the system font family 44 | /// 45 | /// Renamed to `Typography.systemFamily` 46 | @available(*, deprecated, renamed: "Typography.systemFamily") 47 | static var system: FontFamily { Typography.systemFamily } 48 | } 49 | 50 | /// Information about the system font. System font implementation of FontFamily. 51 | public struct SystemFontFamily: FontFamily { 52 | // The system font has a private font family name (literally ".SFUI"), so 53 | // just return empty string for familyName. The system font can't be retrieved by name anyway. 54 | public var familyName: String { "" } 55 | 56 | /// Returns a font for the specified `weight` and `pointSize` that is compatible with the `traitCollection` 57 | /// - Parameters: 58 | /// - weight: desired font weight 59 | /// - pointSize: desired font point size 60 | /// - traitCollection: trait collection to consider (`UITraitCollection.legibilityWeight`). 61 | /// If `nil` then `UIAccessibility.isBoldTextEnabled` will be considered instead 62 | public func font( 63 | for weight: Typography.FontWeight, 64 | pointSize: CGFloat, 65 | compatibleWith traitCollection: UITraitCollection? 66 | ) -> UIFont { 67 | // When UIAccessibility.isBoldTextEnabled == true, then we don't need to manually 68 | // adjust the weight because the system will do it for us. 69 | let useBoldFont = isBoldTextEnabled(compatibleWith: traitCollection) && !UIAccessibility.isBoldTextEnabled 70 | let actualWeight = useBoldFont ? accessibilityBoldWeight(for: weight) : weight 71 | 72 | // The system font cannot be retrieved using UIFont.font(name:size:), but 73 | // instead must be created using UIFont.systemFont(ofSize:weight:) 74 | return UIFont.systemFont(ofSize: pointSize, weight: actualWeight.systemWeight) 75 | } 76 | 77 | /// Returns the next heavier supported weight (if any), otherwise the heaviest supported weight 78 | public func accessibilityBoldWeight(for weight: Typography.FontWeight) -> Typography.FontWeight { 79 | var boldWeight: Typography.FontWeight 80 | 81 | switch weight { 82 | // For 3 lightest weights, move up 1 weight 83 | case .ultralight: 84 | boldWeight = .thin 85 | case .thin: 86 | boldWeight = .light 87 | case .light: 88 | boldWeight = .regular 89 | 90 | // For all remaining weights, move up 2 weights 91 | case .regular: 92 | boldWeight = .semibold 93 | case .medium: 94 | boldWeight = .bold 95 | case .semibold: 96 | boldWeight = .heavy 97 | case .bold, .heavy, .black: 98 | boldWeight = .black 99 | } 100 | 101 | return boldWeight 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/Typography+Enums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+Enums.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 5/2/22. 6 | // 7 | 8 | import UIKit 9 | 10 | extension Typography { 11 | /// Font style (used together with font family name and font weight to load a specific font) 12 | public enum FontStyle: String, CaseIterable { 13 | /// Regular 14 | case regular = "normal" 15 | /// Italic 16 | case italic 17 | } 18 | 19 | /// The nine basic font weights. Not all fonts support all 9 weights. 20 | public enum FontWeight: CGFloat, CaseIterable { 21 | /// ultralight (aka extra light) weight (100) 22 | case ultralight = 100 23 | /// thin weight (200) 24 | case thin = 200 25 | /// light weight (300) 26 | case light = 300 27 | /// regular weight (400) 28 | case regular = 400 29 | /// medium weight (500) 30 | case medium = 500 31 | /// semibold weight (600) 32 | case semibold = 600 33 | /// bold weight (700) 34 | case bold = 700 35 | /// heavy (aka extra bold) weight (800) 36 | case heavy = 800 37 | /// black weight (900) 38 | case black = 900 39 | 40 | /// Creates a new instance from a string. 41 | /// 42 | /// This will be useful for converting Figma tokens to `Typography` objects. 43 | /// Common synonyms will be accepted, e.g. both "SemiBold" and "DemiBold" map to `.semibold`. 44 | /// - Parameter weightName: the case-insensitive weight name, e.g. "Bold" 45 | public init?(_ weightName: String) { 46 | switch weightName.lowercased(with: Locale(identifier: "en_US")) { 47 | case "ultralight", "extralight": 48 | self = .ultralight 49 | case "thin": 50 | self = .thin 51 | case "light": 52 | self = .light 53 | case "regular": 54 | self = .regular 55 | case "medium": 56 | self = .medium 57 | case "semibold", "demibold": 58 | self = .semibold 59 | case "bold": 60 | self = .bold 61 | case "heavy", "extrabold", "ultrabold": 62 | self = .heavy 63 | case "black": 64 | self = .black 65 | default: 66 | return nil 67 | } 68 | } 69 | } 70 | 71 | /// Capitalization to be applied to user-facing text 72 | public enum TextCase: String, CaseIterable { 73 | /// None (do not modify text) 74 | case none 75 | /// Lowercase 76 | case lowercase 77 | /// Uppercase (ALL CAPS) 78 | case uppercase 79 | /// Capitalize (also known as Title Case) 80 | case capitalize 81 | } 82 | 83 | /// Decoration to be applied 84 | public enum TextDecoration: String, CaseIterable { 85 | /// None 86 | case none 87 | /// Strikethrough 88 | case strikethrough = "line-through" 89 | /// Underline 90 | case underline 91 | } 92 | 93 | /// Line mode (single or multi) 94 | public enum LineMode { 95 | /// Single line 96 | case single 97 | /// Multi-line (use paragraph style) 98 | case multi(alignment: NSTextAlignment, lineBreakMode: NSLineBreakMode?) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/Typography+Font.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+Font.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/23/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension Typography { 12 | /// Generates the font and auxilliary layout information needed to render the Typography 13 | /// 14 | /// `maximumPointSize` will have no effect for fixed Typographies (`isFixed` = `true`). 15 | /// - Parameters: 16 | /// - maximumPointSize: (optional) maximum point size for Dynamic Type, default = nil, means no maximum 17 | /// - traitCollection: trait collection to apply (looking for preferredContentSizeCategory and legibilityWeight) 18 | /// - Returns: Font and various styles used to render the Typography 19 | public func generateLayout( 20 | maximumPointSize: CGFloat? = nil, 21 | compatibleWith traitCollection: UITraitCollection? 22 | ) -> TypographyLayout { 23 | var font = generateFixedFont(compatibleWith: traitCollection) 24 | 25 | var scaledLineHeight = lineHeight 26 | 27 | if !isFixed { 28 | let metrics = UIFontMetrics(forTextStyle: textStyle) 29 | 30 | if let maximumPointSize = getMaximumPointSize(maximumPointSize) { 31 | font = metrics.scaledFont( 32 | for: font, 33 | maximumPointSize: maximumPointSize, 34 | compatibleWith: traitCollection 35 | ) 36 | if font.pointSize < maximumPointSize { 37 | scaledLineHeight = metrics.scaledValue(for: lineHeight, compatibleWith: traitCollection) 38 | } else { 39 | // scaledValue(:) will return too large a value in this case, so we calculate based on the 40 | // ratio of the returned pointSize and the original fontSize 41 | scaledLineHeight = (font.pointSize / fontSize) * lineHeight 42 | } 43 | } else { 44 | font = metrics.scaledFont( 45 | for: font, 46 | compatibleWith: traitCollection 47 | ) 48 | scaledLineHeight = metrics.scaledValue(for: lineHeight, compatibleWith: traitCollection) 49 | } 50 | } else if fontFamily is SystemFontFamily { 51 | // In case of a fixed System font, we still need to "scale" the font 52 | // to adjust it to the correct traits. 53 | let metrics = UIFontMetrics(forTextStyle: textStyle) 54 | font = metrics.scaledFont( 55 | for: font, 56 | maximumPointSize: fontSize, // no change in size 57 | compatibleWith: traitCollection 58 | ) 59 | } 60 | 61 | // We need to adjust the baseline so that the text will appear vertically centered 62 | // (while still retaining enough room for descenders below the baseline and accents 63 | // above the main bounding box) 64 | // 65 | // swiftlint:disable line_length 66 | // See Figure 8-4 here: https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/CustomTextProcessing/CustomTextProcessing.html 67 | // swiftlint:enable line_length 68 | 69 | // Our goal is to move the baseline upwards enough that the bounding rectangle 70 | // (whose height = capHeight) is centered within the line height, 71 | // but we don't go below zero because that would clip any descenders (e.g. lowercase g, j, or y). 72 | // We go ahead and round the amount up to the nearest pixel (slightly favoring room 73 | // for descenders which are more common than accents) 74 | 75 | // Note: by my math the offset should only be divided by 2 not 4, 76 | // but testing indicates that increasing baseline offset by 1 moves the text up 2 points, 77 | // hence we divide by 2 again. 78 | let scale = UIScreen.main.scale 79 | let baselineOffset = max( 80 | ceil(((scaledLineHeight - (abs(font.descender) + font.leading) * 2 - font.capHeight) / 4) * scale) / scale, 81 | 0 82 | ) 83 | return TypographyLayout( 84 | font: font, 85 | lineHeight: scaledLineHeight, 86 | baselineOffset: baselineOffset, 87 | kerning: letterSpacing, 88 | paragraphIndent: paragraphIndent, 89 | paragraphSpacing: paragraphSpacing, 90 | textCase: textCase, 91 | textDecoration: textDecoration 92 | ) 93 | } 94 | 95 | /// Generates the font and auxilliary layout information needed to render the Typography 96 | /// 97 | /// `maximumScaleFactor` will have no effect for fixed Typographies (`isFixed` = `true`). 98 | /// - Parameters: 99 | /// - maximumScaleFactor: (optional) maximum scale factor for Dynamic Type, 100 | /// e.g. `2.0` for a 16 pt font would mean limit maximum point size to 32 pts. 101 | /// - traitCollection: trait collection to apply (looking for preferredContentSizeCategory and legibilityWeight) 102 | /// - Returns: Font and various styles used to render the Typography 103 | public func generateLayout( 104 | maximumScaleFactor: CGFloat, 105 | compatibleWith traitCollection: UITraitCollection? 106 | ) -> TypographyLayout { 107 | generateLayout(maximumPointSize: maximumScaleFactor * fontSize, compatibleWith: traitCollection) 108 | } 109 | 110 | /// Generates the font and auxilliary layout information needed to render the Typography 111 | /// 112 | /// Internal method for use by UI components such as `TypographyLabel` and `TypographyButton`. 113 | /// At most one of `maximumScaleFactor` and `maximumPointSize` should be set. 114 | /// If both parameters are non-nil, then `maximumScaleFactor` will be used. 115 | /// 116 | /// Neither `maximumScaleFactor` nor `maximumPointSize` will have any effect for fixed Typographies 117 | /// (`isFixed` = `true`). 118 | /// - Parameters: 119 | /// - maximumScaleFactor: maximum scale factor for Dynamic Type, nil means no maximum scale factor. 120 | /// e.g. `2.0` for a 16 pt font would mean limit maximum point size to 32 pts. 121 | /// - maximumPointSize: (optional) maximum point size for Dynamic Type, nil means no maximum point size. 122 | /// - traitCollection: trait collection to apply (looking for preferredContentSizeCategory and legibilityWeight) 123 | /// - Returns: Font and various styles used to render the Typography 124 | internal func generateLayout( 125 | maximumScaleFactor: CGFloat?, 126 | maximumPointSize: CGFloat?, 127 | compatibleWith traitCollection: UITraitCollection? 128 | ) -> TypographyLayout { 129 | if let maximumScaleFactor = maximumScaleFactor { 130 | return generateLayout(maximumPointSize: maximumScaleFactor * fontSize, compatibleWith: traitCollection) 131 | } 132 | 133 | return generateLayout(maximumPointSize: maximumPointSize, compatibleWith: traitCollection) 134 | } 135 | 136 | /// Maximum point size (if any). 137 | /// 138 | /// Calculated from `maximumScaleFactor` (if any) multiplied by `fontSize` or else `nil` 139 | public var maximumPointSize: CGFloat? { 140 | guard let maximumScaleFactor = maximumScaleFactor else { 141 | return nil 142 | } 143 | 144 | return maximumScaleFactor * fontSize 145 | } 146 | 147 | /// Returns the minimum of the point size (if any) or `maximumPointSize` (if any). 148 | /// - Parameter pointSize: optional point size to evaluate 149 | /// - Returns: the minimum of point size or maximumPointSize 150 | internal func getMaximumPointSize(_ pointSize: CGFloat?) -> CGFloat? { 151 | guard let maximumPointSize = maximumPointSize else { 152 | return pointSize 153 | } 154 | 155 | guard let pointSize = pointSize else { 156 | return maximumPointSize 157 | } 158 | 159 | return min(pointSize, maximumPointSize) 160 | } 161 | } 162 | 163 | private extension Typography { 164 | func generateFixedFont(compatibleWith traitCollection: UITraitCollection?) -> UIFont { 165 | var traits = traitCollection 166 | if fontFamily is SystemFontFamily { 167 | // System font already considers accessibility BoldText when 168 | // we get the scaled font via `UIFontMetrics.scaledFont(for:compatibleWith:)`, 169 | // so we pass a non-bold trait collection when generating the fixed font, so 170 | // as not to increase the font weight twice. 171 | traits = UITraitCollection(legibilityWeight: .regular) 172 | } 173 | return fontFamily.font(for: fontWeight, pointSize: fontSize, compatibleWith: traits) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/Typography+Mutators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+Mutators.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 12/8/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public extension Typography { 12 | /// Returns a copy of the Typography but with the new `familyName` applied. 13 | /// - Parameter value: the family name to use 14 | /// - Returns: an updated copy of the Typography 15 | func familyName(_ value: String) -> Typography { 16 | if fontFamily.familyName == value { return self } 17 | 18 | return Typography( 19 | familyName: value, 20 | fontStyle: fontFamily.fontNameSuffix.isEmpty ? .regular : .italic, 21 | fontWeight: fontWeight, 22 | fontSize: fontSize, 23 | lineHeight: lineHeight, 24 | letterSpacing: letterSpacing, 25 | paragraphIndent: paragraphIndent, 26 | paragraphSpacing: paragraphSpacing, 27 | textCase: textCase, 28 | textDecoration: textDecoration, 29 | textStyle: textStyle, 30 | maximumScaleFactor: maximumScaleFactor, 31 | isFixed: isFixed 32 | ) 33 | } 34 | 35 | /// Returns a copy of the Typography but with `.regular` font weight 36 | var regular: Typography { 37 | fontWeight(.regular) 38 | } 39 | 40 | /// Returns a copy of the Typography but with `.bold` font weight 41 | var bold: Typography { 42 | fontWeight(.bold) 43 | } 44 | 45 | /// Returns a copy of the Typography but with the new `fontWeight` applied. 46 | /// - Parameter value: the font weight to use 47 | /// - Returns: an updated copy of the Typography 48 | func fontWeight(_ value: FontWeight) -> Typography { 49 | if fontWeight == value { return self } 50 | 51 | return Typography( 52 | fontFamily: fontFamily, 53 | fontWeight: value, 54 | fontSize: fontSize, 55 | lineHeight: lineHeight, 56 | letterSpacing: letterSpacing, 57 | paragraphIndent: paragraphIndent, 58 | paragraphSpacing: paragraphSpacing, 59 | textCase: textCase, 60 | textDecoration: textDecoration, 61 | textStyle: textStyle, 62 | maximumScaleFactor: maximumScaleFactor, 63 | isFixed: isFixed 64 | ) 65 | } 66 | 67 | /// Returns a copy of the Typography but with the new `fontSize` applied. 68 | /// - Parameter value: the font size to use 69 | /// - Returns: an updated copy of the Typography 70 | func fontSize(_ value: CGFloat) -> Typography { 71 | if fontSize == value { return self } 72 | 73 | return Typography( 74 | fontFamily: fontFamily, 75 | fontWeight: fontWeight, 76 | fontSize: value, 77 | lineHeight: lineHeight, 78 | letterSpacing: letterSpacing, 79 | paragraphIndent: paragraphIndent, 80 | paragraphSpacing: paragraphSpacing, 81 | textCase: textCase, 82 | textDecoration: textDecoration, 83 | textStyle: textStyle, 84 | maximumScaleFactor: maximumScaleFactor, 85 | isFixed: isFixed 86 | ) 87 | } 88 | 89 | /// Returns a copy of the Typography but with the new `lineHeight` applied. 90 | /// - Parameter value: the line height to use 91 | /// - Returns: an updated copy of the Typography 92 | func lineHeight(_ value: CGFloat) -> Typography { 93 | if lineHeight == value { return self } 94 | 95 | return Typography( 96 | fontFamily: fontFamily, 97 | fontWeight: fontWeight, 98 | fontSize: fontSize, 99 | lineHeight: value, 100 | letterSpacing: letterSpacing, 101 | paragraphIndent: paragraphIndent, 102 | paragraphSpacing: paragraphSpacing, 103 | textCase: textCase, 104 | textDecoration: textDecoration, 105 | textStyle: textStyle, 106 | maximumScaleFactor: maximumScaleFactor, 107 | isFixed: isFixed 108 | ) 109 | } 110 | 111 | /// Returns a copy of the Typography but with `isFixed` set to `true` 112 | var fixed: Typography { 113 | if isFixed { return self } 114 | 115 | return Typography( 116 | fontFamily: fontFamily, 117 | fontWeight: fontWeight, 118 | fontSize: fontSize, 119 | lineHeight: lineHeight, 120 | letterSpacing: letterSpacing, 121 | paragraphIndent: paragraphIndent, 122 | paragraphSpacing: paragraphSpacing, 123 | textCase: textCase, 124 | textDecoration: textDecoration, 125 | textStyle: textStyle, 126 | maximumScaleFactor: maximumScaleFactor, 127 | isFixed: true 128 | ) 129 | } 130 | 131 | /// Returns a copy of the Typography but with the new `letterSpacing` applied. 132 | /// - Parameter value: the letter spacing to use 133 | /// - Returns: an updated copy of the Typography 134 | func letterSpacing(_ value: CGFloat) -> Typography { 135 | if letterSpacing == value { return self } 136 | 137 | return Typography( 138 | fontFamily: fontFamily, 139 | fontWeight: fontWeight, 140 | fontSize: fontSize, 141 | lineHeight: lineHeight, 142 | letterSpacing: value, 143 | paragraphIndent: paragraphIndent, 144 | paragraphSpacing: paragraphSpacing, 145 | textCase: textCase, 146 | textDecoration: textDecoration, 147 | textStyle: textStyle, 148 | maximumScaleFactor: maximumScaleFactor, 149 | isFixed: isFixed 150 | ) 151 | } 152 | 153 | /// Returns a copy of the Typography but with the new `textCase` applied. 154 | /// - Parameter value: the text case to use 155 | /// - Returns: an updated copy of the Typography 156 | func textCase(_ value: TextCase) -> Typography { 157 | if textCase == value { return self } 158 | 159 | return Typography( 160 | fontFamily: fontFamily, 161 | fontWeight: fontWeight, 162 | fontSize: fontSize, 163 | lineHeight: lineHeight, 164 | letterSpacing: letterSpacing, 165 | paragraphIndent: paragraphIndent, 166 | paragraphSpacing: paragraphSpacing, 167 | textCase: value, 168 | textDecoration: textDecoration, 169 | textStyle: textStyle, 170 | maximumScaleFactor: maximumScaleFactor, 171 | isFixed: isFixed 172 | ) 173 | } 174 | 175 | /// Returns a copy of the Typography but with the new `textDecoration` applied. 176 | /// - Parameter value: the text decoration to use 177 | /// - Returns: an updated copy of the Typography 178 | func decoration(_ value: TextDecoration) -> Typography { 179 | if textDecoration == value { return self } 180 | 181 | return Typography( 182 | fontFamily: fontFamily, 183 | fontWeight: fontWeight, 184 | fontSize: fontSize, 185 | lineHeight: lineHeight, 186 | letterSpacing: letterSpacing, 187 | paragraphIndent: paragraphIndent, 188 | paragraphSpacing: paragraphSpacing, 189 | textCase: textCase, 190 | textDecoration: value, 191 | textStyle: textStyle, 192 | maximumScaleFactor: maximumScaleFactor, 193 | isFixed: isFixed 194 | ) 195 | } 196 | 197 | /// Returns a copy of the Typography but with the new `maximumScaleFactor` applied. 198 | /// - Parameter value: the maximum scale factor to apply 199 | /// - Returns: an updated copy of the Typography 200 | func maximumScaleFactor(_ value: CGFloat?) -> Typography { 201 | if maximumScaleFactor == value { return self } 202 | 203 | return Typography( 204 | fontFamily: fontFamily, 205 | fontWeight: fontWeight, 206 | fontSize: fontSize, 207 | lineHeight: lineHeight, 208 | letterSpacing: letterSpacing, 209 | paragraphIndent: paragraphIndent, 210 | paragraphSpacing: paragraphSpacing, 211 | textCase: textCase, 212 | textDecoration: textDecoration, 213 | textStyle: textStyle, 214 | maximumScaleFactor: value, 215 | isFixed: isFixed 216 | ) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/Typography+System.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+System.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/23/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension Typography { 12 | /// The multiple used to generate line heights from standard or system font sizes (130%) 13 | public static let systemLineHeightMultiple: CGFloat = 1.3 14 | 15 | /// Standard typography for labels (uses the system font) 16 | public static let systemLabel = makeSystemTypography( 17 | fontSize: labelFontSize, weight: labelFontWeight, textStyle: .body 18 | ) 19 | 20 | /// Standard typography for buttons (uses the system font) 21 | public static let systemButton = makeSystemTypography( 22 | fontSize: buttonFontSize, weight: buttonFontWeight, textStyle: .subheadline 23 | ) 24 | 25 | /// Typography for the system font 26 | @available(iOS 14, *) 27 | @available(tvOS, unavailable) 28 | public static let system = makeSystemTypography( 29 | fontSize: UIFont.systemFontSize, weight: .regular, textStyle: .callout 30 | ) 31 | 32 | /// Typography for the small system font 33 | @available(iOS 14, *) 34 | @available(tvOS, unavailable) 35 | public static let smallSystem = makeSystemTypography( 36 | fontSize: UIFont.smallSystemFontSize, weight: .regular, textStyle: .footnote 37 | ) 38 | 39 | private static func makeSystemTypography( 40 | fontSize: CGFloat, 41 | weight: Typography.FontWeight, 42 | textStyle: UIFont.TextStyle 43 | ) -> Typography { 44 | let lineHeight = ceil(fontSize * systemLineHeightMultiple) 45 | return Typography( 46 | fontFamily: Typography.systemFamily, 47 | fontWeight: weight, 48 | fontSize: fontSize, 49 | lineHeight: lineHeight, 50 | textStyle: textStyle 51 | ) 52 | } 53 | 54 | #if os(tvOS) 55 | static var labelFontSize: CGFloat { 38.0 } 56 | static var labelFontWeight: FontWeight { .medium } 57 | static var buttonFontSize: CGFloat { 38.0 } 58 | static var buttonFontWeight: FontWeight { .medium } 59 | #else 60 | static var labelFontSize: CGFloat { UIFont.labelFontSize } 61 | static var labelFontWeight: FontWeight { .regular } 62 | static var buttonFontSize: CGFloat { UIFont.buttonFontSize } 63 | static var buttonFontWeight: FontWeight { .regular } 64 | #endif 65 | } 66 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/Typography.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/23/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Represents a font as it would appear in a design document 12 | public struct Typography { 13 | /// Information about the font family 14 | public let fontFamily: FontFamily 15 | /// Font weight 16 | public let fontWeight: FontWeight 17 | /// Font size (aka point size) 18 | public let fontSize: CGFloat 19 | /// Line height (typically greater than font size) 20 | public let lineHeight: CGFloat 21 | /// Letter spacing (in points, not percentage) 22 | public let letterSpacing: CGFloat 23 | /// Paragraph indent (in points) 24 | public let paragraphIndent: CGFloat 25 | /// Paragraph spacing (in points) 26 | public let paragraphSpacing: CGFloat 27 | /// Text case 28 | public let textCase: TextCase 29 | /// Text decoration (none, underline, or strikethrough) 30 | public let textDecoration: TextDecoration 31 | /// The text style (e.g. Body or Title) that this font most closely represents. 32 | /// Used for Dynamic Type scaling of the font 33 | public let textStyle: UIFont.TextStyle 34 | /// Maximum scale factor to apply for this typography. `nil` means no limit. 35 | /// 36 | /// Will not be considered if `isFixed == true`. 37 | /// Do not set to `1.0`, but set `isFixed = true` to disable Dynamic Type scaling. 38 | public let maximumScaleFactor: CGFloat? 39 | /// Whether this font is fixed in size or should be scaled through Dynamic Type 40 | public let isFixed: Bool 41 | 42 | /// The factory to use to convert from family name + font style into a `FontFamily`. 43 | /// The default is to use `DefaultFontFamilyFactory`. 44 | /// 45 | /// If you use a custom font family (or families) in your project, create your own factory to 46 | /// return the correct font family and then set it here, preferably as early as possible in the 47 | /// app launch lifecycle. 48 | public static var factory: FontFamilyFactory = DefaultFontFamilyFactory() 49 | 50 | /// Initializes a typography instance with the specified parameters 51 | /// - Parameters: 52 | /// - fontFamily: font family to use 53 | /// - fontWeight: font weight to use 54 | /// - fontSize: font size to use 55 | /// - lineHeight: line height to use 56 | /// - letterSpacing: letter spacing to use (defaults to `0`) 57 | /// - paragraphIndent: paragraph indent to use (defaults to `0`) 58 | /// - paragraphSpacing: paragraph spacing to use (defaults to `0`) 59 | /// - textCase: text case to apply (defaults to `.none`) 60 | /// - textDecoration: text decoration to apply (defaults to `.none`) 61 | /// - textStyle: text style to use for scaling (defaults to `.body`) 62 | /// - maximumScaleFactor: maximum scale factor to apply (defaults to `nil`) 63 | /// - isFixed: `true` if this font should never scale, `false` if it should scale (defaults to `.false`) 64 | public init( 65 | fontFamily: FontFamily, 66 | fontWeight: FontWeight, 67 | fontSize: CGFloat, 68 | lineHeight: CGFloat, 69 | letterSpacing: CGFloat = 0, 70 | paragraphIndent: CGFloat = 0, 71 | paragraphSpacing: CGFloat = 0, 72 | textCase: TextCase = .none, 73 | textDecoration: TextDecoration = .none, 74 | textStyle: UIFont.TextStyle = .body, 75 | maximumScaleFactor: CGFloat? = nil, 76 | isFixed: Bool = false 77 | ) { 78 | self.fontFamily = fontFamily 79 | self.fontWeight = fontWeight 80 | self.fontSize = fontSize 81 | self.lineHeight = lineHeight 82 | self.letterSpacing = letterSpacing 83 | self.paragraphIndent = paragraphIndent 84 | self.paragraphSpacing = paragraphSpacing 85 | self.textCase = textCase 86 | self.textDecoration = textDecoration 87 | self.textStyle = textStyle 88 | self.maximumScaleFactor = maximumScaleFactor 89 | self.isFixed = isFixed 90 | } 91 | 92 | /// Initializes a typography instance with the specified parameters 93 | /// - Parameters: 94 | /// - familyName: font family name 95 | /// - fontStyle: font style (defaults to `regular`) 96 | /// - fontWeight: font weight to use 97 | /// - fontSize: font size to use 98 | /// - lineHeight: line height to use 99 | /// - letterSpacing: letter spacing to use (defaults to `0`) 100 | /// - paragraphIndent: paragraph indent to use (defaults to `0`) 101 | /// - paragraphSpacing: paragraph spacing to use (defaults to `0`) 102 | /// - textCase: text case to apply (defaults to `.none`) 103 | /// - textDecoration: text decoration to apply (defaults to `.none`) 104 | /// - textStyle: text style to use for scaling (defaults to `.body`) 105 | /// - maximumScaleFactor: maximum scale factor to apply (defaults to `nil`) 106 | /// - isFixed: `true` if this font should never scale, `false` if it should scale (defaults to `.false`) 107 | public init( 108 | familyName: String, 109 | fontStyle: FontStyle = .regular, 110 | fontWeight: FontWeight, 111 | fontSize: CGFloat, 112 | lineHeight: CGFloat, 113 | letterSpacing: CGFloat = 0, 114 | paragraphIndent: CGFloat = 0, 115 | paragraphSpacing: CGFloat = 0, 116 | textCase: TextCase = .none, 117 | textDecoration: TextDecoration = .none, 118 | textStyle: UIFont.TextStyle = .body, 119 | maximumScaleFactor: CGFloat? = nil, 120 | isFixed: Bool = false 121 | ) { 122 | self.init( 123 | fontFamily: Self.factory.getFontFamily(familyName: familyName, style: fontStyle), 124 | fontWeight: fontWeight, 125 | fontSize: fontSize, 126 | lineHeight: lineHeight, 127 | letterSpacing: letterSpacing, 128 | paragraphIndent: paragraphIndent, 129 | paragraphSpacing: paragraphSpacing, 130 | textCase: textCase, 131 | textDecoration: textDecoration, 132 | textStyle: textStyle, 133 | maximumScaleFactor: maximumScaleFactor, 134 | isFixed: isFixed 135 | ) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/YMatterType/Typography/TypographyLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyLayout.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/27/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Typography layout comprises a font and additional typographical information (line height, kerning, etc.). 12 | /// This information is used to render the actual typography using attributed strings. 13 | /// A layout is the output of `Typography.generateLayout(...)` 14 | public struct TypographyLayout { 15 | /// Font to use (potentially scaled and considering Accessibility Bold Text) 16 | public let font: UIFont 17 | 18 | /// Scaled line height to use with this font 19 | public let lineHeight: CGFloat 20 | 21 | /// Baseline offset to use with this font (to vertically center the text within the line height) 22 | public let baselineOffset: CGFloat 23 | 24 | /// Kerning to apply for letter spacing with this font 25 | public let kerning: CGFloat 26 | 27 | /// Paragraph indent to apply 28 | public let paragraphIndent: CGFloat 29 | 30 | /// Paragraph spacing to apply 31 | public let paragraphSpacing: CGFloat 32 | 33 | /// Text case to apply to text 34 | public let textCase: Typography.TextCase 35 | 36 | /// Text decoration to apply 37 | public let textDecoration: Typography.TextDecoration 38 | 39 | /// Paragraph style with the correct line height for rendering multi-line text 40 | public let paragraphStyle: NSParagraphStyle 41 | 42 | /// Line height multiple to use with this font (to achieve the desired line height) 43 | public var lineHeightMultiple: CGFloat { lineHeight / font.lineHeight } 44 | 45 | init( 46 | font: UIFont, 47 | lineHeight: CGFloat, 48 | baselineOffset: CGFloat, 49 | kerning: CGFloat, 50 | paragraphIndent: CGFloat, 51 | paragraphSpacing: CGFloat, 52 | textCase: Typography.TextCase, 53 | textDecoration: Typography.TextDecoration 54 | ) { 55 | self.font = font 56 | self.lineHeight = lineHeight 57 | self.baselineOffset = baselineOffset 58 | self.kerning = kerning 59 | self.paragraphIndent = paragraphIndent 60 | self.paragraphSpacing = paragraphSpacing 61 | self.textCase = textCase 62 | self.textDecoration = textDecoration 63 | self.paragraphStyle = NSParagraphStyle.default.styleWithLineHeight( 64 | lineHeight, 65 | indent: paragraphIndent, 66 | spacing: paragraphSpacing 67 | ) 68 | } 69 | } 70 | 71 | public extension TypographyLayout { 72 | /// Whether the text needs to styled as an attributed string to display properly. 73 | /// 74 | /// If `false` plain text can be set instead. 75 | var needsStylingForSingleLine: Bool { 76 | kerning != 0 || textCase != .none || textDecoration != .none 77 | } 78 | 79 | /// Style plain text using this layout 80 | /// - Parameters: 81 | /// - text: the text to style 82 | /// - lineMode: line mode of the text. 83 | /// Paragraph styles will not be applied to single line text. 84 | /// - additionalAttributes: any additional attributes to apply 85 | /// (e.g. `UITextView` requires `.foregroundColor`), default = `[:]` 86 | /// - Returns: an attributed string containing the styled text. 87 | func styleText( 88 | _ text: String, 89 | lineMode: Typography.LineMode, 90 | additionalAttributes: [NSAttributedString.Key: Any] = [:] 91 | ) -> NSAttributedString { 92 | let attributes = buildAttributes(startingWith: additionalAttributes, lineMode: lineMode) 93 | return NSAttributedString( 94 | string: text.textCase(textCase), 95 | attributes: attributes 96 | ) 97 | } 98 | 99 | /// Style attributed text using this layout 100 | /// - Parameters: 101 | /// - attributedText: the attrubuted text to style 102 | /// - lineMode: line mode of the text. 103 | /// Paragraph styles will not be applied to single line text. 104 | /// - additionalAttributes: any additional attributes to apply 105 | /// (e.g. `UITextView` requires `.foregroundColor`), default = `[:]` 106 | /// - Returns: an attributed string containing the styled text. 107 | func styleAttributedText( 108 | _ attributedText: NSAttributedString, 109 | lineMode: Typography.LineMode, 110 | additionalAttributes: [NSAttributedString.Key: Any] = [:] 111 | ) -> NSAttributedString { 112 | let attributes = buildAttributes(startingWith: additionalAttributes, lineMode: lineMode) 113 | return attributedText.textCase(textCase).attributedString(with: attributes) 114 | } 115 | 116 | /// Generates the text attributes needed to apply this typographical layout. 117 | /// 118 | /// These attributes may change (because the font may change) any time there is a change in 119 | /// content size category (Dynamic Type) or legibility weight (Accessibility Bold Text). 120 | /// - Parameters: 121 | /// - additionalAttributes: any additional attributes to combine with the typographical attributes. 122 | /// Default = `[:]` 123 | /// - lineMode: line mode of the text. Default = `.single`. 124 | /// Paragraph styles will not be applied to single line text. 125 | /// - Returns: the dictionary of attributes needed to style the text. 126 | func buildAttributes( 127 | startingWith additionalAttributes: [NSAttributedString.Key: Any] = [:], 128 | lineMode: Typography.LineMode = .single 129 | ) -> [NSAttributedString.Key: Any] { 130 | var attributes = additionalAttributes 131 | if case let .multi(textAlignment, lineBreakMode) = lineMode { 132 | let style = NSMutableParagraphStyle() 133 | style.setParagraphStyle(paragraphStyle) 134 | style.alignment = textAlignment 135 | if let lineBreakMode = lineBreakMode { 136 | style.lineBreakMode = lineBreakMode 137 | } 138 | attributes[.paragraphStyle] = style 139 | attributes[.baselineOffset] = baselineOffset 140 | } 141 | 142 | attributes[.font] = font 143 | 144 | // Only apply kerning if it is non-zero 145 | if kerning != 0 { 146 | attributes[.kern] = kerning 147 | } 148 | 149 | // Apply text decoration (if any) 150 | switch textDecoration { 151 | case .none: 152 | break 153 | case .underline: 154 | attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue 155 | case .strikethrough: 156 | attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue 157 | } 158 | 159 | return attributes 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Sources/YMatterType/YMatterType+Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YMatterType+Logging.swift 3 | // YMatterType 4 | // 5 | // Created by Sahil Saini on 04/04/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import os 10 | 11 | /// Y—MatterType Settings 12 | public struct YMatterType { 13 | /// Whether console logging for warnings is enabled. Defaults to `true`. 14 | public static var isLoggingEnabled = true 15 | } 16 | 17 | internal extension YMatterType { 18 | /// Logger for warnings related to font loading. cf. `FontFamily` 19 | static let fontLogger = Logger(subsystem: "YMatterType", category: "fonts") 20 | } 21 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Assets/Fonts/NotoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeandtheory/YMatterType/3a9f8dd625ecfde9f9cb2c2e236b102fda148e95/Tests/YMatterTypeTests/Assets/Fonts/NotoSans-Regular.ttf -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/ElementBreakpointAdjustedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ElementBreakpointAdjustedTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/19/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | protocol TraitEnvironmentFlags: UITraitEnvironment { 13 | /// Clear all flags set during `traitCollectionDidChange(:)` 14 | func clear() 15 | } 16 | 17 | protocol BreakpointAdjustable: TraitEnvironmentFlags { 18 | var isBreakpointAdjusted: Bool { get } 19 | } 20 | 21 | extension MockButton: BreakpointAdjustable { } 22 | extension MockLabel: BreakpointAdjustable { } 23 | extension MockTextField: BreakpointAdjustable { } 24 | extension MockTextView: BreakpointAdjustable { } 25 | 26 | final class ElementBreakpointAdjustedTests: XCTestCase { 27 | private let typography = Typography( 28 | fontFamily: Typography.systemFamily, 29 | fontWeight: .light, 30 | fontSize: 22, 31 | lineHeight: 28, 32 | textStyle: .title3, 33 | isFixed: true 34 | ) 35 | 36 | func testLabel() { 37 | let sut = MockLabel(typography: typography) 38 | _testElement(sut) 39 | } 40 | 41 | func testButton() { 42 | let sut = MockButton(typography: typography) 43 | _testElement(sut) 44 | } 45 | 46 | func testTextField() { 47 | let sut = MockTextField(typography: typography) 48 | _testElement(sut) 49 | } 50 | 51 | func testTextView() { 52 | let sut = MockTextView(typography: typography) 53 | _testElement(sut) 54 | } 55 | 56 | private func _testElement(_ sut: T) { 57 | // if traits haven't changed, then breakpoints should not be adjusted 58 | sut.clear() 59 | sut.traitCollectionDidChange(sut.traitCollection) 60 | XCTAssertFalse(sut.isBreakpointAdjusted) 61 | 62 | let sameTraits = UITraitCollection.generateSimilarBreakpointTraits(to: sut.traitCollection) 63 | 64 | // If traits unrelated to breakpoints have changed, then breakpoints should not be adjusted 65 | sameTraits.forEach { 66 | sut.clear() 67 | sut.traitCollectionDidChange($0) 68 | XCTAssertFalse(sut.isBreakpointAdjusted) 69 | } 70 | 71 | let differentTraits = UITraitCollection.generateDifferentBreakpointTraits() 72 | 73 | // If traits have changed, then breakpoints should only be adjusted when the traits have a different breakpoint 74 | differentTraits.forEach { 75 | sut.clear() 76 | sut.traitCollectionDidChange($0) 77 | XCTAssertEqual(sut.isBreakpointAdjusted, sut.traitCollection.hasDifferentBreakpoint(comparedTo: $0)) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/ElementColorAdjustedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ElementColorAdjustedTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/20/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | protocol ColorAdjustable: TraitEnvironmentFlags { 13 | var isColorAdjusted: Bool { get } 14 | } 15 | 16 | extension MockButton: ColorAdjustable { } 17 | extension MockLabel: ColorAdjustable { } 18 | extension MockTextField: ColorAdjustable { } 19 | extension MockTextView: ColorAdjustable { } 20 | 21 | final class ElementColorAdjustedTests: XCTestCase { 22 | private let typography = Typography( 23 | fontFamily: Typography.systemFamily, 24 | fontWeight: .medium, 25 | fontSize: 24, 26 | lineHeight: 32, 27 | textStyle: .title2, 28 | isFixed: true 29 | ) 30 | 31 | func testLabel() { 32 | let sut = MockLabel(typography: typography) 33 | _testElement(sut) 34 | } 35 | 36 | func testButton() { 37 | let sut = MockButton(typography: typography) 38 | _testElement(sut) 39 | } 40 | 41 | func testTextField() { 42 | let sut = MockTextField(typography: typography) 43 | _testElement(sut) 44 | } 45 | 46 | func testTextView() { 47 | let sut = MockTextView(typography: typography) 48 | _testElement(sut) 49 | } 50 | 51 | private func _testElement(_ sut: T) { 52 | // if traits haven't changed, then colors should not be adjusted 53 | sut.clear() 54 | sut.traitCollectionDidChange(sut.traitCollection) 55 | XCTAssertFalse(sut.isColorAdjusted) 56 | 57 | let sameTraits = UITraitCollection.generateSimilarColorTraits(to: sut.traitCollection) 58 | 59 | // If traits unrelated to colors have changed, then colors should not be adjusted 60 | sameTraits.forEach { 61 | sut.clear() 62 | sut.traitCollectionDidChange($0) 63 | XCTAssertFalse(sut.isColorAdjusted) 64 | } 65 | 66 | let differentTraits = UITraitCollection.generateDifferentColorTraits() 67 | 68 | // If traits have changed, then colors should only be adjusted when the traits have a different color appearance 69 | differentTraits.forEach { 70 | sut.clear() 71 | sut.traitCollectionDidChange($0) 72 | XCTAssertEqual(sut.isColorAdjusted, sut.traitCollection.hasDifferentColorAppearance(comparedTo: $0)) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/ElementFontAdjustedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ElementFontAdjustedTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/20/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | protocol FontAdjustable: TraitEnvironmentFlags { 13 | var isFontAdjusted: Bool { get } 14 | } 15 | 16 | extension MockButton: FontAdjustable { } 17 | extension MockLabel: FontAdjustable { } 18 | extension MockTextField: FontAdjustable { } 19 | extension MockTextView: FontAdjustable { } 20 | 21 | final class ElementFontAdjustedTests: XCTestCase { 22 | private let typography = Typography( 23 | fontFamily: Typography.systemFamily, 24 | fontWeight: .heavy, 25 | fontSize: 32, 26 | lineHeight: 40, 27 | textStyle: .title1, 28 | isFixed: true 29 | ) 30 | 31 | func testLabel() { 32 | let sut = MockLabel(typography: typography) 33 | _testElement(sut) 34 | } 35 | 36 | func testButton() { 37 | let sut = MockButton(typography: typography) 38 | _testElement(sut) 39 | } 40 | 41 | func testTextField() { 42 | let sut = MockTextField(typography: typography) 43 | _testElement(sut) 44 | } 45 | 46 | func testTextView() { 47 | let sut = MockTextView(typography: typography) 48 | _testElement(sut) 49 | } 50 | 51 | private func _testElement(_ sut: T) { 52 | // if traits haven't changed, then fonts should not be adjusted 53 | sut.clear() 54 | sut.traitCollectionDidChange(sut.traitCollection) 55 | XCTAssertFalse(sut.isFontAdjusted) 56 | 57 | let sameTraits = UITraitCollection.generateSimilarFontTraits(to: sut.traitCollection) 58 | 59 | // If traits unrelated to fonts have changed, then fonts should not be adjusted 60 | sameTraits.forEach { 61 | sut.clear() 62 | sut.traitCollectionDidChange($0) 63 | XCTAssertFalse(sut.isFontAdjusted) 64 | } 65 | 66 | let differentTraits = UITraitCollection.generateDifferentFontTraits() 67 | 68 | // If traits have changed, then fonts should only be adjusted when the traits have a different font appearance 69 | differentTraits.forEach { 70 | sut.clear() 71 | sut.traitCollectionDidChange($0) 72 | XCTAssertEqual(sut.isFontAdjusted, sut.traitCollection.hasDifferentFontAppearance(comparedTo: $0)) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/Mocks/MockButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockButton.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/19/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | @testable import YMatterType 11 | 12 | final class MockButton: TypographyButton { 13 | var isFontAdjusted: Bool = false 14 | var isColorAdjusted: Bool = false 15 | var isBreakpointAdjusted: Bool = false 16 | var isButtonStateChanged: Bool = false 17 | 18 | func clear() { 19 | isFontAdjusted = false 20 | isColorAdjusted = false 21 | isBreakpointAdjusted = false 22 | } 23 | 24 | override func adjustFonts() { 25 | super.adjustFonts() 26 | isFontAdjusted = true 27 | } 28 | 29 | override func adjustColors() { 30 | super.adjustColors() 31 | isColorAdjusted = true 32 | } 33 | 34 | override func adjustBreakpoint() { 35 | super.adjustBreakpoint() 36 | isBreakpointAdjusted = true 37 | } 38 | 39 | var paragraphStyle: NSParagraphStyle { 40 | guard let attributedText = currentAttributedTitle, 41 | attributedText.length > 0, 42 | let style = attributedText.attribute(.paragraphStyle, at: 0, effectiveRange: nil) 43 | as? NSParagraphStyle else { 44 | return NSParagraphStyle.default 45 | } 46 | 47 | return style 48 | } 49 | 50 | override func buttonStateDidChange() { 51 | super.buttonStateDidChange() 52 | isButtonStateChanged = true 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/Mocks/MockLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockLabel.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/19/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | @testable import YMatterType 11 | 12 | final class MockLabel: TypographyLabel { 13 | var isFontAdjusted: Bool = false 14 | var isColorAdjusted: Bool = false 15 | var isBreakpointAdjusted: Bool = false 16 | 17 | func clear() { 18 | isFontAdjusted = false 19 | isColorAdjusted = false 20 | isBreakpointAdjusted = false 21 | } 22 | 23 | override func adjustFonts() { 24 | super.adjustFonts() 25 | isFontAdjusted = true 26 | } 27 | 28 | override func adjustColors() { 29 | super.adjustColors() 30 | isColorAdjusted = true 31 | } 32 | 33 | override func adjustBreakpoint() { 34 | super.adjustBreakpoint() 35 | isBreakpointAdjusted = true 36 | } 37 | 38 | var paragraphStyle: NSParagraphStyle { 39 | guard let attributedText = attributedText, 40 | attributedText.length > 0, 41 | let style = attributedText.attribute(.paragraphStyle, at: 0, effectiveRange: nil) 42 | as? NSParagraphStyle else { 43 | return NSParagraphStyle.default 44 | } 45 | 46 | return style 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/Mocks/MockTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockTextField.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/19/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import YMatterType 11 | 12 | final class MockTextField: TypographyTextField { 13 | var isFontAdjusted: Bool = false 14 | var isColorAdjusted: Bool = false 15 | var isBreakpointAdjusted: Bool = false 16 | 17 | func clear() { 18 | isFontAdjusted = false 19 | isColorAdjusted = false 20 | isBreakpointAdjusted = false 21 | } 22 | 23 | override func adjustFonts() { 24 | super.adjustFonts() 25 | isFontAdjusted = true 26 | } 27 | 28 | override func adjustColors() { 29 | super.adjustColors() 30 | isColorAdjusted = true 31 | } 32 | 33 | override func adjustBreakpoint() { 34 | super.adjustBreakpoint() 35 | isBreakpointAdjusted = true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/Mocks/MockTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockTextView.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/19/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | @testable import YMatterType 11 | 12 | final class MockTextView: TypographyTextView { 13 | var isFontAdjusted: Bool = false 14 | var isColorAdjusted: Bool = false 15 | var isBreakpointAdjusted: Bool = false 16 | 17 | func clear() { 18 | isFontAdjusted = false 19 | isColorAdjusted = false 20 | isBreakpointAdjusted = false 21 | } 22 | 23 | override func adjustFonts() { 24 | super.adjustFonts() 25 | isFontAdjusted = true 26 | } 27 | 28 | override func adjustColors() { 29 | super.adjustColors() 30 | isColorAdjusted = true 31 | } 32 | 33 | override func adjustBreakpoint() { 34 | super.adjustBreakpoint() 35 | isBreakpointAdjusted = true 36 | } 37 | 38 | var paragraphStyle: NSParagraphStyle { 39 | guard let attributedText = attributedText, 40 | attributedText.length > 0, 41 | let style = attributedText.attribute(.paragraphStyle, at: 0, effectiveRange: nil) 42 | as? NSParagraphStyle else { 43 | return NSParagraphStyle.default 44 | } 45 | 46 | return style 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/Representables/TypographyLabelRepresentableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyLabelRepresentableTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/20/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class TypographyLabelRepresentableTests: XCTestCase { 13 | enum Constants { 14 | static let helloWorldText = "Hello, World" 15 | static let goodbyeText = "Goodbye!" 16 | static let fontSize = CGFloat.random(in: 18...36) 17 | static let textColor: UIColor = .systemPurple 18 | } 19 | 20 | func test_getLabel_deliversLabel() { 21 | // Given 22 | let text = Constants.helloWorldText 23 | let typography: Typography = .smallBody.fontSize(Constants.fontSize) 24 | let textColor = Constants.textColor 25 | let sut = TypographyLabelRepresentable(text: text, typography: typography) { label in 26 | label.textColor = textColor 27 | } 28 | 29 | // When 30 | let label = sut.getLabel() 31 | 32 | // Then 33 | XCTAssertEqual(label.text, text) 34 | XCTAssertTypographyEqual(label.typography, typography) 35 | XCTAssertEqual(label.numberOfLines, 1) 36 | XCTAssertEqual(label.textColor, textColor) 37 | } 38 | 39 | func test_updateLabel_updatesLabel() { 40 | // Given 41 | let text = Constants.goodbyeText 42 | let typography: Typography = .bodyBold.fontSize(Constants.fontSize) 43 | let textColor = Constants.textColor 44 | var sut = TypographyLabelRepresentable(text: Constants.helloWorldText, typography: .smallBody) 45 | let label = sut.getLabel() 46 | XCTAssertNotEqual(label.text, text) 47 | XCTAssertNotEqual(label.typography.fontSize, typography.fontSize) 48 | XCTAssertNotEqual(label.typography.fontWeight, typography.fontWeight) 49 | XCTAssertNotEqual(label.textColor, textColor) 50 | 51 | // When 52 | sut.text = text 53 | sut.typography = typography 54 | sut.configureTextStyleLabel = { label in 55 | label.textColor = textColor 56 | } 57 | sut.updateLabel(label) 58 | 59 | // Then 60 | XCTAssertEqual(label.text, text) 61 | XCTAssertTypographyEqual(label.typography, typography) 62 | XCTAssertEqual(label.textColor, textColor) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/TextStyleLabelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextStyleLabelTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Virginia Pujols on 1/26/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class TextStyleLabelTests: XCTestCase { 13 | enum Constants { 14 | static let helloWorldText = "Hello, World" 15 | static let fontSize = CGFloat.random(in: 10...60) 16 | } 17 | 18 | func testTextStyleLabelSingleLine() { 19 | let expectedText = Constants.helloWorldText 20 | let expectedTypography = Typography.systemLabel.fontSize(Constants.fontSize) 21 | 22 | // Given a TextStyleLabel with a single line of text 23 | let sut = TextStyleLabel( 24 | expectedText, 25 | typography: expectedTypography, 26 | configuration: { (label: TypographyLabel) in 27 | label.lineBreakMode = .byTruncatingMiddle 28 | } 29 | ) 30 | 31 | // we expect a value 32 | XCTAssertNotNil(sut) 33 | 34 | // we expect the text to match the expected 35 | XCTAssertEqual(sut.text, expectedText) 36 | 37 | // we expect the font to match the expected 38 | XCTAssertTypographyEqual(sut.typography, expectedTypography) 39 | 40 | // we expect the configuration closusure to update the label 41 | let labelToUpdate = TypographyLabel(typography: expectedTypography) 42 | XCTAssertNotEqual(labelToUpdate.lineBreakMode, .byTruncatingMiddle) 43 | 44 | sut.configureTextStyleLabel?(labelToUpdate) 45 | XCTAssertEqual(labelToUpdate.lineBreakMode, .byTruncatingMiddle) 46 | } 47 | 48 | func test_getLabel_deliversRepresentable() throws { 49 | let expectedText = Constants.helloWorldText 50 | let expectedTypography = Typography.systemLabel.fontSize(Constants.fontSize) 51 | 52 | // Given a TextStyleLabel with a single line of text 53 | let sut = TextStyleLabel( 54 | expectedText, 55 | typography: expectedTypography 56 | ) 57 | 58 | // When 59 | let label = try XCTUnwrap(sut.getLabel() as? TypographyLabelRepresentable) 60 | 61 | // Then 62 | XCTAssertEqual(label.text, expectedText) 63 | XCTAssertTypographyEqual(label.typography, expectedTypography) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Elements/TypographyElementTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyElementTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 5/25/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class TypographyElementTests: XCTestCase { 12 | /// Create nested view controllers containing the view to be tested so that we can override traits 13 | func makeNestedViewControllers( 14 | subview: UIView, 15 | file: StaticString = #filePath, 16 | line: UInt = #line 17 | ) -> (parent: UIViewController, child: UIViewController) { 18 | let parent = UIViewController() 19 | let child = UIViewController() 20 | parent.addChild(child) 21 | parent.view.addSubview(child.view) 22 | 23 | // constrain child controller view to parent 24 | child.view.translatesAutoresizingMaskIntoConstraints = false 25 | child.view.topAnchor.constraint(equalTo: parent.view.topAnchor).isActive = true 26 | child.view.leadingAnchor.constraint(equalTo: parent.view.leadingAnchor).isActive = true 27 | child.view.bottomAnchor.constraint(equalTo: parent.view.bottomAnchor).isActive = true 28 | child.view.trailingAnchor.constraint(equalTo: parent.view.trailingAnchor).isActive = true 29 | 30 | child.view.addSubview(subview) 31 | 32 | // constrain subview to child view center 33 | subview.translatesAutoresizingMaskIntoConstraints = false 34 | subview.centerXAnchor.constraint(equalTo: child.view.centerXAnchor).isActive = true 35 | subview.centerYAnchor.constraint(equalTo: child.view.centerYAnchor).isActive = true 36 | 37 | trackForMemoryLeak(parent, file: file, line: line) 38 | trackForMemoryLeak(child, file: file, line: line) 39 | 40 | return (parent, child) 41 | } 42 | 43 | func makeCoder(for view: UIView) throws -> NSCoder { 44 | let data = try NSKeyedArchiver.archivedData(withRootObject: view, requiringSecureCoding: false) 45 | return try NSKeyedUnarchiver(forReadingFrom: data) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/Foundation/BaseStringTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseStringTestCase.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 5/3/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | class BaseStringTestCase: XCTestCase { 13 | enum TestCase: CaseIterable { 14 | case empty 15 | case symbols 16 | case word 17 | case sentence 18 | case asymmetric 19 | } 20 | 21 | func text(for testCase: TestCase) -> String { 22 | switch testCase { 23 | case .empty: 24 | return "" 25 | case .symbols: 26 | return "!@#$%^&*()" 27 | case .word: 28 | return "John" 29 | case .sentence: 30 | return "The quick brown fox jumped over the lazy dog." 31 | case .asymmetric: 32 | return "straße" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/Foundation/NSAttributedString+baseAttributesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+baseAttributesTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/2/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class NSAttributedStringBaseAttributesTests: XCTestCase { 13 | private let regularColor = UIColor.label 14 | private let regularFont = UIFont.systemFont(ofSize: 15, weight: .regular) 15 | private lazy var regularAttributes: [NSAttributedString.Key: Any] = [ 16 | .foregroundColor: regularColor, 17 | .font: regularFont 18 | ] 19 | 20 | private let boldFont = UIFont.systemFont(ofSize: 16, weight: .bold) 21 | private let boldColor = UIColor.systemTeal 22 | private let boldRange = NSRange(location: 4, length: 5) 23 | private lazy var boldAttributes: [NSAttributedString.Key: Any] = [ 24 | .foregroundColor: boldColor, 25 | .font: boldFont 26 | ] 27 | 28 | func testApplyBaseAttributes() { 29 | let sut = makeSUT() 30 | let updated = sut.attributedString(with: regularAttributes) 31 | 32 | for i in 0.. NSAttributedString { 64 | let sut = NSMutableAttributedString(string: "The doctor is in.") 65 | sut.addAttributes(boldAttributes, range: boldRange) 66 | trackForMemoryLeak(sut, file: file, line: line) 67 | return sut 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/Foundation/NSAttributedString+textCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+textCaseTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 5/3/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class NSAttributedStringTextCaseTests: BaseStringTestCase { 13 | func testNone() { 14 | let sut = makeSUT() 15 | sut.forEach { _, value in 16 | XCTAssertEqual(value.textCase(.none), value) 17 | } 18 | } 19 | 20 | func testLowercase() { 21 | let sut = makeSUT() 22 | sut.forEach { key, input in 23 | let output = input.textCase(.lowercase).string 24 | let expected: String 25 | switch key { 26 | case .empty, .symbols: 27 | expected = input.string 28 | case .word: 29 | expected = "john" 30 | case .sentence: 31 | expected = "the quick brown fox jumped over the lazy dog." 32 | case .asymmetric: 33 | expected = "straße" 34 | } 35 | XCTAssertEqual(output, expected) 36 | } 37 | } 38 | 39 | func testUppercase() { 40 | let sut = makeSUT() 41 | sut.forEach { key, input in 42 | let output = input.textCase(.uppercase).string 43 | let expected: String 44 | switch key { 45 | case .empty, .symbols: 46 | expected = input.string 47 | case .word: 48 | expected = "JOHN" 49 | case .sentence: 50 | expected = "THE QUICK BROWN FOX JUMPED OVER THE LAZY DOG." 51 | case .asymmetric: 52 | expected = "STRASSE" 53 | } 54 | XCTAssertEqual(output, expected) 55 | } 56 | } 57 | 58 | func testCapitalize() { 59 | let sut = makeSUT() 60 | sut.forEach { key, input in 61 | let output = input.textCase(.capitalize).string 62 | let expected: String 63 | switch key { 64 | case .empty, .symbols: 65 | expected = input.string 66 | case .word: 67 | // capitalize behaves strangely over word fragments 68 | expected = "JoHn" 69 | case .sentence: 70 | expected = "The Quick Brown Fox Jumped Over The Lazy Dog." 71 | case .asymmetric: 72 | expected = "Straße" 73 | } 74 | XCTAssertEqual(output, expected) 75 | } 76 | } 77 | } 78 | 79 | private extension NSAttributedStringTextCaseTests { 80 | func makeSUT() -> [TestCase: NSAttributedString] { 81 | TestCase.allCases.reduce(into: [TestCase: NSAttributedString]()) { 82 | let text = text(for: $1) 83 | let attributes = attributes(for: $1) 84 | switch $1 { 85 | case .word: 86 | // Apply attributes to partial string 87 | let attributedText = NSMutableAttributedString(string: text) 88 | attributedText.setAttributes(attributes, to: "hn") 89 | $0[$1] = attributedText 90 | case .sentence: 91 | // Apply attributes to partial string 92 | let attributedText = NSMutableAttributedString(string: text) 93 | attributedText.setAttributes(attributes, to: "the lazy dog") 94 | $0[$1] = attributedText 95 | case .empty, .symbols, .asymmetric: 96 | // Apply attributes to entire string 97 | $0[$1] = NSAttributedString(string: text, attributes: attributes) 98 | } 99 | } 100 | } 101 | 102 | func attributes(for testCase: TestCase) -> [NSAttributedString.Key: Any] { 103 | switch testCase { 104 | case .empty: 105 | return [:] 106 | case .symbols: 107 | return [.underlineStyle: 1] 108 | case .word: 109 | return [.kern: 1.2] 110 | case .sentence: 111 | return [ 112 | .foregroundColor: UIColor.systemRed, 113 | .backgroundColor: UIColor.systemTeal 114 | ] 115 | case .asymmetric: 116 | return [.paragraphStyle: NSParagraphStyle.default.styleWithLineHeight(24)] 117 | } 118 | } 119 | } 120 | 121 | extension NSMutableAttributedString { 122 | /// Adds attributes to the first range of text matching `subtext`. 123 | /// 124 | /// Internally this uses `addAttributes(:, range:)` using the range of `subtext` within the attributed string. 125 | /// If the attributed string does not contain `subtext` then the attributed string is not mutated. 126 | /// - Parameters: 127 | /// - attributes: the attributes to add 128 | /// - subtext: text within the attributed string to add the attributes. 129 | func addAttributes(_ attributes: [NSAttributedString.Key: Any], to subtext: String) { 130 | guard let range = string.range(of: subtext) else { return } 131 | addAttributes(attributes, range: NSRange(range, in: string)) 132 | } 133 | 134 | /// Sets attributes to the first range of text matching `subtext`. 135 | /// 136 | /// Internally this uses `setAttributes(:, range:)` using the range of `subtext` within the attributed string. 137 | /// If the attributed string does not contain `subtext` then the attributed string is not mutated. 138 | /// - Parameters: 139 | /// - attributes: the attributes to set 140 | /// - subtext: text within the attributed string to set the attributes. 141 | func setAttributes(_ attributes: [NSAttributedString.Key: Any], to subtext: String) { 142 | guard let range = string.range(of: subtext) else { return } 143 | setAttributes(attributes, range: NSRange(range, in: string)) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/Foundation/String+textCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+textCaseTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 5/2/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class StringTextCaseTests: BaseStringTestCase { 13 | func testNone() { 14 | let sut = makeSUT() 15 | sut.forEach { _, value in 16 | XCTAssertEqual(value.textCase(.none), value) 17 | } 18 | } 19 | 20 | func testLowercase() { 21 | let sut = makeSUT() 22 | 23 | sut.forEach { key, input in 24 | let output = input.textCase(.lowercase) 25 | let expected: String 26 | switch key { 27 | case .empty, .symbols: 28 | expected = input 29 | case .word: 30 | expected = "john" 31 | case .sentence: 32 | expected = "the quick brown fox jumped over the lazy dog." 33 | case .asymmetric: 34 | expected = "straße" 35 | } 36 | XCTAssertEqual(output, expected) 37 | } 38 | } 39 | 40 | func testUppercase() { 41 | let sut = makeSUT() 42 | 43 | sut.forEach { key, input in 44 | let output = input.textCase(.uppercase) 45 | let expected: String 46 | switch key { 47 | case .empty, .symbols: 48 | expected = input 49 | case .word: 50 | expected = "JOHN" 51 | case .sentence: 52 | expected = "THE QUICK BROWN FOX JUMPED OVER THE LAZY DOG." 53 | case .asymmetric: 54 | expected = "STRASSE" 55 | } 56 | XCTAssertEqual(output, expected) 57 | } 58 | } 59 | 60 | func testCapitalize() { 61 | let sut = makeSUT() 62 | 63 | sut.forEach { key, input in 64 | let output = input.textCase(.capitalize) 65 | let expected: String 66 | switch key { 67 | case .empty, .symbols: 68 | expected = input 69 | case .word: 70 | expected = "John" 71 | case .sentence: 72 | expected = "The Quick Brown Fox Jumped Over The Lazy Dog." 73 | case .asymmetric: 74 | expected = "Straße" 75 | } 76 | XCTAssertEqual(output, expected) 77 | } 78 | } 79 | } 80 | 81 | private extension StringTextCaseTests { 82 | func makeSUT() -> [TestCase: String] { 83 | TestCase.allCases.reduce(into: [TestCase: String]()) { 84 | $0[$1] = text(for: $1) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/Foundation/String+textSizeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+textSizeTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Sahil Saini on 27/03/23. 6 | // 7 | 8 | import XCTest 9 | @testable import YMatterType 10 | 11 | final class StringTextSizeTests: XCTestCase { 12 | func test_sizeWithFont_deliversRoundedValues() { 13 | // Given 14 | let scale = getScale() 15 | let pointSize = CGFloat(Int.random(in: 10...24)) 16 | let traits = UITraitCollection(displayScale: scale) 17 | let font = UIFont.systemFont(ofSize: pointSize, weight: .regular) 18 | let sut = "Hello" 19 | 20 | // When 21 | let size = sut.size(withFont: font, compatibleWith: traits) 22 | 23 | // Then 24 | XCTAssertGreaterThan(size.width, 0) 25 | XCTAssertGreaterThan(size.height, 0) 26 | XCTAssertEqual(size.width, ceil(size.width * scale) / scale) 27 | XCTAssertEqual(size.height, ceil(size.height * scale) / scale) 28 | } 29 | 30 | func test_sizeWithTypography_deliversRoundedValues() throws { 31 | // Given 32 | let scale = getScale() 33 | let traits = UITraitCollection(displayScale: scale) 34 | let typography = try XCTUnwrap(getTypographies().randomElement()) 35 | let sut = "Hello" 36 | 37 | // When 38 | let size = sut.size(withTypography: typography, compatibleWith: traits) 39 | 40 | // Then 41 | XCTAssertGreaterThan(size.width, 0) 42 | XCTAssertGreaterThan(size.height, 0) 43 | XCTAssertEqual(size.width, ceil(size.width * scale) / scale) 44 | XCTAssertEqual(size.height, ceil(size.height * scale) / scale) 45 | } 46 | 47 | func test_sizeWithTypography_deliversLineHeight() { 48 | // Given 49 | let typography = Typography( 50 | fontFamily: Typography.systemFamily, 51 | fontWeight: .bold, 52 | fontSize: 24, 53 | lineHeight: 32, 54 | textStyle: .title1 55 | ) 56 | let sut = "testString" 57 | 58 | // When 59 | let size = sut.size(withTypography: typography, compatibleWith: .default) 60 | 61 | // Then 62 | XCTAssertGreaterThan(size.height, 0) 63 | XCTAssertEqual(size.height, typography.lineHeight) 64 | } 65 | 66 | #if !os(tvOS) 67 | func test_sizeWithTypography_deliversScaledSize() throws { 68 | // Given 69 | let typography = try XCTUnwrap(getTypographies().randomElement()) 70 | let sut = "testString" 71 | let traits = UITraitCollection(preferredContentSizeCategory: .accessibilityMedium) 72 | 73 | // When 74 | let size = sut.size(withTypography: typography, compatibleWith: traits) 75 | 76 | // Then 77 | XCTAssertGreaterThan(size.height, typography.lineHeight) 78 | } 79 | #endif 80 | 81 | func test_longerStrings_deliverGreaterWidths() { 82 | // Given 83 | let typography = Typography( 84 | fontFamily: Typography.systemFamily, 85 | fontWeight: .bold, 86 | fontSize: 24, 87 | lineHeight: 32, 88 | textStyle: .body 89 | ) 90 | let sut1 = "testString1" 91 | let sut2 = "testString" 92 | 93 | // When 94 | let sutSize1 = sut1.size(withTypography: typography, compatibleWith: .default) 95 | let sutSize2 = sut2.size(withTypography: typography, compatibleWith: .default) 96 | 97 | // Then 98 | XCTAssertGreaterThan(sutSize1.height, 0) 99 | XCTAssertGreaterThan(sutSize2.height, 0) 100 | XCTAssertGreaterThan(sutSize1.width, sutSize2.width) 101 | XCTAssertEqual(sutSize1.height, sutSize2.height) 102 | } 103 | 104 | func test_emptyString_deliversZeroWidth() { 105 | // Given 106 | let typography = Typography( 107 | fontFamily: Typography.systemFamily, 108 | fontWeight: .bold, 109 | fontSize: 24, 110 | lineHeight: 32, 111 | textStyle: .caption1 112 | ) 113 | let sut = "" 114 | 115 | // When 116 | let sutSize = sut.size(withTypography: typography, compatibleWith: nil) 117 | 118 | // Then 119 | XCTAssertGreaterThan(sutSize.height, 0) 120 | XCTAssertEqual(sutSize.width, 0) 121 | } 122 | } 123 | 124 | extension StringTextSizeTests { 125 | func getScale() -> CGFloat { 126 | let scale: CGFloat 127 | #if os(tvOS) 128 | scale = UIScreen.main.scale 129 | #else 130 | scale = CGFloat(Int.random(in: 1...3)) 131 | #endif 132 | return scale 133 | } 134 | 135 | func getTypographies() -> [Typography] { 136 | var typographies: [Typography] = [.systemButton, .systemLabel] 137 | #if !os(tvOS) 138 | typographies.append(.system) 139 | typographies.append(.smallSystem) 140 | #endif 141 | return typographies 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/UIKit/NSParagraphStyle+lineSpacingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSParagraphStyle+lineSpacingTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 8/23/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class NSParagraphStyleLineSpacingTests: XCTestCase { 12 | func testStyleWithLineSpacing() { 13 | let (sut, _, spacings, _, _) = makeSUT() 14 | spacings.forEach { 15 | XCTAssertEqual(sut.styleWithLineSpacing($0).lineSpacing, $0) 16 | XCTAssertEqual(NSParagraphStyle.default.styleWithLineSpacing($0).lineSpacing, $0) 17 | XCTAssertEqual(NonMutableParagraphStyle().styleWithLineSpacing($0).lineSpacing, $0) 18 | } 19 | } 20 | 21 | func testStyleWithLineHeightMultiple() { 22 | let (sut, _, _, multiples, _) = makeSUT() 23 | multiples.forEach { 24 | XCTAssertEqual(sut.styleWithLineHeightMultiple($0).lineHeightMultiple, $0) 25 | XCTAssertEqual(NSParagraphStyle.default.styleWithLineHeightMultiple($0).lineHeightMultiple, $0) 26 | XCTAssertEqual(NonMutableParagraphStyle().styleWithLineHeightMultiple($0).lineHeightMultiple, $0) 27 | } 28 | } 29 | 30 | func testStyleWithAlignment() { 31 | let (sut, alignments, _, _, _) = makeSUT() 32 | alignments.forEach { 33 | XCTAssertEqual(sut.styleWithAlignment($0).alignment, $0) 34 | XCTAssertEqual(NSParagraphStyle.default.styleWithAlignment($0).alignment, $0) 35 | XCTAssertEqual(NonMutableParagraphStyle().styleWithAlignment($0).alignment, $0) 36 | } 37 | } 38 | 39 | func testStyleWithLineHeight() { 40 | let (sut, _, _, _, lineHeights) = makeSUT() 41 | lineHeights.forEach { 42 | let indent = CGFloat(Int.random(in: 1..<10)) 43 | let spacing = CGFloat(Int.random(in: 10..<32)) 44 | let style1 = sut.styleWithLineHeight($0, indent: indent, spacing: spacing) 45 | let style2 = NSParagraphStyle.default.styleWithLineHeight($0, indent: indent) 46 | let style3 = NonMutableParagraphStyle().styleWithLineHeight($0, spacing: spacing) 47 | 48 | for style in [style1, style2, style3] { 49 | XCTAssertEqual(style.minimumLineHeight, $0) 50 | XCTAssertEqual(style.maximumLineHeight, $0) 51 | } 52 | 53 | XCTAssertEqual(style1.firstLineHeadIndent, indent) 54 | XCTAssertEqual(style2.firstLineHeadIndent, indent) 55 | XCTAssertEqual(style3.firstLineHeadIndent, .zero) 56 | 57 | XCTAssertEqual(style1.paragraphSpacing, spacing) 58 | XCTAssertEqual(style2.paragraphSpacing, .zero) 59 | XCTAssertEqual(style3.paragraphSpacing, spacing) 60 | } 61 | } 62 | } 63 | 64 | // We use large tuples in makeSUT() 65 | // swiftlint:disable large_tuple 66 | 67 | private extension NSParagraphStyleLineSpacingTests { 68 | func makeSUT( 69 | file: StaticString = #filePath, 70 | line: UInt = #line 71 | ) -> (NSParagraphStyle, [NSTextAlignment], [CGFloat], [CGFloat], [CGFloat]) { 72 | let style = generateStyle() 73 | let alignments: [NSTextAlignment] = [ 74 | .left, 75 | .center, 76 | .right, 77 | .justified, 78 | .natural 79 | ] 80 | let spacings: [CGFloat] = [ 81 | -2.0, 82 | 0.0, 83 | 1.0, 84 | 2.0, 85 | 7.5, 86 | 16.0 87 | ] 88 | let multiples: [CGFloat] = [ 89 | 0.81, 90 | 0.94, 91 | 1.00, 92 | 1.07, 93 | 1.50, 94 | 2.00 95 | ] 96 | let lineHeights: [CGFloat] = [ 97 | 12, 98 | 14, 99 | 16, 100 | 18, 101 | 24, 102 | 28, 103 | 32, 104 | 64 105 | ] 106 | 107 | trackForMemoryLeak(style, file: file, line: line) 108 | return (style, alignments, spacings, multiples, lineHeights) 109 | } 110 | 111 | func generateStyle() -> NSParagraphStyle { 112 | let style = NSMutableParagraphStyle() 113 | style.lineSpacing = 3 114 | style.lineHeightMultiple = 0.94 115 | style.minimumLineHeight = 16 116 | style.maximumLineHeight = 16 117 | style.alignment = .center 118 | style.headIndent = 16 119 | style.tailIndent = 16 120 | style.paragraphSpacing = 12 121 | style.lineBreakMode = .byWordWrapping 122 | style.lineBreakStrategy = .pushOut 123 | return style 124 | } 125 | } 126 | 127 | // This tests the ternary fallback operator of the following line in NSParagraphStyle+lineSpacing.swift: 128 | // let paragraphStyle = (mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() 129 | private final class NonMutableParagraphStyle: NSParagraphStyle { 130 | override func mutableCopy() -> Any { 131 | // Don't return NSMutableParagraphStyle 132 | return self 133 | } 134 | } 135 | // swiftlint:enable large_tuple 136 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/UIKit/UIFont+registerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIFont+registerTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 5/25/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class UIFontRegisterTests: XCTestCase { 13 | func testRegisterUnregister() throws { 14 | try UIFont.register(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 15 | try UIFont.unregister(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 16 | } 17 | 18 | func testRegisterFailure() { 19 | _testRegisterFailure(name: "Not-A-Font-File", fileExtension: fileExtension, subpath: subpath, bundle: bundle) 20 | _testRegisterFailure(name: fileName, fileExtension: "badext", subpath: subpath, bundle: bundle) 21 | _testRegisterFailure(name: fileName, fileExtension: fileExtension, subpath: "Bad/Path", bundle: bundle) 22 | _testRegisterFailure(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: .main) 23 | } 24 | 25 | func testRegisterTwice() throws { 26 | // Given a registered font 27 | try UIFont.register(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 28 | // We expect second call to fail because font is already registered 29 | _testRegisterFailure(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 30 | 31 | // Clean up 32 | try UIFont.unregister(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 33 | } 34 | 35 | func testUnregisterFailure() { 36 | _testUnregisterFailure(name: "Not-A-Font-File", fileExtension: fileExtension, subpath: subpath, bundle: bundle) 37 | _testUnregisterFailure(name: fileName, fileExtension: "badext", subpath: subpath, bundle: bundle) 38 | _testUnregisterFailure(name: fileName, fileExtension: fileExtension, subpath: "Bad/Path", bundle: bundle) 39 | _testUnregisterFailure(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: .main) 40 | } 41 | 42 | func testUnregisterTwice() throws { 43 | // We expect initial call to fail because font was never registered 44 | _testUnregisterFailure(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 45 | 46 | // Given a registered font 47 | try UIFont.register(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 48 | // that is then unregistered 49 | try UIFont.unregister(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 50 | // We expect second call to fail because font is already unregistered 51 | _testUnregisterFailure(name: fileName, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 52 | } 53 | } 54 | 55 | private extension UIFontRegisterTests { 56 | var fileName: String { "NotoSans-Regular" } 57 | var fileExtension: String { "ttf" } 58 | var subpath: String { "Assets/Fonts" } 59 | var bundle: Bundle { .module } 60 | 61 | func _testRegisterFailure(name: String, fileExtension: String?, subpath: String?, bundle: Bundle) { 62 | do { 63 | try UIFont.register(name: name, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 64 | XCTFail("Expected register to throw") 65 | } catch { 66 | // We expect an error 67 | } 68 | } 69 | 70 | func _testUnregisterFailure(name: String, fileExtension: String?, subpath: String?, bundle: Bundle) { 71 | do { 72 | try UIFont.unregister(name: name, fileExtension: fileExtension, subpath: subpath, bundle: bundle) 73 | XCTFail("Expected unregister to throw") 74 | } catch { 75 | // We expect an error 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/UIKit/UITraitCollection+breakpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITraitCollection+breakpointTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/18/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class UITraitCollectionBreakpointTests: XCTestCase { 12 | // Sample starting values for horizontal and vertical size classes 13 | // (typical for phone in portrait mode) 14 | private let startingTraits = UITraitCollection.startingBreakpointTraits 15 | 16 | func testSelf() { 17 | let (defaultTraits, sameTraits, differentTraits) = makeSUT() 18 | // no trait collection should have a different breakpoint to itself 19 | XCTAssertFalse(defaultTraits.hasDifferentBreakpoint(comparedTo: defaultTraits)) 20 | 21 | sameTraits.forEach { 22 | XCTAssertFalse($0.hasDifferentBreakpoint(comparedTo: $0)) 23 | } 24 | 25 | differentTraits.forEach { 26 | XCTAssertFalse($0.hasDifferentBreakpoint(comparedTo: $0)) 27 | } 28 | } 29 | 30 | func testSameTraits() { 31 | let (defaultTraits, sameTraits, _) = makeSUT() 32 | // Traits that do not affect breakpoint should not be considered different 33 | sameTraits.forEach { 34 | XCTAssertFalse(defaultTraits.hasDifferentBreakpoint(comparedTo: $0)) 35 | XCTAssertFalse($0.hasDifferentBreakpoint(comparedTo: defaultTraits)) 36 | } 37 | 38 | let allSameTraits = UITraitCollection(traitsFrom: sameTraits) 39 | XCTAssertFalse(defaultTraits.hasDifferentBreakpoint(comparedTo: allSameTraits)) 40 | XCTAssertFalse(allSameTraits.hasDifferentBreakpoint(comparedTo: defaultTraits)) 41 | } 42 | 43 | func testDifferentTraits() { 44 | let (defaultTraits, _, differentTraits) = makeSUT() 45 | // Traits that do affect breakpoint should be considered different 46 | differentTraits.forEach { 47 | XCTAssertTrue(defaultTraits.hasDifferentBreakpoint(comparedTo: $0)) 48 | XCTAssertTrue($0.hasDifferentBreakpoint(comparedTo: defaultTraits)) 49 | } 50 | } 51 | } 52 | 53 | // We use large tuples in makeSUT() 54 | // swiftlint:disable large_tuple 55 | 56 | private extension UITraitCollectionBreakpointTests { 57 | func makeSUT( 58 | file: StaticString = #filePath, 59 | line: UInt = #line 60 | ) -> (UITraitCollection, [UITraitCollection], [UITraitCollection]) { 61 | let defaultTraits = generateDefaultTraits() 62 | let sameTraits = generateSimilarTraits() 63 | let differentTraits = UITraitCollection.generateDifferentBreakpointTraits() 64 | 65 | trackForMemoryLeak(defaultTraits, file: file, line: line) 66 | sameTraits.forEach { trackForMemoryLeak($0, file: file, line: line) } 67 | 68 | return (defaultTraits, sameTraits, differentTraits) 69 | } 70 | 71 | func generateDefaultTraits() -> UITraitCollection { 72 | UITraitCollection(traitsFrom: [ 73 | startingTraits, 74 | UITraitCollection(userInterfaceIdiom: .phone), 75 | UITraitCollection(userInterfaceStyle: .light), 76 | UITraitCollection(accessibilityContrast: .normal), 77 | UITraitCollection(legibilityWeight: .regular), 78 | UITraitCollection(preferredContentSizeCategory: .large) 79 | ]) 80 | } 81 | 82 | // Traits affecting a variety of things but not traits that could affect breakpoints 83 | func generateSimilarTraits() -> [UITraitCollection] { 84 | // return each of these traits combined with startingTraits 85 | return [ 86 | UITraitCollection(userInterfaceIdiom: .pad), 87 | UITraitCollection(userInterfaceStyle: .dark), 88 | UITraitCollection(accessibilityContrast: .high), 89 | UITraitCollection(legibilityWeight: .bold), 90 | UITraitCollection(preferredContentSizeCategory: .extraLarge) 91 | ].map({ UITraitCollection(traitsFrom: [startingTraits, $0]) }) 92 | } 93 | } 94 | // swiftlint:enable large_tuple 95 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Extensions/UIKit/UITraitCollection+fontAppearanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITraitCollection+fontAppearanceTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 8/12/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | final class UITraitCollectionFontAppearanceTests: XCTestCase { 12 | func testSelf() { 13 | let (defaultTraits, sameTraits, differentTraits) = makeSUT() 14 | // no trait collection should have a different font appearance to itself 15 | XCTAssertFalse(defaultTraits.hasDifferentFontAppearance(comparedTo: defaultTraits)) 16 | 17 | sameTraits.forEach { 18 | XCTAssertFalse($0.hasDifferentFontAppearance(comparedTo: $0)) 19 | } 20 | 21 | differentTraits.forEach { 22 | XCTAssertFalse($0.hasDifferentFontAppearance(comparedTo: $0)) 23 | } 24 | } 25 | 26 | func testSameTraits() { 27 | let (defaultTraits, sameTraits, _) = makeSUT() 28 | // Traits that do not affect font appearance should not be considered different 29 | sameTraits.forEach { 30 | XCTAssertFalse(defaultTraits.hasDifferentFontAppearance(comparedTo: $0)) 31 | XCTAssertFalse($0.hasDifferentFontAppearance(comparedTo: defaultTraits)) 32 | } 33 | 34 | let allSameTraits = UITraitCollection(traitsFrom: sameTraits) 35 | XCTAssertFalse(defaultTraits.hasDifferentFontAppearance(comparedTo: allSameTraits)) 36 | XCTAssertFalse(allSameTraits.hasDifferentFontAppearance(comparedTo: defaultTraits)) 37 | } 38 | 39 | func testDifferentTraits() { 40 | let (defaultTraits, _, differentTraits) = makeSUT() 41 | // Traits that do affect font appearance should be considered different 42 | differentTraits.forEach { 43 | XCTAssertTrue(defaultTraits.hasDifferentFontAppearance(comparedTo: $0)) 44 | XCTAssertTrue($0.hasDifferentFontAppearance(comparedTo: defaultTraits)) 45 | } 46 | } 47 | } 48 | 49 | // We use large tuples in makeSUT() 50 | // swiftlint:disable large_tuple 51 | 52 | private extension UITraitCollectionFontAppearanceTests { 53 | func makeSUT( 54 | file: StaticString = #filePath, 55 | line: UInt = #line 56 | ) -> (UITraitCollection, [UITraitCollection], [UITraitCollection]) { 57 | let defaultTraits = generateDefaultTraits() 58 | let sameTraits = generateSimilarTraits() 59 | let differentTraits = UITraitCollection.generateDifferentFontTraits() 60 | 61 | trackForMemoryLeak(defaultTraits, file: file, line: line) 62 | sameTraits.forEach { trackForMemoryLeak($0, file: file, line: line) } 63 | 64 | return (defaultTraits, sameTraits, differentTraits) 65 | } 66 | 67 | func generateDefaultTraits() -> UITraitCollection { 68 | UITraitCollection(traitsFrom: [ 69 | .default, 70 | UITraitCollection(horizontalSizeClass: .regular), 71 | UITraitCollection(verticalSizeClass: .regular), 72 | UITraitCollection(userInterfaceIdiom: .phone), 73 | UITraitCollection(userInterfaceStyle: .light), 74 | UITraitCollection(accessibilityContrast: .normal) 75 | ]) 76 | } 77 | 78 | // Traits affecting a variety of things but not Dynamic Type Size or Bold Text 79 | func generateSimilarTraits() -> [UITraitCollection] { 80 | // return each of these traits combined with startingTraits 81 | return [ 82 | UITraitCollection(horizontalSizeClass: .compact), 83 | UITraitCollection(verticalSizeClass: .compact), 84 | UITraitCollection(userInterfaceIdiom: .pad), 85 | UITraitCollection(userInterfaceStyle: .dark), 86 | UITraitCollection(accessibilityContrast: .high) 87 | ].map({ UITraitCollection(traitsFrom: [.default, $0]) }) 88 | } 89 | } 90 | // swiftlint:enable large_tuple 91 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/CGFloat+rounded.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGFloat+rounded.swift 3 | // YMatterType 4 | // 5 | // Created by Mark Pospesel on 8/10/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension CGFloat { 12 | /// Rounds float to 1/scale, e.g. 0.5 on 2x scale, or 0.333 on 3x scale. 13 | /// Useful for pixel-perfect view alignment or drawing. 14 | /// - Parameters: 15 | /// - rule: rounding rule to apply, default is schoolbook rounding 16 | /// - scale: the scale to apply (e.g. 2.0 or 3.0) 17 | /// - Returns: the value rounded 1/scale 18 | public func rounded( 19 | _ rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero, 20 | scale: CGFloat 21 | ) -> CGFloat { 22 | (self * scale).rounded(rule) / scale 23 | } 24 | 25 | /// Floors float to 1/scale, e.g. round down to nearest 0.5 on 2x scale, or 0.333 on 3x scale. 26 | /// Useful for calculating pixel-aligned origins (left, top). 27 | /// - Parameter scale: the scale to apply, default is current screen scale 28 | /// - Returns: the value floored to 1/scale 29 | public func floored(scale: CGFloat = UIScreen.main.scale) -> CGFloat { 30 | rounded(.down, scale: scale) 31 | } 32 | 33 | /// Ceils float to 1/scale, e.g. round up to nearest 0.5 on 2x scale, or 0.333 on 3x scale. 34 | /// Useful for calculating pixel-aligned width & height. 35 | /// - Parameter scale: the scale to apply, default is current screen scale 36 | /// - Returns: the value ceiled to 1/scale 37 | public func ceiled(scale: CGFloat = UIScreen.main.scale) -> CGFloat { 38 | rounded(.up, scale: scale) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/NSAttributedString+hasAttribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+hasAttribute.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 4/26/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSAttributedString { 12 | /// Checks whether an attributed string has an attribute applied to its entire range 13 | /// - Parameter key: the attribute to check for 14 | /// - Returns: returns true if the attributed string contains this attribute, otherwise false 15 | func hasUniversalAttribute(key: NSAttributedString.Key) -> Bool { 16 | var hasAttribute = false 17 | let rangeAll = NSRange(location: 0, length: length) 18 | enumerateAttribute(key, in: rangeAll, options: []) { value, range, _ in 19 | if range == rangeAll && value != nil { 20 | hasAttribute = true 21 | } 22 | } 23 | return hasAttribute 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/Typography+YMatterTypeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+YMatterTypeTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 12/8/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import YMatterType 10 | 11 | extension Typography { 12 | #if !os(tvOS) 13 | /// SF Pro Display, Semibold 32/36 pts 14 | static let largeTitle = Typography( 15 | fontFamily: Typography.systemFamily, 16 | fontWeight: .semibold, 17 | fontSize: 32, 18 | lineHeight: 36, 19 | textStyle: .largeTitle 20 | ) 21 | #endif 22 | 23 | /// SF Pro Display, Semibold 28/34 pts 24 | static let title1 = Typography( 25 | fontFamily: Typography.systemFamily, 26 | fontWeight: .semibold, 27 | fontSize: 28, 28 | lineHeight: 34, 29 | textStyle: .title1 30 | ) 31 | 32 | /// SF Pro Display, Semibold 22/28 pts 33 | static let title2 = Typography( 34 | fontFamily: Typography.systemFamily, 35 | fontWeight: .semibold, 36 | fontSize: 22, 37 | lineHeight: 28, 38 | textStyle: .title2 39 | ) 40 | 41 | /// SF Pro Text, Semibold 17/22 pts (underlined) 42 | static let headline = Typography( 43 | fontFamily: Typography.systemFamily, 44 | fontWeight: .semibold, 45 | fontSize: 17, 46 | lineHeight: 22, 47 | letterSpacing: 0.2, 48 | textDecoration: .underline, 49 | textStyle: .headline 50 | ) 51 | 52 | /// SF Pro Text, Regular 16/22 pts 53 | static let subhead = Typography( 54 | fontFamily: Typography.systemFamily, 55 | fontWeight: .regular, 56 | fontSize: 16, 57 | lineHeight: 22, 58 | letterSpacing: 0.4, 59 | textCase: .uppercase, 60 | textStyle: .subheadline 61 | ) 62 | 63 | /// SF Pro Text, Regular 15/20 pts 64 | static let body = Typography( 65 | fontFamily: Typography.systemFamily, 66 | fontWeight: .regular, 67 | fontSize: 15, 68 | lineHeight: 20, 69 | paragraphIndent: 8, 70 | paragraphSpacing: 10, 71 | textStyle: .body 72 | ) 73 | 74 | /// SF Pro Text, Regular 14/20 pts 75 | static let smallBody = Typography( 76 | fontFamily: Typography.systemFamily, 77 | fontWeight: .regular, 78 | fontSize: 14, 79 | lineHeight: 20, 80 | paragraphIndent: 8, 81 | paragraphSpacing: 8, 82 | textStyle: .callout 83 | ) 84 | 85 | /// SF Pro Text, Bold 15/20 pts 86 | static let bodyBold = Typography.body.bold 87 | 88 | /// SF Pro Text, Bold 14/20 pts 89 | static let smallBodyBold = Typography.smallBody.bold 90 | } 91 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/UIEdgeInsets+vertical.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIEdgeInsets+vertical.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 8/11/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIEdgeInsets { 12 | /// Sum of top and bottom insets 13 | public var vertical: CGFloat { top + bottom } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/UIFont+load.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIFont+load.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 5/24/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import YMatterType 11 | 12 | extension UIFont { 13 | static func register(name: String) throws { 14 | try UIFont.register(name: name, fileExtension: "ttf", subpath: "Assets/Fonts", bundle: .module) 15 | } 16 | 17 | static func unregister(name: String) throws { 18 | try UIFont.unregister(name: name, fileExtension: "ttf", subpath: "Assets/Fonts", bundle: .module) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/UITraitCollection+default.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITraitCollection+default.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 4/26/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UITraitCollection { 12 | static var `default` = UITraitCollection(traitsFrom: [ 13 | UITraitCollection(preferredContentSizeCategory: .large), 14 | UITraitCollection(legibilityWeight: .regular) 15 | ]) 16 | } 17 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/XCTestCase+MemoryLeakTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+MemoryLeakTracking.swift 3 | // YCoreUITests 4 | // 5 | // Created by Karthik K Manoj on 08/04/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCTestCase { 12 | func trackForMemoryLeak(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) { 13 | addTeardownBlock { [weak instance] in 14 | XCTAssertNil( 15 | instance, 16 | "Instance should have been deallocated. Potential memory leak.", 17 | file: file, 18 | line: line 19 | ) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Helpers/XCTestCase+TypographyEquatable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+TypographyEquatable.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/21/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | extension XCTestCase { 13 | /// Compares two typographies and asserts if they are not equal. 14 | /// 15 | /// `Typography` does not conform to `Equatable` because `fontFamily` is just a protocol 16 | /// that is itself not `Equatable`. We can however compare some properties of `FontFamily` as 17 | /// well as all the other properties of `Typography`. 18 | /// - Parameters: 19 | /// - lhs: the first typography to compare 20 | /// - rh2: the second typography to compare 21 | func XCTAssertTypographyEqual(_ lhs: Typography, _ rh2: Typography) { 22 | XCTAssertEqual(lhs.fontFamily.familyName, rh2.fontFamily.familyName) 23 | XCTAssertEqual(lhs.fontFamily.fontNameSuffix, rh2.fontFamily.fontNameSuffix) 24 | XCTAssertEqual(lhs.fontFamily.supportedWeights, rh2.fontFamily.supportedWeights) 25 | XCTAssertEqual(lhs.fontWeight, rh2.fontWeight) 26 | XCTAssertEqual(lhs.fontSize, rh2.fontSize) 27 | XCTAssertEqual(lhs.lineHeight, rh2.lineHeight) 28 | XCTAssertEqual(lhs.letterSpacing, rh2.letterSpacing) 29 | XCTAssertEqual(lhs.paragraphIndent, rh2.paragraphIndent) 30 | XCTAssertEqual(lhs.paragraphSpacing, rh2.paragraphSpacing) 31 | XCTAssertEqual(lhs.textDecoration, rh2.textDecoration) 32 | XCTAssertEqual(lhs.textStyle, rh2.textStyle) 33 | XCTAssertEqual(lhs.isFixed, rh2.isFixed) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/FontFamily/DefaultFontFamilyFactoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultFontFamilyFactoryTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 9/21/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class DefaultFontFamilyFactoryTests: XCTestCase { 13 | func testGetFontFamily() { 14 | // Given 15 | let sut = DefaultFontFamilyFactory() 16 | 17 | // When 18 | let regular = sut.getFontFamily(familyName: "Foo", style: .regular) 19 | let italic = sut.getFontFamily(familyName: "Bar", style: .italic) 20 | let helveticaNeue = sut.getFontFamily(familyName: "HelveticaNeue", style: .regular) 21 | 22 | // Then 23 | XCTAssertTrue(regular is DefaultFontFamily) 24 | XCTAssertEqual(regular.familyName, "Foo") 25 | XCTAssertEqual(regular.fontNameSuffix, "") 26 | XCTAssertTrue(italic is DefaultFontFamily) 27 | XCTAssertEqual(italic.familyName, "Bar") 28 | XCTAssertEqual(italic.fontNameSuffix, "Italic") 29 | XCTAssertEqual(helveticaNeue.familyName, "HelveticaNeue") 30 | XCTAssertEqual(helveticaNeue.weightName(for: .regular), "") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/FontFamily/DefaultFontFamilyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultFontFamilyTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 9/21/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class DefaultFontFamilyTests: XCTestCase { 13 | func testFontNameSuffix() { 14 | // Given 15 | let sut = DefaultFontFamily(familyName: "Mock", style: .regular) 16 | let sutItalic = DefaultFontFamily(familyName: "Mock", style: .italic) 17 | 18 | // Then 19 | XCTAssertEqual(sut.fontNameSuffix, "") 20 | XCTAssertEqual(sutItalic.fontNameSuffix, "Italic") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/FontFamily/FontFamilyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontFamilyTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 8/24/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class FontFamilyTests: XCTestCase { 13 | func testFontName() { 14 | let (sut, weightNames, traitCollection) = makeSUT() 15 | for weight in Typography.FontWeight.allCases { 16 | guard let weightName = weightNames[weight] else { 17 | XCTFail("No name associated with weight") 18 | continue 19 | } 20 | let expected = "\(sut.familyName)-\(weightName)" 21 | XCTAssertEqual(sut.fontName(for: weight, compatibleWith: traitCollection), expected) 22 | } 23 | } 24 | 25 | func testWeightName() { 26 | let (sut, weightNames, _) = makeSUT() 27 | Typography.FontWeight.allCases.forEach { 28 | let expected = weightNames[$0] 29 | XCTAssertNotNil(expected) 30 | XCTAssertEqual(sut.weightName(for: $0), expected) 31 | } 32 | } 33 | 34 | func testA11yBoldWeight() { 35 | let (sut, _, _) = makeSUT() 36 | Typography.FontWeight.allCases.forEach { 37 | let expectedWeight = min($0.rawValue + 100, 900) 38 | XCTAssertEqual(sut.accessibilityBoldWeight(for: $0).rawValue, expectedWeight) 39 | } 40 | } 41 | 42 | func testSupportedWeights() { 43 | let sut = MockFontFamily() 44 | sut.supportedWeights = [.light, .semibold, .heavy] 45 | 46 | // Given any weight, we expect it to return the next heavier supported weight 47 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .ultralight), .light) 48 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .thin), .light) 49 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .light), .semibold) 50 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .regular), .semibold) 51 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .medium), .semibold) 52 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .semibold), .heavy) 53 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .bold), .heavy) 54 | 55 | // If there is no heavier weight, then we expect it to return the heaviest weight 56 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .heavy), .heavy) 57 | XCTAssertEqual(sut.accessibilityBoldWeight(for: .black), .heavy) 58 | 59 | // Given no supported weights we expect it to return the weight passed in 60 | sut.supportedWeights = [] 61 | Typography.FontWeight.allCases.forEach { 62 | XCTAssertEqual(sut.accessibilityBoldWeight(for: $0), $0) 63 | } 64 | } 65 | 66 | func testCompatibleTraitCollection() { 67 | let (sut, _, _) = makeSUT() 68 | let regular = "Regular" 69 | let bold = "Medium" 70 | 71 | let testCases: [(traits: UITraitCollection?, suffix: String)] = [ 72 | (UITraitCollection(legibilityWeight: .regular), regular), 73 | (UITraitCollection(legibilityWeight: .unspecified), regular), 74 | (UITraitCollection(legibilityWeight: .bold), bold), 75 | (nil, UIAccessibility.isBoldTextEnabled ? bold : regular) 76 | ] 77 | 78 | testCases.forEach { 79 | XCTAssert(sut.fontName(for: .regular, compatibleWith: $0.traits).hasSuffix($0.suffix)) 80 | } 81 | } 82 | 83 | func testFallback() { 84 | // This isn't a real font, so we expect it to fallback to the system font 85 | let (sut, _, _) = makeSUT() 86 | let font = sut.font(for: .regular, pointSize: 16, compatibleWith: .default) 87 | let systemFont = Typography.systemFamily.font(for: .regular, pointSize: 16, compatibleWith: .default) 88 | XCTAssertEqual(font.familyName, systemFont.familyName) 89 | XCTAssertEqual(font.fontName, systemFont.fontName) 90 | XCTAssertEqual(font.pointSize, systemFont.pointSize) 91 | XCTAssertEqual(font.lineHeight, systemFont.lineHeight) 92 | } 93 | 94 | func testIsBoldTextEnabled() { 95 | let (sut, _, _) = makeSUT() 96 | 97 | // given a traitCollection with legibilityWeight == .bold, it should return `true` 98 | XCTAssertTrue(sut.isBoldTextEnabled(compatibleWith: UITraitCollection(legibilityWeight: .bold))) 99 | XCTAssertTrue(sut.isBoldTextEnabled(compatibleWith: UITraitCollection(traitsFrom: [ 100 | UITraitCollection(preferredContentSizeCategory: .extraLarge), 101 | UITraitCollection(legibilityWeight: .bold) 102 | ]))) 103 | 104 | // given a traitCollection with legibilityWeight != .bold, it should return `false` 105 | XCTAssertFalse(sut.isBoldTextEnabled(compatibleWith: UITraitCollection(legibilityWeight: .regular))) 106 | XCTAssertFalse(sut.isBoldTextEnabled(compatibleWith: UITraitCollection(legibilityWeight: .unspecified))) 107 | 108 | // given a traitCollection without legibilityWeight trait, it should return `false` 109 | XCTAssertFalse(sut.isBoldTextEnabled(compatibleWith: UITraitCollection())) 110 | XCTAssertFalse(sut.isBoldTextEnabled(compatibleWith: UITraitCollection(preferredContentSizeCategory: .small))) 111 | 112 | // given traitCollection is nil, it should return the system setting 113 | XCTAssertEqual(sut.isBoldTextEnabled(compatibleWith: nil), UIAccessibility.isBoldTextEnabled) 114 | } 115 | 116 | func test_fontNameWithoutWeightOrSuffix_isFamilyName() { 117 | let sut = WeightlessFontFamily() 118 | var name: String 119 | 120 | // If there's a weight name then we will append it 121 | sut.weightName = "Regular" 122 | name = sut.fontName(for: .regular, compatibleWith: nil) 123 | XCTAssertNotEqual(name, sut.familyName) 124 | XCTAssertTrue(name.hasSuffix("-" + sut.weightName)) 125 | 126 | // If there's a suffix then we will append it 127 | sut.weightName = "" 128 | sut.fontNameSuffix = "Italic" 129 | name = sut.fontName(for: .regular, compatibleWith: nil) 130 | XCTAssertNotEqual(name, sut.familyName) 131 | XCTAssertTrue(name.hasSuffix("-" + sut.fontNameSuffix)) 132 | 133 | // If there's neither then we just return the family name 134 | sut.fontNameSuffix = "" 135 | name = sut.fontName(for: .regular, compatibleWith: nil) 136 | XCTAssertEqual(name, sut.familyName) 137 | XCTAssertNil(name.firstIndex(of: "-")) 138 | } 139 | } 140 | 141 | // We use large tuples in makeSUT() 142 | // swiftlint:disable large_tuple 143 | 144 | private extension FontFamilyTests { 145 | func makeSUT( 146 | file: StaticString = #filePath, 147 | line: UInt = #line 148 | ) -> (FontFamily, [Typography.FontWeight: String], UITraitCollection) { 149 | let sut = MockFontFamily() 150 | let weightNames: [Typography.FontWeight: String] = [ 151 | .ultralight: "ExtraLight", 152 | .thin: "Thin", 153 | .light: "Light", 154 | .regular: "Regular", 155 | .medium: "Medium", 156 | .semibold: "SemiBold", 157 | .bold: "Bold", 158 | .heavy: "ExtraBold", 159 | .black: "Black" 160 | ] 161 | let traitCollection: UITraitCollection = .default 162 | 163 | trackForMemoryLeak(sut, file: file, line: line) 164 | return (sut, weightNames, traitCollection) 165 | } 166 | } 167 | 168 | final class MockFontFamily: FontFamily { 169 | let familyName: String = "MockSerifMono" 170 | 171 | var supportedWeights: [Typography.FontWeight] = Typography.FontWeight.allCases 172 | } 173 | 174 | final class WeightlessFontFamily: FontFamily { 175 | let familyName: String = "Weightless" 176 | var weightName: String = "" 177 | var fontNameSuffix: String = "" 178 | 179 | func weightName(for weight: Typography.FontWeight) -> String { weightName } 180 | } 181 | // swiftlint:enable large_tuple 182 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/FontFamily/HelveticaNeueFontFamilyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HelveticaNeueFontFamilyTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/2/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class HelveticaNeueFontFamilyTests: XCTestCase { 13 | func testFont() { 14 | let (families, traitCollections) = makeSUT() 15 | for family in families { 16 | for weight in family.supportedWeights { 17 | for traitCollection in traitCollections { 18 | let pointSize = CGFloat(Int.random(in: 10...32)) 19 | let font = family.font(for: weight, pointSize: pointSize, compatibleWith: traitCollection) 20 | XCTAssertEqual(font.familyName, "Helvetica Neue") 21 | XCTAssertEqual(font.pointSize, pointSize) 22 | } 23 | } 24 | } 25 | } 26 | 27 | func testRegular() { 28 | let (families, _) = makeSUT() 29 | for family in families { 30 | XCTAssertTrue(family.weightName(for: .regular).isEmpty) 31 | } 32 | } 33 | 34 | func testUnsupportedWeights() { 35 | let (families, _) = makeSUT() 36 | for family in families { 37 | let unsupported = Typography.FontWeight.allCases.filter { !family.supportedWeights.contains($0) } 38 | for weight in unsupported { 39 | XCTAssertTrue(family.weightName(for: weight).isEmpty) 40 | } 41 | } 42 | } 43 | } 44 | 45 | private extension HelveticaNeueFontFamilyTests { 46 | func makeSUT() -> ([FontFamily], [UITraitCollection?]) { 47 | let families: [FontFamily] = [Typography.helveticaNeue, Typography.helveticaNeueItalic] 48 | let traitCollections: [UITraitCollection?] = [ 49 | nil, 50 | UITraitCollection(legibilityWeight: .unspecified), 51 | UITraitCollection(legibilityWeight: .regular), 52 | UITraitCollection(legibilityWeight: .bold) 53 | ] 54 | return (families, traitCollections) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/FontFamily/SystemFontFamilyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemFontFamilyTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 9/28/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class SystemFontFamilyTests: XCTestCase { 13 | func testFont() throws { 14 | let (sut, pointSizes, traitCollections) = makeSUT() 15 | let systemFont = UIFont.systemFont(ofSize: 12) 16 | 17 | for weight in Typography.FontWeight.allCases { 18 | for pointSize in pointSizes { 19 | for traitCollection in traitCollections { 20 | let font = sut.font(for: weight, pointSize: pointSize, compatibleWith: traitCollection) 21 | XCTAssertEqual(font.familyName, systemFont.familyName) 22 | XCTAssertEqual(font.pointSize, pointSize) 23 | } 24 | } 25 | } 26 | } 27 | 28 | func testFamilyName() { 29 | let (sut, _, _) = makeSUT() 30 | XCTAssertTrue(sut.familyName.isEmpty) 31 | } 32 | 33 | func test_accessibilityWeight_deliversCorrectWeight() { 34 | let (sut, _, _) = makeSUT() 35 | let lightWeights: [Typography.FontWeight] = [.ultralight, .thin, .light] 36 | let midWeights: [Typography.FontWeight] = [.regular, .medium, .semibold] 37 | let heavyWeights: [Typography.FontWeight] = [.bold, .heavy, .black] 38 | 39 | for weight in lightWeights { 40 | XCTAssertEqual(sut.accessibilityBoldWeight(for: weight).rawValue, weight.rawValue + 100) 41 | } 42 | 43 | for weight in midWeights { 44 | XCTAssertEqual(sut.accessibilityBoldWeight(for: weight).rawValue, weight.rawValue + 200) 45 | } 46 | 47 | for weight in heavyWeights { 48 | XCTAssertEqual(sut.accessibilityBoldWeight(for: weight), .black) 49 | } 50 | } 51 | 52 | func test_layout_deliversCorrectFont() throws { 53 | let (sut, _, traitCollections) = makeSUT() 54 | 55 | for weight in Typography.systemFamily.supportedWeights { 56 | for traitCollection in traitCollections { 57 | let typography = Typography(fontFamily: sut, fontWeight: weight, fontSize: 16, lineHeight: 24) 58 | let layoutDynamic = typography.generateLayout(compatibleWith: traitCollection) 59 | let layoutFixed = typography.fixed.generateLayout(compatibleWith: traitCollection) 60 | XCTAssertEqual(layoutDynamic.font.fontName, layoutFixed.font.fontName) 61 | } 62 | } 63 | } 64 | 65 | func test_layoutWithLegibilityWeightBold_deliversHeavierFont() throws { 66 | // Given 67 | let (sut, _, _) = makeSUT() 68 | let traitRegular = UITraitCollection(legibilityWeight: .regular) 69 | let traitBold = UITraitCollection(legibilityWeight: .bold) 70 | var weights = Typography.systemFamily.supportedWeights 71 | weights.removeLast() // don't consider .black because we cannot go heavier 72 | 73 | for weight in weights { 74 | // When 75 | let typography = Typography(fontFamily: sut, fontWeight: weight, fontSize: 16, lineHeight: 24) 76 | let layoutRegular = typography.generateLayout(compatibleWith: traitRegular) 77 | let layoutBold = typography.generateLayout(compatibleWith: traitBold) 78 | 79 | let weightRegular = try XCTUnwrap(self.weight(from: layoutRegular.font)) 80 | let weightBold = try XCTUnwrap(self.weight(from: layoutBold.font)) 81 | 82 | // Then 83 | XCTAssertGreaterThan(weightBold.rawValue, weightRegular.rawValue) 84 | } 85 | } 86 | } 87 | 88 | // We use large tuples in makeSUT() 89 | // swiftlint:disable large_tuple 90 | 91 | private extension SystemFontFamilyTests { 92 | func makeSUT() -> (FontFamily, [CGFloat], [UITraitCollection?]) { 93 | let sut = Typography.systemFamily 94 | let pointSizes: [CGFloat] = [10, 12, 14, 16, 18, 24, 28, 32] 95 | let traitCollections: [UITraitCollection?] = [ 96 | nil, 97 | UITraitCollection(legibilityWeight: .unspecified), 98 | UITraitCollection(legibilityWeight: .regular), 99 | UITraitCollection(legibilityWeight: .bold) 100 | ] 101 | return (sut, pointSizes, traitCollections) 102 | } 103 | 104 | func weight(from font: UIFont) -> Typography.FontWeight? { 105 | guard let weightString = font.fontName.components(separatedBy: "-").last else { 106 | return nil 107 | } 108 | 109 | switch weightString.lowercased(with: Locale(identifier: "en_US")) { 110 | case "ultralight", "extralight": 111 | return .ultralight 112 | case "thin": 113 | return .thin 114 | case "light": 115 | return .light 116 | case "regular": 117 | return .regular 118 | case "medium": 119 | return .medium 120 | case "semibold", "demibold": 121 | return .semibold 122 | case "bold": 123 | return .bold 124 | case "heavy", "extrabold": 125 | return .heavy 126 | case "black": 127 | return .black 128 | default: 129 | return nil 130 | } 131 | } 132 | } 133 | // swiftlint:enable large_tuple 134 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/TypogaphyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypogaphyTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 8/24/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class TypogaphyTests: XCTestCase { 13 | func testInit() { 14 | // test the default initializer 15 | let avenir = DefaultFontFamily(familyName: "AvenirNext") 16 | let typeInfo = Typography(fontFamily: avenir, fontWeight: .regular, fontSize: 16, lineHeight: 24) 17 | let layout = typeInfo.generateLayout(compatibleWith: .default) 18 | 19 | XCTAssertEqual(layout.font.familyName, "Avenir Next") 20 | _testDefaults(typeInfo) 21 | } 22 | 23 | func testInit2() { 24 | // test the convenience initializer 25 | let typeInfo = Typography(familyName: "AvenirNext", fontWeight: .regular, fontSize: 16, lineHeight: 24) 26 | let layout = typeInfo.generateLayout(compatibleWith: .default) 27 | 28 | XCTAssertEqual(layout.font.familyName, "Avenir Next") 29 | _testDefaults(typeInfo) 30 | } 31 | 32 | func testFactory() throws { 33 | // Given 34 | Typography.factory = NotoSansFactory() 35 | try UIFont.register(name: "NotoSans-Regular") 36 | addTeardownBlock { 37 | Typography.factory = DefaultFontFamilyFactory() 38 | try UIFont.unregister(name: "NotoSans-Regular") 39 | } 40 | 41 | // When 42 | let typeInfo = Typography(familyName: "AvenirNext", fontWeight: .regular, fontSize: 16, lineHeight: 24) 43 | let layout = typeInfo.generateLayout(compatibleWith: .default) 44 | 45 | // Then 46 | XCTAssertEqual(layout.font.familyName, "Noto Sans") 47 | } 48 | 49 | private func _testDefaults(_ typography: Typography) { 50 | // Confirm default init parameter values 51 | XCTAssertEqual(typography.letterSpacing, 0) 52 | XCTAssertEqual(typography.textStyle, UIFont.TextStyle.body) 53 | XCTAssertNil(typography.maximumScaleFactor) 54 | XCTAssertFalse(typography.isFixed) 55 | } 56 | } 57 | 58 | struct NotoSansFontFamily: FontFamily { 59 | let familyName = "NotoSans" 60 | 61 | var supportedWeights: [Typography.FontWeight] { [.regular] } 62 | } 63 | 64 | struct AvenirNextFontFamily: FontFamily { 65 | let familyName = "AvenirNext" 66 | 67 | var supportedWeights: [Typography.FontWeight] { [.ultralight, .regular, .medium, .semibold, .bold, .heavy] } 68 | 69 | func weightName(for weight: Typography.FontWeight) -> String { 70 | switch weight { 71 | case .ultralight: 72 | return "UltraLight" 73 | case .regular: 74 | return "Regular" 75 | case .medium: 76 | return "Medium" 77 | case .semibold: 78 | return "DemiBold" 79 | case .bold: 80 | return "Bold" 81 | case .heavy: 82 | return "Heavy" 83 | case .thin, .light, .black: 84 | return "" // these 3 weights are not installed 85 | } 86 | } 87 | } 88 | 89 | extension Typography { 90 | static let notoSans = NotoSansFontFamily() 91 | } 92 | 93 | struct NotoSansFactory: FontFamilyFactory { 94 | // Always returns Noto Sans font family 95 | func getFontFamily(familyName: String, style: Typography.FontStyle) -> FontFamily { 96 | Typography.notoSans 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/Typography+EnumTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+EnumTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 3/17/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class TypographyEnumTests: XCTestCase { 13 | func test_fontWeightInit_garbageReturnsNil() { 14 | ["", "nonsense", "garbage", "not a weight"].forEach { 15 | XCTAssertNil(Typography.FontWeight($0)) 16 | } 17 | } 18 | 19 | func test_fontWeightInit_isCaseInsensitive() { 20 | ["Regular", "regular", "REGULAR", "ReGuLaR"].forEach { 21 | XCTAssertEqual(Typography.FontWeight($0), .regular) 22 | } 23 | 24 | ["Semibold", "semibold", "SEMIBOLD", "SemiBold"].forEach { 25 | XCTAssertEqual(Typography.FontWeight($0), .semibold) 26 | } 27 | } 28 | 29 | func test_fontWeightInit_acceptsSynonyms() { 30 | ["ExtraLight", "UltraLight"].forEach { 31 | XCTAssertEqual(Typography.FontWeight($0), .ultralight) 32 | } 33 | 34 | ["SemiBold", "DemiBold"].forEach { 35 | XCTAssertEqual(Typography.FontWeight($0), .semibold) 36 | } 37 | 38 | ["Heavy", "ExtraBold", "UltraBold"].forEach { 39 | XCTAssertEqual(Typography.FontWeight($0), .heavy) 40 | } 41 | } 42 | 43 | func test_fontWeightInit_acceptsFontFamilyWeightNames() { 44 | let sut = DefaultFontFamily(familyName: "Any") 45 | 46 | for weight in sut.supportedWeights { 47 | let weightName = sut.weightName(for: weight) 48 | XCTAssertEqual(Typography.FontWeight(weightName), weight) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/Typography+MutatorsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+MutatorsTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 12/8/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class TypographyMutatorsTests: XCTestCase { 13 | private let types: [Typography] = { 14 | var styles: [Typography] = [ 15 | .title1, 16 | .title2, 17 | .headline, 18 | .subhead, 19 | .body, 20 | .bodyBold, 21 | .smallBody, 22 | .smallBodyBold 23 | ] 24 | #if !os(tvOS) 25 | styles.insert(.largeTitle, at: 0) 26 | #endif 27 | return styles 28 | }() 29 | 30 | func testFamilyName() { 31 | types.forEach { 32 | let familyName = "AppleSDGothicNeo" 33 | let typography = $0.familyName(familyName) 34 | _test(original: $0, modified: typography, familyName: familyName) 35 | let layout = typography.generateLayout(compatibleWith: UITraitCollection(legibilityWeight: .regular)) 36 | XCTAssertEqual(layout.font.familyName.removeSpaces(), familyName) 37 | } 38 | } 39 | 40 | func testRegular() { 41 | types.forEach { 42 | _test(original: $0, modified: $0.regular, weight: .regular) 43 | } 44 | } 45 | 46 | func testBold() { 47 | types.forEach { 48 | _test(original: $0, modified: $0.bold, weight: .bold) 49 | } 50 | } 51 | 52 | func testFontWeight() { 53 | types.forEach { 54 | for fontweight in Typography.FontWeight.allCases { 55 | _test(original: $0, modified: $0.fontWeight(fontweight), weight: fontweight) 56 | } 57 | } 58 | } 59 | 60 | func testFontSize() { 61 | types.forEach { 62 | let newFontSize = $0.fontSize + 1 63 | _test(original: $0, modified: $0.fontSize(newFontSize), fontSize: newFontSize) 64 | } 65 | } 66 | 67 | func testLineHeight() { 68 | types.forEach { 69 | let newLineHeight = $0.lineHeight + 2 70 | _test(original: $0, modified: $0.lineHeight(newLineHeight), lineHeight: newLineHeight) 71 | } 72 | } 73 | 74 | func testFixed() { 75 | types.forEach { 76 | _test(original: $0, modified: $0.fixed, isFixed: true) 77 | } 78 | } 79 | 80 | func testLetterSpacing() { 81 | types.forEach { 82 | for kerning in [-1.5, 0, 0.8] { 83 | _test(original: $0, modified: $0.letterSpacing(kerning), letterSpacing: kerning) 84 | } 85 | } 86 | } 87 | 88 | func testTextCase() { 89 | types.forEach { 90 | for textCase in Typography.TextCase.allCases { 91 | _test(original: $0, modified: $0.textCase(textCase), textCase: textCase) 92 | } 93 | } 94 | } 95 | 96 | func testTextDecoration() { 97 | types.forEach { 98 | for textDecoration in Typography.TextDecoration.allCases { 99 | _test(original: $0, modified: $0.decoration(textDecoration), textDecoration: textDecoration) 100 | } 101 | } 102 | } 103 | 104 | func testMaximumScaleFactor() { 105 | let factors: [CGFloat?] = [nil, 1.5, 2.0, 2.5] 106 | types.forEach { 107 | for factor in factors { 108 | _test(original: $0, modified: $0.maximumScaleFactor(factor), maximumScaleFactor: factor) 109 | } 110 | } 111 | } 112 | 113 | private func _test( 114 | original: Typography, 115 | modified: Typography, 116 | familyName: String? = nil, 117 | weight: Typography.FontWeight? = nil, 118 | fontSize: CGFloat? = nil, 119 | lineHeight: CGFloat? = nil, 120 | isFixed: Bool? = nil, 121 | letterSpacing: CGFloat? = nil, 122 | textCase: Typography.TextCase? = nil, 123 | textDecoration: Typography.TextDecoration? = nil, 124 | maximumScaleFactor: CGFloat? = nil 125 | ) { 126 | let familyName = familyName ?? original.fontFamily.familyName 127 | let weight = weight ?? original.fontWeight 128 | let fontSize = fontSize ?? original.fontSize 129 | let lineHeight = lineHeight ?? original.lineHeight 130 | let isFixed = isFixed ?? original.isFixed 131 | let kerning = letterSpacing ?? original.letterSpacing 132 | let textCase = textCase ?? original.textCase 133 | let textDecoration = textDecoration ?? original.textDecoration 134 | let maximumScaleFactor = maximumScaleFactor ?? original.maximumScaleFactor 135 | 136 | // familyName, fontWeight, fontSize, lineHeight, isFixed, 137 | // letterSpacing, textCase, and textDecoration should be as expected 138 | XCTAssertEqual(modified.fontFamily.familyName, familyName) 139 | XCTAssertEqual(modified.fontWeight, weight) 140 | XCTAssertEqual(modified.fontSize, fontSize) 141 | XCTAssertEqual(modified.lineHeight, lineHeight) 142 | XCTAssertEqual(modified.isFixed, isFixed) 143 | XCTAssertEqual(modified.letterSpacing, kerning) 144 | XCTAssertEqual(modified.textCase, textCase) 145 | XCTAssertEqual(modified.textDecoration, textDecoration) 146 | XCTAssertEqual(modified.maximumScaleFactor, maximumScaleFactor) 147 | 148 | // the other variables should be the same 149 | XCTAssertEqual(modified.textStyle, original.textStyle) 150 | XCTAssertEqual(modified.paragraphIndent, original.paragraphIndent) 151 | XCTAssertEqual(modified.paragraphSpacing, original.paragraphSpacing) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/Typography+SystemTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Typography+SystemTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 5/25/22. 6 | // Copyright © 2022 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class TypographySystemTests: XCTestCase { 13 | func testSystemLabel() { 14 | let sut = Typography.systemLabel 15 | XCTAssertEqual(sut.fontSize, Typography.labelFontSize) 16 | XCTAssertEqual(sut.fontWeight, Typography.labelFontWeight) 17 | _testLineHeight(sut: sut) 18 | } 19 | 20 | func testSystemButton() { 21 | let sut = Typography.systemButton 22 | XCTAssertEqual(sut.fontSize, Typography.buttonFontSize) 23 | XCTAssertEqual(sut.fontWeight, Typography.buttonFontWeight) 24 | _testLineHeight(sut: sut) 25 | } 26 | 27 | #if !os(tvOS) 28 | func testSystem() { 29 | let sut = Typography.system 30 | XCTAssertEqual(sut.fontSize, UIFont.systemFontSize) 31 | XCTAssertEqual(sut.fontWeight, .regular) 32 | _testLineHeight(sut: sut) 33 | } 34 | 35 | func testSmallSystem() { 36 | let sut = Typography.smallSystem 37 | XCTAssertEqual(sut.fontSize, UIFont.smallSystemFontSize) 38 | XCTAssertEqual(sut.fontWeight, .regular) 39 | _testLineHeight(sut: sut) 40 | } 41 | #endif 42 | 43 | private func _testLineHeight(sut: Typography) { 44 | XCTAssertEqual(sut.lineHeight, ceil(sut.fontSize * Typography.systemLineHeightMultiple)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/Typography/TypographyLayoutTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypographyLayoutTests.swift 3 | // YMatterTypeTests 4 | // 5 | // Created by Mark Pospesel on 8/27/21. 6 | // Copyright © 2021 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class TypographyLayoutTests: XCTestCase { 13 | func testLineHeightMultiple() { 14 | let sut = makeSUT() 15 | XCTAssertEqual(sut.lineHeightMultiple * sut.font.lineHeight, 20) 16 | } 17 | 18 | func testParagraphStyle() { 19 | let sut = makeSUT() 20 | XCTAssertEqual(sut.paragraphStyle.minimumLineHeight, sut.lineHeight) 21 | XCTAssertEqual(sut.paragraphStyle.maximumLineHeight, sut.lineHeight) 22 | } 23 | 24 | func testBaselineOffset() { 25 | let sut = makeSUT() 26 | // baselineOffset should be >= 0 27 | XCTAssertGreaterThanOrEqual(sut.baselineOffset, 0) 28 | // and it should be ceiled to 1/scale (pixel precision) 29 | XCTAssertEqual(sut.baselineOffset.ceiled(), sut.baselineOffset) 30 | } 31 | 32 | func testKerning() { 33 | let (text, attributedText) = makeText() 34 | 35 | [-1.6, 0, 2.4].forEach { 36 | let sut = makeSUT(letterSpacing: $0) 37 | XCTAssertEqual(sut.kerning, $0) 38 | 39 | _testKernAttribute(styled: sut.styleText(text, lineMode: .single), letterSpacing: $0) 40 | _testKernAttribute(styled: sut.styleAttributedText(attributedText, lineMode: .single), letterSpacing: $0) 41 | } 42 | } 43 | 44 | func testTextCase() { 45 | Typography.TextCase.allCases.forEach { 46 | let sut = makeSUT(textCase: $0) 47 | XCTAssertEqual(sut.textCase, $0) 48 | } 49 | } 50 | 51 | func testTextDecoration() { 52 | let (text, attributedText) = makeText() 53 | 54 | Typography.TextDecoration.allCases.forEach { 55 | let sut = makeSUT(textDecoration: $0) 56 | XCTAssertEqual(sut.textDecoration, $0) 57 | 58 | _testDecorationAttributes(styled: sut.styleText(text, lineMode: .single), textDecoration: $0) 59 | 60 | _testDecorationAttributes( 61 | styled: sut.styleAttributedText(attributedText, lineMode: .single), 62 | textDecoration: $0 63 | ) 64 | } 65 | } 66 | 67 | func testNeedsStylingForSingleLine() { 68 | for kerning in [-1.6, 0, 2.4] { 69 | for textCase in Typography.TextCase.allCases { 70 | for textDecoration in Typography.TextDecoration.allCases { 71 | let sut = makeSUT(letterSpacing: kerning, textCase: textCase, textDecoration: textDecoration) 72 | XCTAssertEqual( 73 | sut.needsStylingForSingleLine, 74 | kerning != 0 || textCase != .none || textDecoration != .none 75 | ) 76 | } 77 | } 78 | } 79 | } 80 | 81 | func testStyleText() { 82 | let sut = makeSUT() 83 | let (text, _) = makeText() 84 | 85 | // given text styled as single line 86 | var styled = sut.styleText(text, lineMode: .single) 87 | // we expect it to not have paragraph style 88 | XCTAssertNil(styled.attribute(.paragraphStyle, at: 0, effectiveRange: nil)) 89 | 90 | // but given text styled as multi line 91 | styled = sut.styleText(text, lineMode: .multi(alignment: .natural, lineBreakMode: .byWordWrapping)) 92 | // we expect it to have paragraph style 93 | XCTAssertNotNil(styled.attribute(.paragraphStyle, at: 0, effectiveRange: nil)) 94 | } 95 | 96 | func testStyleAttributedText() { 97 | let sut = makeSUT() 98 | let (_, attributedText) = makeText() 99 | 100 | // given attributed text styled as single line 101 | var styled = sut.styleAttributedText(attributedText, lineMode: .single) 102 | // we expect it to not have paragraph style 103 | XCTAssertNil(styled.attribute(.paragraphStyle, at: 0, effectiveRange: nil)) 104 | 105 | // but given attributed text styled as multi line 106 | styled = sut.styleAttributedText( 107 | attributedText, 108 | lineMode: .multi(alignment: .natural, lineBreakMode: .byWordWrapping) 109 | ) 110 | // we expect it to have paragraph style 111 | XCTAssertNotNil(styled.attribute(.paragraphStyle, at: 0, effectiveRange: nil)) 112 | } 113 | 114 | func test_buildAttributes_defaultDeliversFontAttributeOnly() { 115 | let sut = makeSUT() 116 | let attributes = sut.buildAttributes() 117 | 118 | XCTAssertEqual(sut.font, attributes[.font] as? UIFont) 119 | XCTAssertNil(attributes[.paragraphStyle]) 120 | XCTAssertNil(attributes[.kern]) 121 | XCTAssertNil(attributes[.underlineStyle]) 122 | XCTAssertNil(attributes[.strikethroughStyle]) 123 | XCTAssertNil(attributes[.baselineOffset]) 124 | } 125 | 126 | func test_buildAttributes_singleLineDeliversNoParagraphStyles() { 127 | let sut = makeSUT() 128 | let attributes = sut.buildAttributes(lineMode: .single) 129 | 130 | XCTAssertNil(attributes[.paragraphStyle]) 131 | } 132 | 133 | func test_buildAttributes_multiLineDeliversParagraphStyles() throws { 134 | let sut = makeSUT() 135 | let attributes = sut.buildAttributes(lineMode: .multi(alignment: .natural, lineBreakMode: .byWordWrapping)) 136 | 137 | let paragraphStyle = try XCTUnwrap(attributes[.paragraphStyle] as? NSParagraphStyle) 138 | XCTAssertEqual(paragraphStyle.minimumLineHeight, sut.lineHeight) 139 | XCTAssertEqual(paragraphStyle.maximumLineHeight, sut.lineHeight) 140 | XCTAssertEqual(paragraphStyle.lineBreakMode, .byWordWrapping) 141 | XCTAssertEqual(sut.baselineOffset, attributes[.baselineOffset] as? CGFloat) 142 | } 143 | 144 | func test_buildAttributes_deliversKernAttribute() { 145 | let letterSpacing = CGFloat(Int.random(in: 1...24)) / 10.0 146 | let sut = makeSUT(letterSpacing: letterSpacing) 147 | let attributes = sut.buildAttributes() 148 | 149 | XCTAssertEqual(letterSpacing, attributes[.kern] as? CGFloat) 150 | } 151 | 152 | func test_buildAttributes_deliversUnderlineAttributes() { 153 | let sut = makeSUT(textDecoration: .underline) 154 | let attributes = sut.buildAttributes() 155 | 156 | XCTAssertEqual(NSUnderlineStyle.single.rawValue, attributes[.underlineStyle] as? Int) 157 | } 158 | 159 | func test_buildAttributes_deliversStrikethroughAttributes() { 160 | let sut = makeSUT(textDecoration: .strikethrough) 161 | let attributes = sut.buildAttributes() 162 | 163 | XCTAssertEqual(NSUnderlineStyle.single.rawValue, attributes[.strikethroughStyle] as? Int) 164 | } 165 | } 166 | 167 | private extension TypographyLayoutTests { 168 | func makeSUT( 169 | letterSpacing: CGFloat = 0, 170 | textCase: Typography.TextCase = .none, 171 | textDecoration: Typography.TextDecoration = .none 172 | ) -> TypographyLayout { 173 | let fontFamily = DefaultFontFamily(familyName: "Futura") 174 | 175 | let typography = Typography( 176 | fontFamily: fontFamily, 177 | fontWeight: .medium, 178 | fontSize: 16, 179 | lineHeight: 20, 180 | letterSpacing: letterSpacing, 181 | textCase: textCase, 182 | textDecoration: textDecoration 183 | ) 184 | 185 | return typography.generateLayout(compatibleWith: .default) 186 | } 187 | 188 | func makeText() -> (String, NSAttributedString) { 189 | let text = "Hello World" 190 | let attributedText = NSAttributedString(string: text, attributes: [.foregroundColor: UIColor.label]) 191 | return (text, attributedText) 192 | } 193 | 194 | func _testKernAttribute(styled: NSAttributedString, letterSpacing: Double) { 195 | let kernValue = styled.attribute(.kern, at: 0, effectiveRange: nil) as? Double 196 | if letterSpacing == 0 { 197 | // should not have the kern style 198 | XCTAssertNil(kernValue) 199 | } else { 200 | // should have the correct kern value 201 | XCTAssertEqual(kernValue, letterSpacing) 202 | } 203 | } 204 | 205 | func _testDecorationAttributes(styled: NSAttributedString, textDecoration: Typography.TextDecoration) { 206 | let underlineValue = styled.attribute(.underlineStyle, at: 0, effectiveRange: nil) as? Int 207 | let strikethroughValue = styled.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int 208 | 209 | switch textDecoration { 210 | case .none: 211 | XCTAssertNil(strikethroughValue) 212 | XCTAssertNil(underlineValue) 213 | case .strikethrough: 214 | XCTAssertEqual(strikethroughValue, NSUnderlineStyle.single.rawValue) 215 | XCTAssertNil(underlineValue) 216 | case .underline: 217 | XCTAssertNil(strikethroughValue) 218 | XCTAssertEqual(underlineValue, NSUnderlineStyle.single.rawValue) 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /Tests/YMatterTypeTests/YMatterType+LoggingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YMatterType+LoggingTests.swift 3 | // YMatterType 4 | // 5 | // Created by Sahil Saini on 04/04/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YMatterType 11 | 12 | final class YMatterTypeLoggingTests: XCTestCase { 13 | func testDefaults() { 14 | XCTAssertTrue(YMatterType.isLoggingEnabled) 15 | } 16 | } 17 | --------------------------------------------------------------------------------