In the ever-evolving landscape of app development, staying ahead of the curve often requires embracing modular architectures. The benefits are numerous, but complexities can arise when tools struggle to keep pace. One such challenge that has surfaced is SwiftUI Previews in multi-module projects, particularly when leveraging Swift Packages that contain resources.
The SwiftUI Previews Dilemma
Imagine building an app with SwiftUI, breaking it into several isolated modules using Swift Packages, and introducing multiple levels of dependencies.
A common issue emerges when SwiftUI Previews in one module, let's say AwesomeFeature
, fail to work due to dependencies, such as DesignLibrary
.
The problem lies in loading resources like images from the bundle, causing a headache for developers.
Here's a simplified scenario:
// In DesignLibrary
public struct CustomView: View {
var body: some View {
// Fails in cross-module SwiftUI Previews
Image("customImage", bundle: .module)
}
}
Xcode generates a bundle extension for Swift Packages like this:
import class Foundation.Bundle
import class Foundation.ProcessInfo
import struct Foundation.URL
private class BundleFinder {}
extension Foundation.Bundle {
/// Returns the resource bundle associated with the package Swift module.
static let module: Bundle = {
let bundleName = "DesignLibrary_DesignLibrary"
let overrides: [URL]
#if DEBUG
// The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The
// check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over.
// This removal is tracked by rdar://107766372.
if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"]
?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
overrides = [URL(fileURLWithPath: override)]
} else {
overrides = []
}
#else
overrides = []
#endif
let candidates = overrides + [
// 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: BundleFinder.self).resourceURL,
// For command-line tools.
Bundle.main.bundleURL,
]
for candidate in candidates {
let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
return bundle
}
}
fatalError("unable to find bundle named DesignLibrary_DesignLibrary")
}()
}
However, this generated code may miss crucial directories used by previews, leading to bundle loading issues.
Manual Solutions - Not Ideal
A manual solution involves creating custom bundle extensions for each module, addressing the missing directories. While effective, this approach contradicts the modular principle of avoiding repetition. This is where Xcode's build plugins come to the rescue.
Creating the Xcode Build Plugin
To resolve this challenge systematically, we can create a Xcode Build Plugin. This special type of package can generate the necessary code for each module during the build process.
1. Package.swift
Start by creating a Build Tool Plug-in
via File > New > Package
. The Package.swift
manifest looks like this:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "PackageBundlePlugin",
products: [
.plugin(
name: "PackageBundlePlugin",
targets: ["PackageBundlePlugin"]
),
],
targets: [
.plugin(
name: "PackageBundlePlugin",
capability: .buildTool(),
dependencies: ["PackageBundleGenerator"]
),
.executableTarget(name: "PackageBundleGenerator"),
]
)
2. PackageBundlePlugin.swift
The source code of the plugin (Plugins/PackageBundlePlugin/PackageBundlePlugin.swift
) acts as a bridge between Xcode's build pipeline and the executable:
import PackagePlugin
@main
struct PackageBundlePlugin: BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
guard target.sourceModule != nil else { return [] }
let output = context.pluginWorkDirectory.appending(subpath: "PackageBundle.g.swift")
return [
.buildCommand(
displayName: "Generating package bundle extension for \(target.name)",
executable: try context.tool(named: "PackageBundleGenerator").path,
arguments: [
context.package.displayName,
target.name,
output,
],
inputFiles: [],
outputFiles: [output]
)
]
}
}
3. Executable - main.swift
Finally, the executable (Sources/PackageBundleGenerator/main.swift
) generates the file containing the parameterized extension:
import Foundation
// Some insight into the problem and the implemented solution.
// https://developer.apple.com/forums/thread/664295
let arguments = ProcessInfo().arguments
let (packageName, targetName, output) = (arguments[1], arguments[2], arguments[3])
let generatedCode = """
import class Foundation.Bundle
import class Foundation.ProcessInfo
import struct Foundation.URL
private class BundleFinder {}
extension Bundle {
public static let package: Bundle = {
let bundleName = "\(packageName)_\(targetName)"
let overrides: [URL]
#if DEBUG
// The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The
// check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over.
// This removal is tracked by rdar://107766372.
if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"]
?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] {
overrides = [URL(fileURLWithPath: override)]
} else {
overrides = []
}
#else
overrides = []
#endif
var candidates = overrides + [
// 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: BundleFinder.self).resourceURL,
// For command-line tools.
Bundle.main.bundleURL
]
// FIX FOR PREVIEWS
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
candidates.append(contentsOf: [
// Bundle should be present here when running previews from a different package
Bundle(for: BundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent(),
Bundle(for: BundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
])
}
for candidate in candidates {
let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
return bundle
}
}
fatalError("unable to find bundle named \(packageName)_\(targetName)")
}()
}
"""
try generatedCode.write(to: URL(fileURLWithPath: output), atomically: true, encoding: .utf8)
Using the Build Plugin
With the build plugin in place, integrate it into your packages.
For instance, referencing it in DesignLibrary
would look like this:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "DesignLibrary",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
.library(
name: "DesignLibrary",
targets: ["DesignLibrary"]
),
],
dependencies: [
.package(path: "../PackageBundlePlugin"),
],
targets: [
.target(
name: "DesignLibrary",
resources: [.process("Resources")],
plugins: [
.plugin(name: "PackageBundlePlugin", package: "PackageBundlePlugin"),
]
),
.testTarget(
name: "DesignLibraryTests",
dependencies: ["DesignLibrary"]
),
]
)
Xcode will seamlessly execute the plugin during the build, generating the required extension for the custom .package
bundle.
Once you replace the .module
bundle usages with the .package
bundle, SwiftUI Previews should function flawlessly across modules, regardless of their dependencies.
Conclusion
In essence, Xcode build plugins provide the flexibility to enhance the build process according to your preferences. Whether it's code generation, linting tasks, or code analysis – the choice is yours.
As always, we hope you liked this article, and if you have anything to add, we are available via our Support Channel.