Making Swift package assets work in SwiftUI previews

Jun 1, 2022 · Follow on Twitter and Mastodon

In this post, we’ll take a look at how we can get colors, images and other assets that are defined in Swift packages to work in external SwiftUI previews.

Background

Swift packages make it easy to share assets, such as colors, images, files,fonts, etc. Just add resources to a package folder and specify the folder in the package definition file:

// swift-tools-version: 5.6

import PackageDescription

let package = Package(
    name: "MyPackage",
    products: [
        .library(
            name: "MyPackage",
            targets: ["MyPackage"])
    ],
    dependencies: [],
    targets: [
        .target(
            name: "MyPackage",
            dependencies: [],
            resources: [.process("Resources")]), // <-- Define the folder here
        .testTarget(
            name: "MyPackageTests",
            dependencies: ["MyPackage"])
    ]
)

When you add resources to a package, it will generate a .module bundle that you can use to access any embedded assets and resources. Tools like SwiftGen also use this bundle to access resources from the package.

While this works great within the package, SwiftUI is currently not able to use the .module bundle in external previews. Trying to access module assets will cause a preview to crash.

This bug is discussed in here and here, where Skyler_S and Jeremy Gale shows how you can creat a custom bundle to resolve a bundle differently in a SwiftUI preview.

Creating a custom bundle

To create this custom bundle, lets first extend Bundle with a private class that the package can use to find the package bundle:

extension Bundle {

    private class BundleFinder {}
}

We then have to define the package name. The pattern was LocalPackages_<ModuleName> for iOS, but the new format is:

extension Bundle {

    static let myPackageBundleName = "MyPackage_MyPackage"
}

This can change in new Xcode versions, so make sure to test it when a new Xcode version is released. If it stops working, you can print out the path and look for the bundle name:

Bundle(for: BundleFinder.self)
    .resourceURL?
    .deletingLastPathComponent()
    .deletingLastPathComponent()

The name pattern above can be different for macOS, but you should then just be able to add an #if os(macOS) check and return another value.

We can now define a custom bundle that looks for a bundle in more places than .module:

public static let myPackage: Bundle = {
    let bundleNameIOS = myPackageBundleName
    let candidates = [
        // Bundle should be here when the package is linked into an App.
        Bundle.main.resourceURL,
        // Bundle should be here when the package is linked into a framework.
        Bundle(for: BundleFinder.self).resourceURL,
        // For command-line tools.
        Bundle.main.bundleURL,
        // Bundle should be here when running previews from a different package
        // (this is the path to "…/Debug-iphonesimulator/").
        Bundle(for: BundleFinder.self)
            .resourceURL?
            .deletingLastPathComponent()
            .deletingLastPathComponent()
            .deletingLastPathComponent(),
        Bundle(for: BundleFinder.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 myPackage custom bundle.")
}()

With this new bundle, you can now use .myPackage instead of .module to make external previews work. If they start crashing, you can use the techniques above to find out why.

Using our custom bundle in SwiftGen

If you use SwiftGen to generate code from your assets and resource files, you should tell it to use the custom bundle, by adjusting the swiftgen.yml file so that it defines a bundle:

// swiftgen.yml

fonts:
  inputs:
    - Sources/MyPackage/Resources/Fonts
  outputs:
    - templateName: swift5
      output: Sources/MyPackage/Fonts/Fonts.swift
      params:
        bundle: Bundle.myPackage
        fontAliasName: SwiftGenFont

xcassets:
  inputs:
    - Sources/MyPackage/Resources/Colors.xcassets
    - Sources/MyPackage/Resources/Images.xcassets
  outputs:
    - templateName: swift5
      output: Sources/MyPackage/Assets/Assets.swift
      params:
        bundle: Bundle.myPackage

If you run swiftgen, the generated code should now use .myPackage instead of .module and any external previews that refer to these assets should render without crashes.

Conclusion

Adding assets to Swift packages is easy and convenient, but SwiftUI currently have some bugs that make them problematic. If you have these problems, I hope this post was helpful.

Let’s hope that the Swift package team is aware of this problem and that the upcoming changes at this year’s WWDC will finally provide a fix for it and make this post obsolete.