Swift Package Manager

Overview

Swift Package Manager (SPM) provides a standard way to define and use software packages in Swift applications. SPM is preferred over other packaging options such as CocoaPods and Carthage because it is provided by Apple and is integrated into Xcode.

Packages can contain source code written in Swift, Objective-C, or C/C++, but these cannot be mixed within the same package.

Packages can be local to a specific project or remote.

Packages can depend of other packages. Dependencies on local packages are expressed using relative (preferred) or absolute file paths. Dependencies on remote packages are expressed using Git URLs.

Packages can contain unit and integration tests.

Packages typically reside in their own Git repository. These repositories can be public or private. For details on using private Git repositories see Building Swift Packages or Apps that Use Them in Continuous Integration Workflows

For example packages, see RMVGeometry and RMVSwiftUIViews.

Benefits

There are several benefits to factoring out code from a large application into packages that each reside in their own source control repository.

Terminology

It is common for the names of packages, libraries, and targets to have UpperCamelCase names. It is common for git repositories that hold the code for a package to have a kebab-case name.

The package names used by an app must be unique within that app. One way to avoid name conflicts it to add a two or three letter prefix to package names that identifiers the company or developer that created it.

Dependencies

Every Swift app and package can have dependencies on other Swift packages. Swift Package Manager manages these dependencies. It can install directly specified dependencies and the dependencies of those recursively.

Swift Package Manager honors many kinds of version constraints in order to select the Git commit of a package that:

Creating a Package

To create a new package from the command line:

This creates the directory structure and files described in the Package Contents section.

To create a new package in Xcode:

To create a local package inside an existing project for ease of testing from it:

For all types, properties, methods, and functions to be exposed by the package, change any existing access specifiers to public or add public if there is no access specifier. This includes the parameter and return types of functions. It also includes the body computed property in structs that conform to the View protocol.

For structs that are taking advantage of the default memberwise initializer (have no init method), add a public init method that takes all the struct properties as arguments and explicitly initializes all the properties.

If the package includes .xcassets files and uses image sets in them in SwiftUI Image views, add bundle: .module to those Image initializer calls.

Add DocC documentation to each type and method. To have Xcode supply as much of this as it can, select the first line of a type, method, or function and select Editor ... Structure ... Add Documentation or press cmd-option-/. After adding this documentation, rebuild the project and option-click on the name of a documented item to see the documentation in a popup.

Command-line Executable Example

The following steps generate the files required to implement a Swift command-line executable, build a development version, run unit tests, run it locally, build a production version, and copy it into the PATH:

  1. mkdir mydemo
  2. cd mydemo
  3. swift package init --type executable
  4. swift build
  5. swift test to run unit tests
  6. swift run to run as an executable (will print "Hello, World!")
  7. swift build --configuration release
  8. cp -f .build/release/mydemo /usr/local/bin
  9. mydemo

To add command-line argument parsing:

  1. Edit Package.swift.

  2. Add the following in the dependencies array:

    .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"),
  3. In the executableTarget dependencies array, add the following:

    .product(name: "ArgumentParser", package: "swift-argument-parser"),
  4. Edit the .swift file in the Sources directory and replace the contents with something like the following taken from swift-argument-parser GitHub repo:

    import ArgumentParser

    @main
    struct Repeat: ParsableCommand {
    @Flag(help: "Include a counter with each repetition.")
    var includeCounter = false

    @Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.")
    var count: Int? = nil

    @Argument(help: "The phrase to repeat.")
    var phrase: String

    func run() throws {
    let repeatCount = count ?? 1
    for i in 1 ... repeatCount {
    print((includeCounter ? "\(i): " : "") + phrase)
    }
    }
    }

For more details, see Creating a command line tool using the Swift Package Manager.

Package Contents

The initial package contents are:

Items such as classes, structs, enums, properties, methods, and functions that should be accessible outside of the package must be marked as public. Default memberwise initializers in structs are not public, so it is necessary to add public initializers.

