Preview Assets in Swift Package

In a .xcodeproj I am able to enrich SwiftUI Previews with resources that are stripped during an archive of an app when placed in a “Preview Assets” asset catalog. See Development Assets in Xcode to enrich SwiftUI Previews for more info.

In a Swift Package, I am not aware of an option that resources, specified for a target in a package manifest, can be made conditional based on configuration.

Adding resources to Swift Package to enrich SwiftUI Previews have a negative impact on an app as it bloats its IPA size unnecessarily.

Do I overlook something or are there plans for Swift Packages to support development assets?

7 Likes

I can't talk about future plans for an Xcode centric feature like this, but there's currently no support for development assets in packages.

1 Like

@NeoNacho If Package Manager Conditional Target Dependencies would support configuration conditionals then it would be an option to create a dedicated helper target containing SwiftUI previews and resources for DEBUG configuration. But I guess there are no plans to introduce support for configuration conditionals?

3 Likes

We are using this approach.

Thanks for sharing your approach on this matter, anreitersimon.

I'm doing exactly the same thing - environment variable triggers that are used to manipulate whats in, and out, of Package.swift. Not for this specific use case (preview assets), but the same pattern anyway.

A question to you, @Joseph_Heck, out of curiosity: you are doing this for an internal Swift package (to modularize an iOS app) or for an open-source reusable Swift package? Using environment variable seems fine for an internal Swift package but not for an SDK-style Swift package

I'm using it within an open-source package, specifically to expose a CLI exec that I use when developing the package - generating images for docs in one case, or enabling a benchmarking executable in another. I mostly came at it from the area of not wanting these additional targets "clogging up" a developer's experience when they go to "just use" the package.

1 Like

Interesting, thanks for sharing more lights on how you use this technique of environment variables.

1 Like

In the SE-0273 Acceptance thread, @NeoNacho stated that…

@NeoNacho: Was this effort tracked in a new proposal or other visible location? If not, can you give us an update regarding the current direction? Apologies if there's a discussion that I missed... I've spent a fair bit of time searching to no avail, and it's been quite some time since that quote. Thanks!

From what I see in the code, there's a TargetDependencyCondition type.

Sure, but unless there's something I'm missing, you can only create a TargetDependencyCondition for a collection of platforms, not build configurations (the scope of this thread).

1 Like

Did you manage to get this to work? I now add a preview target but that is combursome too

@doozMen How does adding a preview target work? Can you preview using classes/resources from this target and not have the stuff in that target linked in the final build?

you have 2 targets. I production and one preview. If you use the preview then you have the resources linked in your app. but the production does not

package = Package {
products: [
.library("FooProduction"),
.library("FooPreview")
],
targets: [
.target("FooProduction"), // no dependencies and/or resources here
.target("FooPreview",  dependencies: ["some"],  resources: ["some"])
]

It is a pain as a bit of a duplicate effort but it works.

I hit this issue today. It would be great if SPM could have better support for development assets.
I ended up resorting to a specific package to contain all the dev assets and exporting those using public accessors.
As the last step of the build process, I delete the associated resource bundle from the build output (yeah, a bit hacky).
Those assets shouldn't be referenced from prod code but to protect myself from unexpected crashes in prod from referencing them, I wrapped the assets public accessors with #if DEBUG. This cascades down the code and previews using those assets which is a little inconvenient but I feel a little safer that way.
It's a bit annoying though that debug builds might work but release builds might not, detecting this issue later in the CI pipeline. But that should be better than potential crashes in prod.

1 Like

Just for the record, I ended up refining the above approach. This is for a macOS app, not sure if this will work for other platforms. This was it:

  • A single package named SharedPreviewAssets that contains all preview assets
  • The package exports assets using public constants as described in the docs or this WWDC video

Example:

public enum SharedPreviewAssets {
    public static let background = Image(.background)

    // These assets might not be found in prod so even if these resources shouldn’t be accessed by production code, we make sure we don’t crash just in case
    // The generated SwiftUI ImageResource Image extension won’t crash but the extension of NSImage will crash so we do it this way.
    public static let someNSImage = Bundle.module.image(forResource: “image”) ?? placeholderNsImage

    /// Placeholder image to return when the asset was not found
    private static let placeholderNsImage = NSImage(size: NSSize(width: 1, height: 1))
}
  • This package must be included in the Package.swift of clients as a dependency. Then it can be used this way:
import SharedPreviewAssets

struct MyView: View {
    var body: some View { ... }
}

#Preview {
    MyView()
    .background(SharedPreviewAssets.background)
}
  • A Run Script build phase at the end will delete the preview resources in the build output of the app. Note I didn’t delete the whole bundle but only its resources content. This way it shouldn’t crash. The bundle is still there at the final build but at least it is empty.
