Load custom fonts into your app using Swift Package Plugins

Sponsored
RevenueCat logo
Develop with RocketSim, Ship with Helm.

Helm Pro yearly subscribers now get a 30% discount on RocketSim thanks to contingent pricing on the App Store.

If you’re an indie developer or you’re part of an organisation with multiple apps that share a common design language and find yourself using the same fonts over and over again, you might want to consider creating a Swift Package to contain the shared font files and font-loading code.

Doing this will allow you to launch new apps a lot faster, have a single place to update the font files for all of your apps and reduce code duplication.

If you pair the reusability of Swift Packages with the power of Swift Package Plugins, you can even remove the need to write any code yourself and have the plugin generate it all for you at build time from the font files themselves 🤯. In this article, I’ll show you how to do just that using SwiftGen!

Creating a Swift Package with resources

Let’s get started by creating a new Swift Package called Fonts:

Terminal
mkdir Fonts
cd Fonts && swift package init

Let’s then modify the new Package.swift file to remove the test target and add a resource definition to the Fonts target:

Package.swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Fonts",
    products: [
        .library(
            name: "Fonts",
            targets: ["Fonts"]),
    ],
    dependencies: [],
    targets: [
        .target(
            name: "Fonts",
            dependencies: [],
            resources: [.process("Resources")]
        )
    ]
)

You can keep the test target if you intend on writing tests around the font loading code, but I decided to remove it for simplicity’s sake.

All of the code in this package is going to be generated by a Swift Package Plugin so we don’t need to explicitly write any code in Fonts target. We do however need to add a dummy file to the target’s Sources directory so that the package can be built.

For this reason, let’s remove the contents of the auto-generated Fonts.swift file, rename it to something more suitable like Fake.swift and add a comment in the file to make it clear that it’s not meant to contain any code:

Fake.swift
// The code in this package is auto-generated.
// This file is a placeholder to keep the compiler happy

Let’s now create a Resources directory in the Fonts target’s Sources directory to match the resource we defined earlier in the package’s manifest and add one or more font files to it.

As per Apple’s documentation, format support for custom fonts is limited to otf and ttf files. I will be adding both the Excon and Ranade variable font files which pair well together and that I found thanks to Fontshare’s font pair finder, which I would thoroughly recommend.

After the package setup is done, our package structure should look like this:

Fonts
├── README.md
├── Package.swift
└── Sources
    └── Fonts
        ├── Resources
        │   ├── Excon.ttf
        │   └── Ranade.ttf
        └── Fake.swift

Adding the SwiftGen plugin

Now that we have a Swift Package that can host the custom fonts we want to use in our apps, we need to generate the necessary code to load such fonts. To achieve this, I decided to use one of my favourite Swift Package Plugins: SwiftGen.

One of SwiftGen’s most powerful features is the ability to generate Swift interfaces for resources such as Fonts. To start using the plugin, we must define it as a dependency in our Package.swift file and add the SwiftGenPlugin to our target’s plugins array:

Package.swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Fonts",
    products: [
        .library(
            name: "Fonts",
            targets: ["Fonts"]),
    ],
    dependencies: [
        .package(url: "https://github.com/SwiftGen/SwiftGenPlugin", exact: "6.6.0")
    ],
    targets: [
        .target(
            name: "Fonts",
            dependencies: [],
            resources: [.process("Resources")],
            plugins: [
              .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin")
            ]
        )
    ]
)

SwiftGen requires a configuration file (swiftgen.yml) to be present in either the root of the Swift Package or in a target’s Sources directory. In our case, we’ll create this configuration file in the Fonts target’s Sources directory:

swiftgen.yml
# 1
fonts:
  # 2
  inputs: Resources/
  outputs:
    # 3
    templateName: swift5
    # 4
    output: ${DERIVED_SOURCES_DIR}/Generated.swift
    params:
        # 5
        publicAccess: true

The configuration file above tells SwiftGen to:

  1. Generate code for font assets.
  2. Look for font files in the Resources directory of the target the plugin is applied to.
  3. Use the fonts swift5 template to generate the code.
  4. Output the generated code to a file called Generated.swift. SwiftGenPlugin sets an environment variable called DERIVED_SOURCES_DIR to the path of the plugin’s work directory, which is the only directory with write permissions the plugin has access to. We can then use this variable in the configuration file to specify the exact location within where we want SwiftGen to create any output files.
  5. Make the generated code public so that it can be used by clients of the Swift Package.

Next time we build the package, SwiftGen will create a file called Generated.swift that will be added to the Fonts target’s list of input files. This means that the file will be compiled and included in the package’s final build product.

If you want to inspect the generated code, you need to know that the file will be created in a different location based on how you’re building the package:

  • If you’re building the package using Xcode, the file will be created in the Fonts directory in ~/Library/Developer/Xcode/DerivedData: Location of generated plugin code in Derived Data

  • If you’re 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: Location of generated plugin code in the root directory

Using the generated code

SwiftGen generates a FontFamily enum with a static property for each font found in the Resources directory. Each of these properties is of type FontConvertible, which is a struct containing several methods so you can retrieve:

  • A SwiftUI.Font that you can use directly on your views:
ArticleView.swift
import SwiftUI
import Fonts

struct ArticleView: View {
    var body: some View {
        VStack(spacing: 8) {
            Text("This is a title")
                .font(FontFamily.ExconVariable.medium.swiftUIFont(size: 28, relativeTo: .title))
            Text("This is the body!")
                .font(FontFamily.RanadeVariable.light.swiftUIFont(size: 17, relativeTo: .body))
        }
    }
}
  • A Font, which is a typealias that resolves to UIFont on iOS and NSFont on macOS that you can use directly from your AppKit or UIKit views:
ArticleViewController.swift
import UIKit
import Fonts

class ArticleViewController: UIViewController {
    let titleLabel = UILabel()
    let bodyLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()

        titleLabel.font = scaledFont(
            from: FontFamily.ExconVariable.medium,
            size: 28,
            relativeToStyle: .title1
        )
        bodyLabel.font = scaledFont(
            from: FontFamily.RanadeVariable.light,
            size: 17,
            relativeToStyle: .body
        )
    }

    func scaledFont(
        from fontProvider: FontConvertible,
        size: CGFloat,
        relativeToStyle style: UIFont.TextStyle
    ) -> UIFont {
        let font = fontProvider.font(size: size)
        return UIFontMetrics(forTextStyle: style).scaledFont(for: font)
    }
}