Manifest File

The file Package.swift is the package manifest. It contains Swift code the creates a Package object. The Package initializer is passed the following arguments:

This file takes the place of having a .xcodeproj file as is present in applications. It is similar to a package.json file in a Node.js project.

Here is an example manifest for a package that defines two targets.

import PackageDescription

// This specifies the version of Swift Tools the `Package` object assumes and
// by association the minimum version of Swift needed to build this package.
// Even though it is a comment, Swift Package Manager uses it.
// swift-tools-version: 5.7

let package = Package(
name: "RMVGeometry",
defaultLocalization: "en",
platforms: [
.macOS(.v11), .iOS(.v14), .watchOS(.v7), .tvOS(.v14)
],
products: [
// Products define the executables and libraries a package produces,
// and make them visible to other packages.
.library(
name: "RMVGeometry",
targets: ["RMVCircle", "RMVGeometry"]
),
],
dependencies: [
// Dependencies describe other packages on which this package depends.
// For packages in a remote repository ...
// .package(url: some-url, from: "some-version"),
// For packages in the local file system ...
// .package(url: some-file-path, from: "some-version"),
// some-file-path can be absolute or relative.
// Relative paths are preferred so they can
// remain valid in a CI/CD environment.
],
targets: [
// A target can define a module or a test suite.
// Targets can depend on other targets in this package,
// and on products in packages this package depends on.
.target(name: "RMVCircle"),
.testTarget(name: "RMVCircleTests"),
.target(name: "RMVGeometry", dependencies: []),
.testTarget(name: "RMVGeometryTests", dependencies: ["RMVGeometry"])
]
)

Building a Package

Building a package begins by downloading all of its remote dependencies recursively.

To build a package from the command-line, enter swift build.

To build a package inside Xcode, select Product ... Build or press cmd-b.

Documentation

Package documentation is provided by the README .md file and by DocC comments in the code.

Available Features

To state that items in a library should only be available in applications that require at least a certain version of iOS, add an @available(iOS {version}, *) attribute before the item definition where {version} is an iOS version like 16.0. This is alway needed when an item uses SwiftUI.

Using GitHub

Swift packages are frequently stored in a GitHub repository. Each package must be in its own repository. To create a GitHub repository for a new package inside Xcode:

Package versions are specified with Git tags that are semantic version numbers. To add a tag to the current commit of a Swift package:

For each new version of the package to be published, commit the changes, tag the commit, and push it as described above.

To view the GitHub repository in your default web browser:

Finding Packages

There is no official package index similar to npmjs for Node.js packages. Swift Package Index is an unofficial index where you can search for open source packages. As of January 7, 2023 it provides information on 5,306 packages.

Using Packages

To use a package in an application or other package:

To update the version of all package dependencies, select File ... Packages ... Update to Latest Package Versions.

To update the version of a specific package dependency:

To see source code for a package dependency:

In source files that wish to use a package, import one or more targets defined by the package. Often a package defines a single target whose name is the same as that of the package. But the names do not always match, especially when a package defines multiple targets.

Updating Packages

To update the version of a single package dependency to the latest version that is compatible with the dependency rule for specified for the package, right-click the dependency near the bottom of the Project Navigator and select "Update Package".

To update the version of all of the application dependencies, select File ... Packages ... Update to Latest Package Versions.

Application projects contain the file {project-name}.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved. This file records the exact version of each package dependency being used. It is similar to a package-lock.json file in a Node.js project.

The Package.resolved file can be checked into version control to enable all developers on a team to use the same versions of all the dependencies. This file is updated when a new package dependency is added and when an existing package dependency is updated.

Other options in the File ... Packages menu include:

Adding Assets

To add assets such as color sets and image sets to a package:

Here is an example of a package method that returns a SwiftUI Image:

    public func comet(size: Double) -> some View {
// This looks for the image inside the .xcassets file in this package
// instead of looking in the .xcassets file of the using application.
Image(uiImage: UIImage(named: "Comet", in: .module, with: nil)!)
.resizable()
.scaledToFit()
.frame(width: size, height: size)
}

