Search code examples
swiftuiswift-package-managercustom-font

How to setup and use custom fonts in a Swift Package Manager for my iOS app?


I have setup a Swift Package Manager with custom fonts for my iOS application.

I have three different types, and none of them are read and displayed in my SwiftUI app.

What is the proper way to setup custom fonts within a Swift Package Manager to be used within my iOS app?

In my SPM, I have added my three different fonts, in a Fonts folder that I process in Package.swift. The font files are in the .otf format:

Font folders

let package = Package(
  name: "MyAwesomePackage",
  platforms: [
    .iOS(.v14)
  ],
  products: [
    .library(
      name: "MyAwesomePackage",
      targets: ["MyAwesomePackage"]),
  ],
  targets: [
    .target(
      name: "MyAwesomePackage",
      resources: [
        .process("Fonts") // Processing my custom fonts
      ])
  ]
)

This is the code I am using in my Swift Package Manager to use my custom font in my SwiftUI app:

public extension Text {
  // This is the modifier used in the SwiftUI app.
  func customFont(_ type: CustomFontType) -> some View {

    return self
      .font(.custom(
        type.customFont,
        size: type.size,
        relativeTo: type.nativeTextStyle)
      )
  }
}

public enum CustomFontType {

  case body
  case header
  case link

  var customFont: String {
    switch self {
    case .header: "Gotham-Bold"
    case .body: "Gotham-Book"
    case .link: "Gotham-Medium"
    }
  }

  var size: CGFloat {
    switch self {
    case .header:
      return 32
    case .body:
      return 14
    case .link:
      return 12
    }
  }

  var nativeTextStyle: Font.TextStyle {
    switch self {
    case .header: .largeTitle
    case .body: .body
    case .link: .footnote
    }
  }
}

And this is how I call my custom font in my SwiftUI app. The thing is that I do not have any of my font being populated when called with the .customFont(_:) modifier, and the font provided by the app is not even the Apple SFFont ones.

import MyAwesomePackage
import SwiftUI

struct ContentView: View {

  var body: some View {
    Text("Custom Font")
      .customFont(.header)

    Text("Apple Font")
      .font(.largeTitle)
      .fontWeight(.bold)
  }
}

