├── .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 |
--------------------------------------------------------------------------------