Unit Tests

Unit tests enables verifying the functionality of a package before deploying the initial version or changes to it.

Unit tests for a package reside in the Tests/{package-name}Tests directory.

The @testable attribute can be applied to an import statement. It raises the access level of the types exposed by the imported target. For example, imported types that have an access level of internal are treated as though they have an access level of public. This enables writing tests for them.

Tests must be implemented as methods inside a class that inherits from XCTestCase. The methods must have names that begin with "test".

For example, the file IconTests.swift below comes from the package GitHub repository RMVSwiftUIViews.

@testable import RMVSwiftUIViews
import XCTest

final class IconTests: XCTestCase {
func testEnumCases() throws {
XCTAssertEqual(Icon.baseball.rawValue, "baseball")
XCTAssertEqual(Icon.bell.rawValue, "sf-bell")
XCTAssertEqual(Icon.thumbsUp.rawValue, "sf-hand.thumbsup")
}
}

The file IconToggleTests.swift below comes from the same package GitHub repository. It demonstrates creating an instance of a custom View in a test, including passing it a Binding.

@testable import RMVSwiftUIViews
import SwiftUI
import XCTest

final class RMVSwiftUIViewsTests: XCTestCase {
func testDefaultParameters() throws {
var bindingValue = false
let binding = Binding(
get: { bindingValue },
set: { bindingValue = $0 }
)
let view = IconToggle(icon: .football, isOn: binding)
XCTAssertEqual(view.color, Color.black)
XCTAssertEqual(view.size, 20)
}

func testSuppliedParameters() throws {
var bindingValue = false
let binding = Binding(
get: { bindingValue },
set: { bindingValue = $0 }
)
let color: Color = .red
let size = 50.0
let view = IconToggle(
icon: .football,
color: color,
size: size,
isOn: binding
)
XCTAssertEqual(view.color, color)
XCTAssertEqual(view.size, size)
}
}

To run all of the tests in a package inside Xcode, select Product ... Test or press cmd-u.

To run a specific test function, click the diamond icon to the left of the first line in the function definition These can be found in the left gutter where line numbers typically appear. If no diamond icons appear, close the test source file and open it again. If the test passes, the diamond will turn green with a white checkmark inside. If the test fails, the diamond will turn red with a white "X" inside.

To run all the tests in an XCTestCase subclass, click the diamond icon to the left of the first line in the class definition.

Tests can also be run from the Test Navigator which is the sixth navigator tab. To run a specific test function, click the diamond icon to the right of the function name. To run all the tests in an XCTestCase subclass, click the diamond icon to the right of the class name.

For packages that target macOS or Linux (not other platforms like iOS), the following commands can be run from the command-line. To list all of the tests in a package, enter swift test --list-tests or swift test -l. To run all of the tests in a package, enter swift test. To run the tests in parallel, add the --parallel option.

To run tests in a specific iOS or watchOS Simulator, create a shell script containing the following command. TODO: Can this script can be used to run the tests in a headless fashion in a CI/CD server?

xcodebuild \
-scheme RMVSwiftUIViews \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=16.2' \
test

The XCTest framework relies on the Objective-C runtime to find the test methods to be run. When running in Linux, this is not available. In a Linux environment it is necessary to define a static variable named allTests that is set to an array of strings that are the names of the test methods to run. This array must be updated whenever new test methods are added. It is possible to automate this by running the command swift test --generate-linuxmain.

Resources

A package can include resources that are files other that Swift source files. These include .xcassets files that hold images, .strings and .stringsdict files for localization, Core Data model files, and more.

The following package.swift manifest file from the RMVSwiftUIViews package demonstrates including the file Assets.xcassets which contains several Image Sets. Note the line that begins with "resources:".

// swift-tools-version: 5.7
import PackageDescription

