Jeremy Gale
Jeremy Gale's iOS Musings

Jeremy Gale's iOS Musings

SwiftUI: How to use custom fonts and images in a Swift Package

SwiftUI: How to use custom fonts and images in a Swift Package

Create a design system module with custom fonts, reusable components, and working Xcode Previews

Jeremy Gale's photo
Jeremy Gale
·Mar 10, 2022·

9 min read

Table of contents

Background

Modern iOS development is moving towards SwiftUI, while using Swift Package Manager (SPM) to handle internal and external dependencies. The iOS team at Shift has been working this way for about a year. It's dramatically faster to develop, with excellent modularization.

We have been using a similar approach to modularization as PointFree, where we have a single Swift Package with a single Package.swift file, and multiple feature modules which are completely isolated from each other. This reduces tightly-coupled spaghetti code.

Xcode Previews are a fantastic way to see at a glance how a view is used. They let you see the view in different contexts on different devices sizes, accessibility settings, dark/light mode, etc. They've been one of the biggest boons to rapid development and iteration, and really help new team members get up to speed. For SwiftUI, spitting up features into modules is important to keep Xcode Previews working. Once your app is a certain size, the Previews will fail to load because they take more than 5 seconds to render.

Our company also uses a design system. This consists of custom fonts, images like logos, and reusable view components that are used throughout the app.

Goal

  • Have all of our design system components (fonts, views) contained in a single Swift Package Manager module.
  • Be able to specify SwiftUI fonts using Dynamic Type styles, e.g. .font(.bodyCustom). These might represent different fonts themselves, or different weights.
  • Be able to import the design system module into feature modules .
  • Have the Xcode Previews for those feature modules work, including custom fonts and reusable components/assets from the design system.

Complete Code

If you'd rather just dive into the code, I have a complete app example on GitHub.

The Approach

Let's first look at the Package.swift file:

// swift-tools-version:5.5

import PackageDescription

let package = Package(
    name: "CustomFontsPackage",
    platforms: [
        .iOS(.v15),
    ],
    products: [
        .library(name: "FeatureModule", targets: ["FeatureModule"]),
        .library(name: "DesignSystem", targets: ["DesignSystem"]),
    ],
    dependencies: [],
    targets: [
        .target(
            name: "FeatureModule",
            dependencies: [
                "DesignSystem",
            ]
        ),
        .target(
            name: "DesignSystem",
            dependencies: [],
            resources: [
                .process("Sources/Resources"),
            ]
        ),
    ]
)

The DesignSystem module contains all of the custom fonts, reusable views and image assets used in other parts of the system. It has as few dependencies as possible, ideally none. For each feature, we'd create a new module/target similar to FeatureModule. Also most all of them will depend on DesignSystem. Note that you can also include third-party SPM dependencies in this file.

Filesystem Layout

This is the layout of our Swift Package on the filesystem. You can see all the fonts and an asset catalog of images are located here. In the Package.swift you must mark these resources as .process() so they can be used by other modules.

image.png

Fonts

Let's cover the approach for defining our custom font styles, and registering them with the application.

Specifying the Dynamic Type styles using custom fonts

Here is the Font+Custom.swift file:

import SwiftUI

extension Font {
    public static let largeTitleCustom = Font.custom("Lobster-Regular", size: 34, relativeTo: .largeTitle)
    public static let titleCustom = Font.custom("Lobster-Regular", size: 28, relativeTo: .title)
    public static let title2Custom = Font.custom("Lobster-Regular", size: 22, relativeTo: .title2)
    public static let title3Custom = Font.custom("Lobster-Regular", size: 20, relativeTo: .title3)
    public static let headlineCustom = Font.custom("Lobster-Regular", size: 17, relativeTo: .headline)
    public static let subheadlineCustom = Font.custom("Lobster-Regular", size: 15, relativeTo: .subheadline)
    public static let bodyCustom = Font.custom("SyneMono-Regular", size: 17, relativeTo: .body)
    public static let calloutCustom = Font.custom("SyneMono-Regular", size: 16, relativeTo: .callout)
    public static let footnoteCustom = Font.custom("SyneMono-Regular", size: 13, relativeTo: .footnote)
    public static let captionCustom = Font.custom("SyneMono-Regular", size: 12, relativeTo: .caption)
    public static let caption2Custom = Font.custom("SyneMono-Regular", size: 11, relativeTo: .caption2)
}