#!/bin/bash

echo "Cleaning up preview assets..."
PREVIEW_ASSETS_PATH="${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Contents/Resources/SharedPreviewAssets_SharedPreviewAssets.bundle/Contents/Resources"

if [ ! -d "$PREVIEW_ASSETS_PATH" ]; then
    echo "Preview assets directory not found: $PREVIEW_ASSETS_PATH"
    exit 0
fi

# Check if the directory has any files before deleting
if [ -z "$(ls -A "$PREVIEW_ASSETS_PATH")" ]; then
    echo "No preview assets found to be deleted"
    exit 0
fi
    
# Proceed to delete if files are present
rm -rf "${PREVIEW_ASSETS_PATH}"/*
echo "Preview assets deleted successfully"

I didn’t end up using #if DEBUG. This way the developer experience feels a bit better and it should still be safe. This also prevents having build issues on release if you forget wrapping all previews with the compiler directive. If you do what you shouldn’t do, i.e, referencing preview assets from prod code, it will not load the assets but at least it won’t crash.

Again, not ideal and a bit hacky but seems promising in the meantime. It would be great if proper developer assets support is added to SPM.

Thanks!

1 Like
import SwiftUI

public enum PreviewAssetsResolver {

    /// Finds `PreviewAssets` folder by walking up from a starting file path.
    ///
    /// - Parameter startFilePath: Usually `#filePath`
    /// - Returns: URL to PreviewAssets folder if found
    public static func resolve(from startFilePath: String) -> URL? {
        let fm = FileManager.default

        var current = URL(fileURLWithPath: startFilePath)
            .deletingLastPathComponent()

        // Walk upward until root or Package.swift
        while true {

            // 1. Check for PreviewAssets in current directory
            let candidate = current.appendingPathComponent("PreviewAssets", isDirectory: true)

            if fm.fileExists(atPath: candidate.path) {
                return candidate
            }

            // 2. Stop if we reached Package.swift (project root anchor)
            let packageSwift = current.appendingPathComponent("Package.swift")
            if fm.fileExists(atPath: packageSwift.path) {
                return nil
            }

            // 3. Move one level up
            let parent = current.deletingLastPathComponent()

            // Stop if we cannot go further
            if parent.path == current.path {
                return nil
            }

            current = parent
        }
        return nil
    }
}

/// Executes a closure with access to the `PreviewAssets` folder.
///
/// The function searches upward from the calling file location (`#filePath`)
/// for a folder named `PreviewAssets`, stopping at `Package.swift`.
///
///
/// # Required structure
///
/// `PreviewAssets` must exist somewhere inside the package root:
///
/// ```text
/// MyPackage/
/// ├── Package.swift
/// ├── Sources/
/// │   └── Feature/
/// │       └── File.swift
/// └── PreviewAssets/
///     ├── image.png
///     └── config.json
/// ```
///
/// The folder is not part of the SPM build and is intended only for previews/dev use.
///
/// - Parameter startFilePath: Source file path (defaults to `#filePath`)
/// - Parameter perform: Closure receiving the resolved assets directory
/// - Returns: Result of the closure
public func withAssets<T>(
    from startFilePath: String = #filePath,
    perform: (URL) -> T
) -> T {

    guard let assetsURL = PreviewAssetsResolver.resolve(from: startFilePath) else {
        fatalError("PreviewAssets folder not found in package hierarchy.")
    }

    return perform(assetsURL)
}

#Preview {
    withAssets { url in
        return AsyncImage(url: url.appending(path: "test_image.png"))
    }
}

Something like this works fine in my case.

I have created the folder "PreviewAssets" that contains the assets at the root level of my SPM package. This folder is not referenced in any way by the Package.swift.

Previews can access files from the host environment, so it is possible to directly reference the URL like in the preview example.