let package = Package(
name: "RMVSwiftUIViews",
platforms: [.iOS(.v13)],
products: [
// This package defines a single library that defines a single target.
.library(
name: "RMVSwiftUIViews",
targets: ["RMVSwiftUIViews"]
),
],
dependencies: [], // This package doesn't depend on any others.
targets: [
// This target will be imported by apps.
.target(
name: "RMVSwiftUIViews",
dependencies: [],
resources: [.process("Assets.xcassets")]
),
// This target contains unit tests for the target above.
.testTarget(
name: "RMVSwiftUIViewsTests",
dependencies: ["RMVSwiftUIViews"]
),
]
)

The following method from the Icon enum defined in this package demonstrates getting images from both the package bundle and the bundle of an app that uses this package.

func image(fill: Bool) -> Image {
let suffix = fill ? ".fill" : ""
if rawValue.prefix(3) == "sf-" {
let systemName = String(rawValue.dropFirst(3)) + suffix
return Image(systemName: systemName) // gets from SF Symbols
}
return rawValue == "custom" ?
Image(rawValue + suffix) : // gets from the app bundle
Image(rawValue + suffix, bundle: .module) // gets from this bundle
}

To add a text file to the package as a resource, modify the resources array passed to the first target in package.swift as follows:

            resources: [
.process("Assets.xcassets"),
.copy("demo.csv") // a text file
]

Alternatively, place all the resource files in a subdirectory of the package named Resources and make the following change to the resources array. With this change, additional resource files can be added in the Resources directory and it will not be necessary to add a reference to each one in package.swift.

            resources: [
.process("Assets.xcassets"),
.process("Resources")
]

See the discussion about the differences between the process and copy methods in the Adding Assets section.

To read the resource text file from code in the package:

let filePath = Bundle.module.path(
forResource: "myFileName",
ofType: "csv"
)
if let filePath {
let contents = try String(contentsOfFile: filePath)
// Do something with contents.
}

Excluding Resources

Some files that are part of the package project do not need to be included in the bundle that is produced. These include documentation files, code examples, and shell scripts, but not files used in unit tests.

To exclude specific files from the bundle, add an exclude argument to the target method whose value is an array of relative file path strings. For example:

        .target(
...
exclude: ["notes.md"],
...
),

Localized Resources

To include a resource that requires a different file for each supported language, add one directory inside the Sources/{package-name} directory for each language with the name {language-code}.lproj. For example, en.lproj for English and fr.lproj for French. Then add files in these directories where the corresponding files have the same name (ex. message.txt).

SwiftUI Package Localized Resources
Swift Package Localized Resources

Note that there is a message.txt file for each supported language. The file in the en.lproj directory contains "Hello". The file in the es.lproj directory contains "Hola". The file in the fr.lproj directory contains "Bonjour".

The .lproj directories cannot contain subdirectories.

The package.swift manifest file is required to specify defaultLocalization when there are localized resources. For example, defaultLocalization: "en",.

It seems there is no way to change the locale used by a test inside the test code. To change the locale that will be used when tests are run:

The following code defines a function to be tested. It reads and returns the contents of a localized resource file.

import Foundation

extension String: LocalizedError {
// Allows String values to be thrown.
public var errorDescription: String? { self }
}

public func message() throws -> String {
let filePath = Bundle.module.path(forResource: "message", ofType: "txt")
guard let filePath else { throw "file not found" }
let line = try String(contentsOfFile: filePath)
return line.trimmingCharacters(in: .whitespacesAndNewlines)
}

The following test code demonstrates detecting the current language and using it to determine the expected value. Run this test once for each supported language by following the steps above to select the language to use.

final class LocalizeTests: XCTestCase {
func testMessage() throws {
let lang = Locale.preferredLanguages.first
guard let lang else { throw "language not set" }
let actual = try message()
let expected =
lang == "en" ? "Hello" :
lang == "es" ? "Hola" :
lang == "fr" ? "Bonjour" :
"unsupported language \(lang)"
XCTAssertEqual(actual, expected)
}
}