Solution

  • As pointed out @Andrew, the custom fonts need to be registered in order to be used.

    To do so, and avoid calling a method within the iOS app to register the fonts, we will use the SwiftGen plugin to create a swift file using a script that will generate the code to register the fonts in the background.

    So first of all, the SPM must import the SwiftGen dependency:

    // swift-tools-version: 6.0
    
    import PackageDescription
    
    let package = Package(
      name: "FooKit",
      platforms: [
        .iOS(.v18)
      ],
      products: [
        .library(name: "FooKit", targets: ["FooKit"])
      ],
      dependencies: [
        .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", from: "6.6.0") // Import SwiftGen.
      ],
      targets: [
        .target(
          name: "FooKit",
          resources: [
            .process("Resources") // The folder where the custom fonts file must be added.
          ],
          plugins: [
            .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin") // Set SwiftGen plugin.
          ]
        )
      ]
    )
    

    Following this, the custom fonts file must be added within the Resources folders, under a Fonts folder for clarity. So here we have 4 customs fonts files: (Use your own fonts file on this part)

    Important: Do not add custom fonts in an Asset catalog, it must be under the Resources folder.

    • Urbanist-Regular.ttf
    • Urbanist-Medium.ttf
    • Urbanist-SemiBold.ttf
    • Urbanist-Bold.ttf

    Then in the package root folder, create a swiftgen.yml file that will be used to generate the fonts registering code from a configuration file that will be created after this one.

    The swiftgen.yml file:

    # Generate code for font assets.
    #
    # - When building the package using Xcode, the file will be created in
    # the Fonts directory in ~/Library/Developer/Xcode/DerivedData.
    # - When building the package using `$ swift build` from the command line, the
    # file will be created in the `.build` directory in the package’s root directory.
    fonts:
      inputs: Sources/FooKit/Resources/Fonts/
      outputs:
        templatePath: Sources/FooKit/SwiftGenTemplates/fonts-swift6.stencil
        output: ${DERIVED_SOURCES_DIR}/CKFonts.swift
        params:
            publicAccess: true
    
    

    The templatePath file declaration, is the script that will be run by SwiftGen, which is the one that will create the code to register the custom fonts.

    Let's create the file. It can be anywhere within the SPM, but make sure the path set in the swiftgen.yml file to it is the right one.

    We will call this file fonts-swift6.stencil

    // Generated code using SwiftGen — https://github.com/SwiftGen/SwiftGen
    // swiftlint:disable all
    
    {% if families %}
    {% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %}
    {% set fontType %}{{param.fontTypeName|default:"FontConvertible"}}{% endset %}
    #if os(macOS)
      import AppKit.NSFont
    #elseif os(iOS) || os(tvOS) || os(watchOS)
      import UIKit.UIFont
    #endif
    #if canImport(SwiftUI)
      import SwiftUI
    #endif
    
    // MARK: - Fonts
    
    /// Represents all font families available in the project.
    {{accessModifier}} enum {{param.enumName|default:"FontFamily"}} {
    
      {% for family in families %}
      /// Represents the ``{{family.name}}`` font family and its associated styles.
      {{accessModifier}} enum {{family.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
        {% for font in family.fonts %}
        /// The `{{font.style}}` style of the ``{{family.name}}`` font family.
        {{accessModifier}} static let {{font.style|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{fontType}}(name: "{{font.name}}", family: "{{family.name}}", path: "{{font.path|basename}}")
        {% endfor %}
        /// A collection of all font styles in the ``{{family.name}}`` font family.
        {{accessModifier}} static let all: [{{fontType}}] = [{% for font in family.fonts %}{{font.style|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}{{ ", " if not forloop.last }}{% endfor %}]
      }
      {% endfor %}
      /// A collection of all custom fonts in the project.
      {{accessModifier}} static let allCustomFonts: [{{fontType}}] = [{% for family in families %}{{family.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}.all{{ ", " if not forloop.last }}{% endfor %}].flatMap { $0 }
    
      /// Registers all custom fonts in the project for use.
      {{accessModifier}} static func registerAllCustomFonts() {
        allCustomFonts.forEach { $0.register() }
      }
    }
    
    // MARK: - FontConvertible
    
    /// Represents a font resource that can be registered and used in the application.
    {{accessModifier}} struct {{fontType}}: Sendable, Equatable {
    
      /// The name of the font.
      {{accessModifier}} let name: String
      /// The font family name.
      {{accessModifier}} let family: String
      /// The resource path for the font.
      {{accessModifier}} let path: String
    
      #if os(macOS)
      /// The platform-specific font type for macOS.
      {{accessModifier}} typealias FKFont = NSFont
      #elseif os(iOS) || os(tvOS) || os(watchOS)
      /// The platform-specific font type for iOS, tvOS, and watchOS.
      {{accessModifier}} typealias FKFont = UIFont
      #endif
    
      /// Initializes a font object with a given size.
      /// - Parameter size: The size of the font.
      /// - Returns: A ``FKFont`` object initialized with the font resource.
      /// - Throws: A fatal error if the font cannot be initialized.
      {{accessModifier}} func font(size: CGFloat) -> FKFont {
        guard let font = FKFont(name: self.name, size: size)
        else { fatalError("Unable to initialize font '\(name)' (\(family)).") }
        return font
      }
    
      #if canImport(SwiftUI)
      /// Creates a SwiftUI-compatible font with a specific size.
      /// - Parameter size: The size of the font.
      /// - Returns: A ``Font`` object for use in SwiftUI.
      {{accessModifier}} func swiftUIFont(size: CGFloat) -> Font {
        self.registerIfNeeded()
        return .custom(self.name, size: size)
      }
    
      /// Creates a SwiftUI-compatible font with a specific size and text style.
      /// - Parameters:
      ///   - size: The size of the font.
      ///   - textStyle: The associated text style for the font.
      /// - Returns: A ``Font`` object for use in SwiftUI.
      {{accessModifier}} func swiftUIFont(
        size: CGFloat,
        relativeTo textStyle: Font.TextStyle
      ) -> Font {
        self.registerIfNeeded()
        return .custom(self.name, size: size, relativeTo: textStyle)
      }
      #endif
    
      /// Registers the font with the system for use if not already done.
      /// - Info: If the font is already registered, this method does nothing.
      {{accessModifier}} func register() {
        guard let url = url else { return }
        CTFontManagerRegisterFontsForURL(url as CFURL, .process, .none)
      }
    
      /// Registers the font if it is not already registered.
      fileprivate func registerIfNeeded() {
        #if os(iOS) || os(tvOS) || os(watchOS)
        let registeredFonts = UIFont.fontNames(forFamilyName: family)
        if registeredFonts.isEmpty || !registeredFonts.contains(name) {
          register()
        }
        #elseif os(macOS)
        if let url = url, CTFontManagerGetScopeForURL(url as CFURL) == .none {
          register()
        }
        #endif
      }
    
      /// The URL for the font resource in the application bundle.
      fileprivate var url: URL? {
        Bundle.module.url(forResource: path, withExtension: nil)
      }
    }
    
    {{accessModifier}} extension {{fontType}}.FKFont {
    
      /// Initializes a platform-specific font using a ``FontConvertible``.
      /// - Parameters:
      ///   - font: The ``FontConvertible`` to use.
      ///   - size: The size of the font.
      /// - Returns: A ``FKFont`` object if the font is successfully created.
      convenience init?(font: {{fontType}}, size: CGFloat) {
        font.registerIfNeeded()
        guard let uiFont = Self(name: font.name, size: size) else { return nil }
        self.init(descriptor: uiFont.fontDescriptor, size: size)
      }
    }
    
    // MARK: - SwiftUI Font Extension
    #if canImport(SwiftUI)
    {{accessModifier}} extension Font {
    
      /// Creates a SwiftUI-compatible font using a ``FontConvertible``.
      /// - Parameters:
      ///   - font: The ``FontConvertible`` to use.
      ///   - size: The size of the font.
      /// - Returns: A ``Font`` object for use in SwiftUI.
      static func custom(_ font: {{fontType}}, size: CGFloat) -> Font {
        font.registerIfNeeded()
        return custom(font.name, size: size)
      }
    
      /// Creates a SwiftUI-compatible font with a specific size and text style.
      /// - Parameters:
      ///   - font: The ``FontConvertible`` to use.
      ///   - size: The size of the font.
      ///   - textStyle: The associated text style for the font.
      /// - Returns: A ``Font`` object for use in SwiftUI.
      static func custom(
        _ font: {{fontType}},
        size: CGFloat,
        relativeTo textStyle: Font.TextStyle
      ) -> Font {
        font.registerIfNeeded()
        return custom(font.name, size: size, relativeTo: textStyle)
      }
    }
    #endif
    // swiftlint:enable all
    {% endif %}
    

    For this example, the folder structure should be set this way:

    FooKit/
    ├─ Package.swift
    ├─ Sources/
    │   └─ FooKit/
    │       ├─ Resources/
    │       │   └─ Fonts/
    │       │       ├─ Urbanist-Bold.ttf
    │       │       ├─ Urbanist-Medium.ttf
    │       │       ├─ Urbanist-Regular.ttf
    │       │       └─ Urbanist-SemiBold.ttf
    │       └─ SwiftGenTemplates/
    │           └─ fonts-swift6.stencil
    └─ swiftgen.yml
    

    Build the Swift Package in order for SwiftGen to register the custom fonts and create the code to access it.

    Now, we will create the Swift code within the Swift Package Manager to have the custom font ready to be used in our iOS application. Refers to the code documentation to understand their purpose:

    import Foundation
    
    /// Represents the font styles used in the application.
    public enum FKFontStyle {
    
      /// A large title style, typically used for prominent headings.
      case largeTitle
      /// A title style, used for main titles.
      case title
      /// A body style, used for main content text.
      case body
      /// A callout style, used for highlighting text.
      case callout
    
      /// The associated ``UIFont.TextStyle`` for the font style.
      ///
      /// This value is used to map the custom font style to its corresponding
      /// dynamic text style for accessibility support.
      var textStyle: Font.TextStyle {
        switch self {
        case .largeTitle: .largeTitle
        case .title: .title
        case .body: .body
        case .callout: .callout
        }
      }
    
      /// The default font weight for the font style.
      var defaultFontWeight: FKFontWeight {
        switch self {
        case .largeTitle: .bold
        case .title: .regular
        case .body: .regular
        case .callout: .regular
        }
      }
    
      /// The line height multiplier associated to the font style.
      ///
      /// This value defines the proportional spacing for each line of text relative to the font size.
      var lineHeightMultiplier: CGFloat {
        switch self {
        default: 1.6
        }
      }
    
      /// The predefined font size for the font style.
      var size: CGFloat {
        switch self {
        case .largeTitle: 48
        case .title: 40
        case .body: 18
        case .callout: 16
        }
      }
    }
    
    /// Represents the font weights used in the application.
    public enum FKFontWeight {
    
      /// A bold font weight, used for strong emphasis.
      case bold
      /// A medium font weight, slightly lighter than bold.
      case medium
      /// A regular font weight, typically used for body text.
      case regular
      /// A semi-bold font weight, between regular and bold.
      case semiBold
    
      /// The font name associated with the font weight.
      ///
      /// This property provides the specific font name for the selected weight,
      /// ensuring consistency with the design system's font family.
      /// - Info: ``FontConvertible`` and ``FontFamily`` is the code generated by SwiftGen, which has now registered our custom fonts.
      var fontName: FontConvertible {
        switch self {
        case .bold: FontFamily.Urbanist.bold
        case .medium: FontFamily.Urbanist.medium
        case .regular: FontFamily.Urbanist.regular
        case .semiBold: FontFamily.Urbanist.semiBold
        }
      }
    }
    

    Now, we will create the SwiftUI View modifier to access our custom fonts in a Swifty way in our iOS app:

    public extension View {
    
      /// Adds the ``FooKit`` font style to a view.
      /// - Parameters:
      ///   - style: The ``FKFontStyle`` defining the font style to apply.
      ///   - weight: An optional ``FKFontWeight`` specifying the font weight. Defaults to `nil`, which uses the
      ///   style's `defaultFontWeight`.
      /// - Returns: A modified view with the specified font style and weight.
      func fkFont(
        _ style: FKFontStyle,
        weight: FKFontWeight? = .none
      ) -> some View {
        modifier(FKFontModifier(style: style, weight: weight))
      }
    }
    
    /// Adds the ``FooKit`` font style to a view.
    private struct FKFontModifier: ViewModifier {
    
      /// The font style to apply.
      let style: FKFontStyle
      /// The optional font weight to apply. If `nil`, the style's default weight is used.
      let weight: FKFontWeight?
    
      func body(content: Content) -> some View {
    
        let fontName = weight?.fontName ?? style.defaultFontWeight.fontName
        let font = Font.custom(fontName, size: style.size, relativeTo: style.textStyle)
        let lineSpacing = (style.lineHeightMultiplier * style.size) - style.size
    
        content
          .font(font)
          .lineSpacing(lineSpacing)
          .padding(.vertical, lineSpacing / 2)
      }
    }
    

    This is it, our code is easily accessible within the iOS app, without the need to call any register font within it. Which simplify its access.

    Accessing the custom font within the iOS app

    import FooKit
    import SwiftUI
    
    struct ContentView: View {
    
      var body: some View {
        Text("Custom font `body` in `regular` weight.")
          .fkFont(.body, weight: .regular)
    
        Text("Custom font `callout` in `bold` weight.")
          .fkFont(.callout, weight: .bold)
      }
    }