public enum CustomFonts {
    public static func registerCustomFonts() {
        for font in ["SyneMono-Regular.ttf", "Lobster-Regular.ttf"] {
            guard let url = Bundle.designSystem.url(forResource: font, withExtension: nil) else { return }
            CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
        }
    }
}

extension View {
    /// Attach this to any Xcode Preview's view to have custom fonts displayed
    /// Note: Not needed for the actual app
    public func loadCustomFonts() -> some View {
        CustomFonts.registerCustomFonts()
        return self
    }
}

This uses the new iOS 14 API for custom fonts. Read a bit more about it here at Hacking with Swift.

In the app, we will be able to set the dynamic type font for any Text like so:

Text("Hello, World")
    .font(.title3Custom)

Registering the fonts

Due to our goal of having this work in both Swift Package modules and Xcode Previews, I recommend registering your fonts the following way:

import DesignSystem
import SwiftUI

@main
struct SwiftUICustomFontsApp: App {

    init() {
        CustomFonts.registerCustomFonts()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

This will register all your custom fonts with the system. At this point, your custom fonts will work from within any of your Swift Package modules in the compiled running app. However, Xcode Previews will still not always show the custom font.

Note: The old way of including custom fonts was to add the <key>UIAppFonts</key> to your Info.plist. You can still do this if you want, for example if you have a mixed UIKit/SwiftUI app. In that scenario, you need to duplicate the font files in your main target (not just the Swift Package) to ensure they are copied into the final bundle. If you do that, you don't even need to make the registerCustomFonts() call, except that Xcode Previews still won't always show the custom font.

Fixing Xcode Previews for Custom Fonts

At this point, the compiled running app uses the custom fonts and works well. The Xcode Previews are a mixed bag though. Any views within your main app target, if it's small enough, will properly show the custom fonts. However, any views contained within your Swift Package Manager feature modules will not. Xcode Previews do not run the global App.init() when running, and don't look at Info.plist UIAppFonts either. We need a different approach.

First, what it looks like when used. This is a file in the FeatureModule:

import DesignSystem
import SwiftUI

public struct ModuleView: View {
    public init() {}

    public var body: some View {
        Text("Hello, Module!")
            .font(.largeTitleCustom)
    }
}

struct ModuleView_Previews: PreviewProvider {
    static var previews: some View {
        ModuleView()
            .loadCustomFonts()
    }
}

We need to use .loadCustomFonts() to quickly run the registration code that we also have in App.init(), before rendering any preview. Thanks to u/camji55 for this idea.

Behold: working SwiftUI Previews with custom fonts! Note that this is previewing FeatureModule, not DesignSystem.

image.png

Unfortunately you need to do this in every preview. To help with this, I've created a CommonPreviews helper which automatically previews my view on a few different devices sizes, with dark mode, and accessibility dynamic type. It also loads the custom fonts so I don't need to remember that:

import SwiftUI

public struct CommonPreviews<Content: View>: View {
    let content: Content

    public init(@ViewBuilder _ content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        Group {
            // First preview will use whatever device you have selected in Xcode
            self.content
                .environment(\.colorScheme, .light)
                .preferredColorScheme(.light)
                .navigationBarHidden(true)
                .previewDisplayName("Currently selected iPhone")

            self.content
                .environment(\.colorScheme, .dark)
                .preferredColorScheme(.dark)
                .navigationBarHidden(true)
                .previewDevice("iPhone SE (1st generation)")
                .previewDisplayName("iPhone SE, dark mode")

            self.content
                .environment(\.colorScheme, .light)
                .preferredColorScheme(.light)
                .navigationBarHidden(true)
                .previewDevice("iPhone 8")
                .previewDisplayName("iPhone 8, dynamic text")
                .environment(\.sizeCategory, .accessibilityMedium)
        }
        .loadCustomFonts()
    }
}

You use CommonPreviews as follows:

struct ModuleView_Previews: PreviewProvider {
    static var previews: some View {
        CommonPreviews {
            ModuleView()
        }
    }
}

The Secret Sauce

There is one crucial bit of code I haven't shown yet, and it's the Bundle.designSystem shown in the Font+Custom.swift file. Normally, you would pick Bundle.main for your main app target, or Bundle.module for a SPM module target. However, neither of these work for Xcode Previews.

Note: If you don't care about seeing the custom fonts in other module previews, just use Bundle.module.url() in Font+Custom.swift.

After searching the web, I found this solution which I adapted into my Bundle+Extensions.swift file:

import UIKit

extension Bundle {
    private class CurrentBundleFinder {}

    /// This is used to allow you to use resources from DesignSystem in other Swift Package previews.
    /// Inspiration from here: https://developer.apple.com/forums/thread/664295
    public static var designSystem: Bundle = {
        // The name of your local package bundle. This may change on every different version of Xcode.
        // It used to be "LocalPackages_<ModuleName>" for iOS. To find out what it is, print out  the path for
        // Bundle(for: CurrentBundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent()
        // and then look for what bundle is named in there.
        let bundleNameIOS = "CustomFontsPackage_DesignSystem"
        let candidates = [
            // Bundle should be present here when the package is linked into an App.
            Bundle.main.resourceURL,
            // Bundle should be present here when the package is linked into a framework.
            Bundle(for: CurrentBundleFinder.self).resourceURL,
            // For command-line tools.
            Bundle.main.bundleURL,
            // Bundle should be present here when running previews from a different package
            // (this is the path to "…/Debug-iphonesimulator/").
            Bundle(for: CurrentBundleFinder.self)
                .resourceURL?
                .deletingLastPathComponent()
                .deletingLastPathComponent()
                .deletingLastPathComponent(),
            Bundle(for: CurrentBundleFinder.self)
                .resourceURL?
                .deletingLastPathComponent()
                .deletingLastPathComponent(),
        ]

        for candidate in candidates {
            let bundlePathiOS = candidate?.appendingPathComponent(bundleNameIOS + ".bundle")
            if let bundle = bundlePathiOS.flatMap(Bundle.init(url:)) {
                return bundle
            }
        }
        fatalError("Can't find designSystem custom bundle. See Bundle+Extensions.swift")
    }()
}

You might need to change:

  • public static var designSystem: Whatever custom name you want to use for this bundle that's used in Xcode Previews.
  • let bundleNameIOS = "CustomFontsPackage_DesignSystem" The first part before the underscore is your Package.swift's name property. The part after the underscore is your module name.

This is a bit hacky, and fragile to be sure. It defines a custom Bundle that searches every possible bundle path (including .main) to find where those custom fonts and assets are located. The nice thing is that this works both in Xcode Previews and in the actual final app, but no warranty is implied here! This has actually broken between different Xcode versions for me. See the comment in there for troubleshooting the correct path to use.

Hopefully at some point in the future, the Xcode Previews team will make it not necessarily to use hacks like this to load assets/fonts across module boundaries.

Loading Image assets and Views from a different module

Sometimes you want to make a custom view that contains an image asset, e.g. a company logo that gets displayed in multiple places. It's easy to define one in a shared module, import that elsewhere, but it has an even worse problem than fonts - Xcode Previews will crash when you try to preview the feature module that uses it.

Luckily, the root cause is the same, and we can use the same Bundle.designSystem approach. When loading the image from your module's asset catalog, use .designSystem instead of .module:

import SwiftUI

public struct LogoView: View {
    public init() {}

    public var body: some View {
        VStack {
            Image("logo", bundle: .designSystem)
                .resizable()
                .frame(width: 80, height: 80)

            Text("Creative Commons")
                .font(.bodyCustom)
        }
    }
}

Now when you are in a feature module, your previews will work! Note that LogoView() and the Creative Commons logo asset are stored in DesignSystem, not in FeatureModule that we are previewing here.

image.png

Conclusion

Today we discovered how to use custom fonts and image assets in a SwiftUI app that makes use of Swift Package Manager modules. We can encapsulate them in a single DesignSystem module, and use it anywhere in our app including other SPM modules. Crucially, we managed to keep Xcode Previews working. This allows a highly modular, reusable design system/component library approach.

To view a complete working example, I have an example on GitHub:

https://github.com/jgale/SwiftUICustomFonts

If you have any questions, get in touch on Twitter: @jgale_ios

Thanks for reading!

 
Share this