The to-do app from the earlier articles in this series lives in one Xcode project with one package beside it. A second app that needs the same code has two poor options: copy the files, or point a second project at a folder inside the first. A monorepo, one repository holding several apps and the code they share, avoids both, and an Xcode workspace ties it together: it lists projects and packages, opens them in one window, and builds them as one.
This article turns the todo-list folder from
The Xcode Build Process from the Command Line, with make
into a folder called my-apps that holds two iOS apps, the existing
TodoList and a new Shopping list, and three local Swift packages:
TodoKit (the to-do model), CoreKit (a text-cleaning rule both apps use), and
DesignSystem (a checklist row both apps draw). One workspace,
MyApps.xcworkspace, holds all five. At the end you will change one line in
DesignSystem, rebuild with make build, and run each app to see both pick up
the change on the simulator. A top-level Makefile tests every package in dependency order,
builds every app, runs the UI test, and runs or archives any one app with
APP=.
What you end up with
my-apps/MyApps.xcworkspace, listing two app projects underApps/and three packages underPackages/.- App projects that name the package products they link and record no path to any package, so the workspace is the only place an app finds its packages.
CoreKitused byTodoKitand by Shopping, andDesignSystemused by both apps.- Shared schemes for both apps, saved in the repository.
- A
Makefilewithmake testfor everything andAPP=TodoList|Shoppingforsettings,run,screenshot, andarchive.
Prerequisites
- The finished project from
The Xcode Build Process from the Command Line, with make:
the
todo-listfolder with theTodoListproject, theTodoKitpackage, theConfigfolder of.xcconfigfiles, theMakefile, and theTodoList-iPhonesimulator. Its UI test leaves a to-do named “Buy milk”, marked done, in the app, which Step 11 shows. - Xcode 27 or later on a Mac with Apple silicon. Validated on Xcode 27.1 (build 27A9275) with Swift 6.4, on macOS 27.0.1.
- Steps 2, 3, 6, 7, 8 and 9 are done in Xcode; a command after each one checks the result.
Run the commands in Step 1 from the folder that contains todo-list, and
every later command from inside my-apps.
Step 1: Move the app and package into a monorepo layout
A monorepo needs a place for each kind of thing, so that a third app or a
fourth package has an obvious home. This article uses two folders: Apps/ for
Xcode app projects and Packages/ for Swift packages. Start by deleting the old
build output, because build/ caches full paths to the folders you are about
to move. Then rename the folder and move its contents:
make -C todo-list clean
mv todo-list my-apps
cd my-apps
mkdir Apps Packages
mv TodoList Config Apps/
mv TodoKit Packages/
Config moves into Apps/ beside TodoList on purpose. The project records
each .xcconfig file as ../Config/<name>, relative to the folder that holds
TodoList.xcodeproj. Moving both folders together keeps that path true.
The package path did not survive the move. The project recorded TodoKit as
../TodoKit (Build a Swift Package with Swift Package Manager on macOS
showed the relativePath line in project.pbxproj), and TodoKit is no
longer beside the project. Building the project now fails. grep keeps the error lines and sed shortens
the current folder to .:
xcodebuild -project Apps/TodoList/TodoList.xcodeproj -scheme TodoList -derivedDataPath build -destination 'platform=iOS Simulator,name=TodoList-iPhone' -quiet build 2>&1 | grep -A1 'error:' | sed "s#$PWD#.#g"
xcodebuild: error: Could not resolve package dependencies:
the package at './Apps/TodoKit' cannot be accessed (./Apps/TodoKit doesn't exist in file system)
You could repair the path in Xcode. The next two steps remove it instead, so that no app project records where a package lives.
Step 2: Create the workspace
A workspace is a folder ending in .xcworkspace whose
contents.xcworkspacedata file lists the projects and packages it contains,
each by a path relative to the workspace. Building through a workspace makes
every package in it available to every project in it. This is a separate,
top-level workspace, not the private project.xcworkspace every .xcodeproj
already contains. Create it in Xcode:
- Choose File → New → Workspace….
- Name it
MyApps, choose themy-appsfolder, and click Save. Xcode opens an empty workspace window.
Add the package and the project with File → Add Files to “MyApps”…, once for each:
- Select the
Packages/TodoKitfolder and click Add. - In the options sheet that appears, leave Action at Reference files in place and click Finish.
- Click an empty area of the Project navigator, the file list on the left, so nothing is selected. With a row selected, the next item is added inside that row instead of at the top of the workspace.
- Repeat with
Apps/TodoList/TodoList.xcodeproj.
The options sheet should show only Action and Targets. If it also shows Groups, Xcode is about to add the item inside a project rather than to the workspace: click Cancel, clear the selection, and try again.
Check what Xcode wrote:
cat MyApps.xcworkspace/contents.xcworkspacedata
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Packages/TodoKit">
</FileRef>
<FileRef
location = "group:Apps/TodoList/TodoList.xcodeproj">
</FileRef>
</Workspace>
group: means relative to the workspace’s own folder, my-apps. These two
lines are now the only record of where the package and the project live.
Xcode keeps your window layout in MyApps.xcworkspace/xcuserdata/, which the
.gitignore from
Build Your First SwiftUI App on macOS already
ignores, so the workspace needs no new entry there.
Step 3: Let the workspace supply TodoKit
The project still holds its own broken reference to ../TodoKit. Delete it, so
that the app finds TodoKit through the workspace:
- In the Project navigator, click TodoList, the blue project icon.
- In the editor, select TodoList under PROJECT (not under TARGETS), then the Package Dependencies tab.
- Select the TodoKit row, whose location is
../TodoKit, and click the − button below the list.
Xcode removes the reference and keeps the app target’s link to the TodoKit
product. Check both:
grep -c relativePath Apps/TodoList/TodoList.xcodeproj/project.pbxproj
grep -o 'productName = TodoKit;' Apps/TodoList/TodoList.xcodeproj/project.pbxproj
0
productName = TodoKit;
The project now says only “link the product named TodoKit”. The workspace
says where that product comes from, which is why the project no longer builds
on its own:
xcodebuild -project Apps/TodoList/TodoList.xcodeproj -scheme TodoList -derivedDataPath build -destination 'platform=iOS Simulator,name=TodoList-iPhone' -quiet build 2>&1 | grep 'error:' | sed "s#$PWD#.#g"
./Apps/TodoList/TodoList.xcodeproj: error: Missing package product 'TodoKit' (in target 'TodoList' from project 'TodoList')
Built through the workspace, it succeeds, and xcodebuild -list shows a scheme
for the package as well as the app:
xcodebuild -workspace MyApps.xcworkspace -scheme TodoList -derivedDataPath build -destination 'platform=iOS Simulator,name=TodoList-iPhone' -quiet build
xcodebuild -list -quiet -workspace MyApps.xcworkspace
Information about workspace "MyApps":
Schemes:
TodoKit
TodoList
The build prints nothing when it succeeds, though it may print the
IDERunDestination log line the previous article described; the output blocks
in this article leave that line out. From here on, every build goes through
-workspace. Moving a package means changing one line in
contents.xcworkspacedata, not every app project that uses it.
Step 4: Create the CoreKit package
CoreKit holds code with no knowledge of to-dos or shopping. Its first rule is
the one TodoKit already has for titles: trim the whitespace a person typed
around an entry, and reject an entry that is blank once trimmed. Shopping needs
the same rule for item names, so it moves to a package both can use. Create
the package with SwiftPM, as the package article did for TodoKit, and delete
the two template files the listings below replace:
mkdir Packages/CoreKit
cd Packages/CoreKit
swift package init --type library
cd ../..
rm Packages/CoreKit/Sources/CoreKit/CoreKit.swift Packages/CoreKit/Tests/CoreKitTests/CoreKitTests.swift
Creating library package: CoreKit
Creating Package.swift
Creating .gitignore
Creating Sources
Creating Sources/CoreKit/CoreKit.swift
Creating Tests/
Creating Tests/CoreKitTests/
Creating Tests/CoreKitTests/CoreKitTests.swift
Create the file
touch Packages/CoreKit/Package.swift
The file exists already. Replace its contents with the listing below.
Add the code: Packages/CoreKit/Package.swift
// swift-tools-version: 6.4
import PackageDescription
let package = Package(
name: "CoreKit",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "CoreKit", targets: ["CoreKit"]),
],
targets: [
.target(
name: "CoreKit",
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
.testTarget(
name: "CoreKitTests",
dependencies: ["CoreKit"],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
]
)
Create the file
touch Packages/CoreKit/Sources/CoreKit/TextInput.swift
Add the code: Packages/CoreKit/Sources/CoreKit/TextInput.swift
import Foundation
/// Rules for text a person types into an app.
public enum TextInput {
/// Trims surrounding whitespace and returns nil when nothing is left,
/// so a blank entry can be rejected before it is stored.
public static func cleaned(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
Create the file
touch Packages/CoreKit/Tests/CoreKitTests/TextInputTests.swift
Add the code: Packages/CoreKit/Tests/CoreKitTests/TextInputTests.swift
import Testing
import CoreKit
struct TextInputTests {
@Test(arguments: [
("Apples", "Apples"),
(" Oat milk ", "Oat milk"),
("\tBread ", "Bread"),
])
func textIsTrimmed(raw: String, expected: String) {
#expect(TextInput.cleaned(raw) == expected)
}
@Test(arguments: ["", " ", "\t"])
func blankTextIsRejected(raw: String) {
#expect(TextInput.cleaned(raw) == nil)
}
}
Detailed breakdown
- The manifest matches
TodoKit’s. The sameplatformsminimums let any app in the workspace use it, and macOS 14 letsswift testrun its tests on the Mac without a simulator. The template’s comments are dropped; the package article explained each part. TextInputis anenumwith no cases. It cannot be instantiated, so it works as a namespace: callers writeTextInput.cleaned(raw), which says where the rule comes from.publicmakes it visible to code outside theCoreKitmodule; without it, neither app norTodoKitcould call it.- The tests cover both halves of the rule, trimming and rejecting. Swift
Testing’s
arguments:runs each test once per input; each run is a test case, which is why the output counts 2 tests but 3 test cases each. Step 11 breaks this function on purpose to show howmakereports a failure here. - SwiftPM wrote a
.gitignoreinto the package. It ignores the package’s.build/folder, which the top-level.gitignorealso covers.
Step 5: Create the DesignSystem package
A design system is a set of shared visual building blocks that keeps
several apps looking alike. This one starts with a single view, CheckRow: a
title with a symbol showing whether the item is done. The to-do app draws that
row today in its own TodoRow view; moving it here lets Shopping draw the
same row. Create the package the same way:
mkdir Packages/DesignSystem
cd Packages/DesignSystem
swift package init --type library
cd ../..
rm Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift Packages/DesignSystem/Tests/DesignSystemTests/DesignSystemTests.swift
Create the file
touch Packages/DesignSystem/Package.swift
The file exists already. Replace its contents with the listing below.
Add the code: Packages/DesignSystem/Package.swift
// swift-tools-version: 6.4
import PackageDescription
let package = Package(
name: "DesignSystem",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "DesignSystem", targets: ["DesignSystem"]),
],
targets: [
.target(
name: "DesignSystem",
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
.testTarget(
name: "DesignSystemTests",
dependencies: ["DesignSystem"],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
]
)
Create the file
touch Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
Add the code: Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
import SwiftUI
/// One line of a checklist: a title, and a symbol that shows whether it is done.
public struct CheckRow: View {
let title: String
let isDone: Bool
public init(title: String, isDone: Bool) {
self.title = title
self.isDone = isDone
}
public var body: some View {
Label {
Text(title)
.strikethrough(isDone)
.foregroundStyle(isDone ? .secondary : .primary)
} icon: {
Image(systemName: Self.symbolName(isDone: isDone))
}
}
/// The SF Symbol for each state, public so the tests can check it.
nonisolated public static func symbolName(isDone: Bool) -> String {
isDone ? "checkmark.circle.fill" : "circle"
}
}
Create the file
touch Packages/DesignSystem/Tests/DesignSystemTests/CheckRowTests.swift
Add the code: Packages/DesignSystem/Tests/DesignSystemTests/CheckRowTests.swift
import AppKit
import Testing
import DesignSystem
struct CheckRowTests {
// Image(systemName:) draws nothing for a name that does not exist, and
// reports no error, so look each name up in the Mac's symbol library.
@Test(arguments: [false, true])
func symbolExists(isDone: Bool) {
let name = CheckRow.symbolName(isDone: isDone)
#expect(NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil)
}
@Test func doneAndNotDoneDiffer() {
#expect(CheckRow.symbolName(isDone: true) != CheckRow.symbolName(isDone: false))
}
}
Detailed breakdown
CheckRowis the oldTodoRowwith plain values.TodoRowtook aTodoItem;CheckRowtakes a title and a done flag, so it does not depend onTodoKitand an app with a different model, such as Shopping, can use it.- A public
initis required. Swift generates a memberwise initializer for a struct, but it isinternal, usable only inside theDesignSystemmodule. Without the explicitpublic init, the apps could seeCheckRowand still not create one. - The symbols are SF Symbols, Apple’s library of icons that ship with the
system, named by strings such as
checkmark.circle.fill. A misspelled name compiles without a warning and shows an empty space.symbolName(isDone:)is public so the test can read the names, and the test looks each one up withNSImage, which returnsnilfor an unknown name. The test target importsAppKitbecause the tests run on the Mac withswift test; the package itself uses only SwiftUI. Running theDesignSystemscheme’s tests on an iOS simulator from Xcode would fail, because iOS has noAppKit. nonisolatedlets the tests callsymbolName. A SwiftUI view runs on the main actor, so its members are main-actor-isolated by default, and the test structs are not. Withoutnonisolated, each call in the tests produceswarning: call to main actor-isolated static method 'symbolName(isDone:)' in a synchronous nonisolated context, which theMakefile’s result filter would hide. The function only returns a string, so it needs no actor.doneAndNotDoneDifferguards against both states being given the same symbol, which would make the row’s state invisible.
Step 6: Add the packages to the workspace, then connect TodoKit to CoreKit
The two new packages are folders on disk that nothing refers to yet. Add each
to the workspace with File → Add Files to “MyApps”…, as in Step 2: clear
the selection in the Project navigator first, pick Packages/CoreKit, click
Add, check that the sheet shows no Groups row, and click Finish.
Then do the same for Packages/DesignSystem.
grep location MyApps.xcworkspace/contents.xcworkspacedata
location = "group:Packages/TodoKit">
location = "group:Apps/TodoList/TodoList.xcodeproj">
location = "group:Packages/CoreKit">
location = "group:Packages/DesignSystem">
Now make TodoKit use CoreKit’s rule instead of its own copy. A package
cannot see the workspace, so it names the packages it depends on in its own
manifest, by path. This is the one kind of package path that stays in the
repository, and it points sideways within Packages/, so it survives moving
Packages/ as a whole.
Create the file
touch Packages/TodoKit/Package.swift
The file exists already. Replace its contents with the listing below.
Add the code: Packages/TodoKit/Package.swift
// swift-tools-version: 6.4
import PackageDescription
let package = Package(
name: "TodoKit",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "TodoKit", targets: ["TodoKit"]),
],
dependencies: [
.package(path: "../CoreKit"),
],
targets: [
.target(
name: "TodoKit",
dependencies: ["CoreKit"],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
.testTarget(
name: "TodoKitTests",
dependencies: ["TodoKit"],
swiftSettings: [
.enableUpcomingFeature("ApproachableConcurrency"),
]
),
]
)
Create the file
touch Packages/TodoKit/Sources/TodoKit/TodoItem.swift
The file exists already. Replace its contents with the listing below; only the
import CoreKit line and the body of cleanedTitle change.
Add the code: Packages/TodoKit/Sources/TodoKit/TodoItem.swift
import CoreKit
import Foundation
import SwiftData
@Model
public final class TodoItem {
public var title: String
public var isDone: Bool
public var createdAt: Date
public init(title: String, createdAt: Date = .now) {
self.title = title
self.isDone = false
self.createdAt = createdAt
}
/// Trims surrounding whitespace and returns nil when nothing is left,
/// so a blank entry can never become a to-do.
public static func cleanedTitle(_ raw: String) -> String? {
TextInput.cleaned(raw)
}
/// The line shown under the list: what is left to do.
public static func summary(of items: [TodoItem]) -> String {
let remaining = items.filter { !$0.isDone }.count
if items.isEmpty { return "Nothing to do" }
if remaining == 0 { return "All done" }
return "\(remaining) remaining"
}
}
extension ModelContext {
/// Inserts a new to-do if the title is not blank, and returns it.
@discardableResult
public func addTodo(titled raw: String) -> TodoItem? {
guard let title = TodoItem.cleanedTitle(raw) else { return nil }
let item = TodoItem(title: title)
insert(item)
return item
}
}
Detailed breakdown
- A package dependency has two halves. The package-level
dependenciesentry,.package(path: "../CoreKit"), tells SwiftPM where to find the package. The target-leveldependencies: ["CoreKit"]says which target imports which product. Leave out the second andimport CoreKitfails withno such module 'CoreKit'. - The path is relative to
TodoKit’s folder, so../CoreKitisPackages/CoreKit.swift test --package-path Packages/TodoKitfollows it on its own; Xcode follows it too, and finds the same folder the workspace lists. cleanedTitlekeeps its name and its tests. The app and the fiveTodoKittests still callTodoItem.cleanedTitle, so they now testCoreKit’s rule throughTodoKit, with no change to either.- Add the packages to the workspace before this edit. Once
TodoKitdepends onCoreKit, the Project navigator showsCoreKitin its own Package Dependencies section. During validation, an attempt to addCoreKitto the workspace after this edit showed the Groups row from Step 2, with a project selected; clearing the selection fixed it. Adding the packages first keeps that section out of the way.
Run the three packages’ tests on the Mac:
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/CoreKit 2>&1 | tail -1
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/DesignSystem 2>&1 | tail -1
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/TodoKit 2>&1 | tail -1
✔ Test run with 2 tests in 1 suite passed after 0.001 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.019 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.
SWT_SF_SYMBOLS_ENABLED=0 makes Swift Testing print ✔ and ✘; without it,
each line starts with an SF Symbol that most terminals cannot draw. Your
timings will differ.
Step 7: Create the Shopping app inside the workspace
The second app shows the shared packages work for more than one project. Create it with Xcode’s App template, as the first article did for TodoList, but let the wizard put it into the workspace:
- Choose File → New → Project…, then Choose Template… at the bottom of the palette, then the iOS tab, App, and Next.
- Set Product Name to
Shopping, Team to None, Organization Identifier tocom.example(check it: Xcode reuses the last one you typed), Interface to SwiftUI, Language to Swift, Testing System to None, and Storage to None. The bundle identifier below the fields readscom.example.Shopping. Click Next. - In the save panel, open
my-apps/Apps, leave Create Git repository on my Mac unchecked, set Add to to MyApps and Group to MyApps, and click Create.
Shopping has no test target because its logic lives in the packages, which have their own tests. Check where it landed:
grep -c Shopping MyApps.xcworkspace/contents.xcworkspacedata
ls Apps/Shopping Apps/Shopping/Shopping
1
Apps/Shopping:
Shopping
Shopping.xcodeproj
Apps/Shopping/Shopping:
Assets.xcassets
ContentView.swift
ShoppingApp.swift
Link the two package products it uses. In Xcode, the app target’s Build Phases tab holds the same list as the Frameworks, Libraries, and Embedded Content section of the General tab. This article uses Build Phases, because showing the General tab can write settings into the target; Step 8 shows where that matters.
- Click Shopping, the blue project icon in the Project navigator, select the Shopping target under TARGETS, and open Build Phases.
- Expand Link Binary With Libraries and click +.
- The sheet lists a Workspace section with
CoreKit,DesignSystemandTodoKit. Expand CoreKit, select the CoreKit library inside it, and click Add. - Click + again and add the DesignSystem library the same way.
grep -E 'productName = (CoreKit|DesignSystem);|relativePath' Apps/Shopping/Shopping.xcodeproj/project.pbxproj
productName = CoreKit;
productName = DesignSystem;
Two product names and no relativePath: Shopping, like TodoList after Step 3,
relies on the workspace to find its packages. Xcode also added an empty
Frameworks group to the project; it does no harm.
Now write the app. The template’s ShoppingApp.swift already shows
ContentView, so only that file changes.
Create the file
touch Apps/Shopping/Shopping/ContentView.swift
The file exists already. Replace its contents with the listing below.
Add the code: Apps/Shopping/Shopping/ContentView.swift
import SwiftUI
import CoreKit
import DesignSystem
struct ShoppingItem: Identifiable {
let id = UUID()
var name: String
var isBought = false
}
struct ContentView: View {
// Kept in memory, so every launch starts from these two items.
@State private var items = [
ShoppingItem(name: "Apples"),
ShoppingItem(name: "Bread", isBought: true),
]
@State private var newName = ""
var body: some View {
NavigationStack {
List {
Section {
HStack {
TextField("New item", text: $newName)
.onSubmit(add)
Button("Add", action: add)
.disabled(TextInput.cleaned(newName) == nil)
}
}
Section {
ForEach($items) { $item in
Button {
item.isBought.toggle()
} label: {
CheckRow(title: item.name, isDone: item.isBought)
}
.buttonStyle(.plain)
}
.onDelete { items.remove(atOffsets: $0) }
}
}
.navigationTitle("Shopping")
}
}
private func add() {
guard let name = TextInput.cleaned(newName) else { return }
items.append(ShoppingItem(name: name))
newName = ""
}
}
#Preview {
ContentView()
}
Detailed breakdown
- Both imports are workspace packages.
TextInput.cleaneddecides whether Add is enabled and what gets stored, the same rule TodoList applies to to-do titles throughTodoKit.CheckRowdraws each item. - The list lives in
@State, so it is kept in memory and resets on every launch. That keeps this app to one file; the two starting items, one bought and one not, show both ofCheckRow’s states as soon as the app opens. ForEach($items) { $item in ... }iterates over bindings, so tapping a row can flipisBoughtin place..buttonStyle(.plain)stops the button from tinting the row’s text in the accent color. Without it, Shopping’s titles turn blue while TodoList’s stay black, so the shared row looks different in each app.
Step 8: Use CheckRow in TodoList
TodoList links TodoKit already. It needs DesignSystem too, and then its own
TodoRow can go. Link the library through Build Phases, not the General
tab. In Xcode 27.1, merely showing the General tab for this target wrote
INFOPLIST_KEY_CFBundleDisplayName = "To-Do Dev" into both the Debug and
Release settings of the TodoList target. Target settings sit above the
project’s .xcconfig files, so Release builds were then named To-Do Dev as
well. If the editor opens on General when you select the target, switch
tabs and run the check below.
- Click TodoList, the blue project icon, select the TodoList target, and open the Build Phases tab.
- Expand Link Binary With Libraries, click +, expand DesignSystem, select its library, and click Add.
make settings CONFIG=Release in Step 11 checks the display name again, and
Troubleshooting has the fix.
grep -c CFBundleDisplayName Apps/TodoList/TodoList.xcodeproj/project.pbxproj
grep -o 'productName = [A-Za-z]*;' Apps/TodoList/TodoList.xcodeproj/project.pbxproj
0
productName = TodoList;
productName = TodoListUITests;
productName = TodoKit;
productName = DesignSystem;
The first two productName lines are the targets’ own; the last two are the
linked packages.
Create the file
touch Apps/TodoList/TodoList/ContentView.swift
The file exists already. Replace its contents with the listing below: it
imports DesignSystem, draws CheckRow inside each NavigationLink, and no
longer defines TodoRow.
Add the code: Apps/TodoList/TodoList/ContentView.swift
import SwiftUI
import SwiftData
import DesignSystem
import TodoKit
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \TodoItem.createdAt) private var items: [TodoItem]
@State private var newTitle = ""
var body: some View {
NavigationStack {
List {
Section {
HStack {
TextField("New to-do", text: $newTitle)
.accessibilityIdentifier("newTodoField")
.onSubmit(add)
Button("Add", action: add)
.accessibilityIdentifier("addTodoButton")
.disabled(TodoItem.cleanedTitle(newTitle) == nil)
}
}
Section {
ForEach(items) { item in
NavigationLink(value: item) {
CheckRow(title: item.title, isDone: item.isDone)
}
}
.onDelete(perform: delete)
} footer: {
Text(TodoItem.summary(of: items))
.accessibilityIdentifier("summary")
}
}
.navigationTitle("To-Do")
.navigationDestination(for: TodoItem.self) { item in
TodoDetailView(item: item)
}
}
}
private func add() {
if modelContext.addTodo(titled: newTitle) != nil {
newTitle = ""
save()
}
}
private func delete(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(items[index])
}
save()
}
// SwiftData autosaves only now and then; save explicitly so a
// crash or force-quit right after a change does not lose it.
private func save() {
do {
try modelContext.save()
} catch {
print("Save failed: \(error)")
}
}
}
#Preview {
ContentView()
.modelContainer(for: TodoItem.self, inMemory: true)
}
Detailed breakdown
CheckRow(title: item.title, isDone: item.isDone)replacesTodoRow(item: item). The row looks the same as before, becauseCheckRowisTodoRow’s body moved into the package.- The UI test still finds the row. It looks for a button labeled with the
to-do’s title, and a
NavigationLinkcontaining aLabelis still a button with that label. import DesignSystemneeds the link from the steps above. Without it, the import fails, usually withno such module 'DesignSystem'.
Step 9: Share the app schemes
Each scheme names what to build and how to run, test, and archive it;
make test relies on the TodoList scheme’s test action to run the UI test. So
far Xcode has generated the schemes each time it opened the workspace, keeping
only their order and visibility in xcuserdata/, which .gitignore excludes.
A fresh clone gets newly generated schemes, and any change you make to one
stays on your Mac. A shared scheme is saved inside the project, in
xcshareddata/xcschemes/, and is committed like any other file.
Xcode 27.1 lists generated schemes with Shared already ticked under Product → Scheme → Manage Schemes…, but writes no file until the scheme is opened in Edit Scheme. Save each app’s scheme:
- Choose Product → Scheme → TodoList, then Product → Scheme → Edit Scheme….
- Check that Shared at the bottom of the sheet is ticked, and click Close.
- Repeat with Product → Scheme → Shopping.
find Apps -name '*.xcscheme'
Apps/TodoList/TodoList.xcodeproj/xcshareddata/xcschemes/TodoList.xcscheme
Apps/Shopping/Shopping.xcodeproj/xcshareddata/xcschemes/Shopping.xcscheme
find lists them in whatever order the file system returns, so yours may
differ. The package schemes (CoreKit, DesignSystem, TodoKit) are left
generated: the Makefile tests packages with swift test, which does not use
schemes.
Step 10: Replace the Makefile
The previous Makefile built one project. This one builds the workspace,
knows which apps and packages exist, and runs the package tests in dependency
order. Replace the whole file.
Create the file
touch Makefile
Add the code: Makefile
WORKSPACE := MyApps.xcworkspace
APPS := TodoList Shopping
# Leaves first: each package comes after the packages it depends on.
PACKAGES := CoreKit DesignSystem TodoKit
# The apps with a UI-test target.
UI_TESTED := TodoList
# The app that settings, run, screenshot, and archive act on.
APP := TodoList
ifeq ($(filter $(APP),$(APPS)),)
$(error APP must be one of $(APPS), not "$(APP)")
endif
# Debug or Release; override with `make build CONFIG=Release`.
CONFIG := Debug
ifeq ($(filter $(CONFIG),Debug Release),)
$(error CONFIG must be Debug or Release, not "$(CONFIG)")
endif
BUNDLE_ID := com.example.$(APP)
SIM_NAME := TodoList-iPhone
DEVICE_TYPE := iPhone 17
DERIVED := build
APP_PATH := $(DERIVED)/Build/Products/$(CONFIG)-iphonesimulator/$(APP).app
ARCHIVE := $(DERIVED)/$(APP).xcarchive
SIM := -destination 'platform=iOS Simulator,name=$(SIM_NAME)'
DEVICE := -destination 'generic/platform=iOS'
# Keep only result lines from the test logs: Swift Testing (✔/✘),
# XCTest ("Test Case ... passed"), errors, and xcodebuild's final verdict.
RESULTS := grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
# The build settings `make settings` prints, out of several hundred.
SETTINGS := CONFIGURATION|INFOPLIST_KEY_CFBundleDisplayName|MARKETING_VERSION|PRODUCT_BUNDLE_IDENTIFIER|SWIFT_TREAT_WARNINGS_AS_ERRORS
XCODEBUILD := xcodebuild -workspace $(WORKSPACE) -derivedDataPath $(DERIVED)
TEST_FLAGS := -parallel-testing-enabled NO -collect-test-diagnostics never
# bash, so the recipes can use `set -o pipefail`.
SHELL := /bin/bash
.DEFAULT_GOAL := help
.PHONY: help simulator settings package-test build test run screenshot archive clean delete-simulator
help: ## Show this help
@echo "Usage: make <target> [APP=$(subst $() ,|,$(APPS))] [CONFIG=Debug|Release]"
@echo
@grep -E '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-17s %s\n", $$1, $$2}'
simulator: ## Create the TodoList-iPhone simulator if it is missing
@xcrun simctl list devices available | grep -q "$(SIM_NAME) (" || \
xcrun simctl create "$(SIM_NAME)" "$(DEVICE_TYPE)"
settings: ## Show the key build settings for APP in CONFIG
@set -o pipefail; \
$(XCODEBUILD) -scheme $(APP) $(DEVICE) -configuration $(CONFIG) -showBuildSettings 2>/dev/null | \
grep -E '^ *($(SETTINGS)) =' | sed 's/^ *//'
# `|| exit 1` stops at the first package that fails, so the failure is
# reported against the lowest package that broke.
package-test: ## Test every package on the Mac, leaves first
@set -o pipefail; for pkg in $(PACKAGES); do \
echo "--- $$pkg"; \
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/$$pkg \
--disable-xctest 2>&1 | $(RESULTS) || exit 1; \
done
build: simulator ## Build every app for the simulator in CONFIG
@for app in $(APPS); do \
echo "--- $$app"; \
$(XCODEBUILD) -scheme $$app $(SIM) -configuration $(CONFIG) -quiet build || exit 1; \
done
test: package-test build ## Test the packages, build every app, run the UI tests
@set -o pipefail; for app in $(UI_TESTED); do \
echo "--- $$app UI tests"; \
$(XCODEBUILD) -scheme $$app $(SIM) $(TEST_FLAGS) test 2>&1 | $(RESULTS) || exit 1; \
done
run: simulator ## Build APP in CONFIG, then install and launch it
$(XCODEBUILD) -scheme $(APP) $(SIM) -configuration $(CONFIG) -quiet build
xcrun simctl bootstatus "$(SIM_NAME)" -b
open "$$(xcode-select -p)/../Applications/DeviceHub.app"
xcrun simctl install "$(SIM_NAME)" "$(APP_PATH)"
xcrun simctl launch "$(SIM_NAME)" $(BUNDLE_ID)
screenshot: ## Save a screenshot of the simulator to screenshots/APP.png
@mkdir -p screenshots
xcrun simctl io "$(SIM_NAME)" screenshot screenshots/$(APP).png
# An archive is a Release build for real devices. It is left unsigned,
# because signing needs a development team.
archive: ## Archive an unsigned Release build of APP into build/
$(XCODEBUILD) -scheme $(APP) $(DEVICE) -archivePath $(ARCHIVE) CODE_SIGNING_ALLOWED=NO -quiet archive
@plutil -extract ApplicationProperties json -o - $(ARCHIVE)/Info.plist; echo
clean: ## Delete the build folders
rm -rf $(DERIVED) $(addsuffix /.build,$(addprefix Packages/,$(PACKAGES)))
delete-simulator: ## Delete the TodoList-iPhone simulator
xcrun simctl delete "$(SIM_NAME)"
Detailed breakdown
XCODEBUILDuses-workspaceand no-scheme. Each recipe adds the scheme it needs, because one workspace now has several.-projectwould fail with theMissing package producterror from Step 3.APPSandPACKAGESare the monorepo’s inventory. A third app or a fourth package is one word added here, plus its entry in the workspace.buildandpackage-testloop over these lists with a shellforloop;|| exit 1makes the loop stop at the first failure instead of carrying on and finishing with success.PACKAGESis in dependency order, leaves first. A leaf is a package that depends on no other package in the repository:CoreKitandDesignSystem.TodoKitcomes afterCoreKitbecause it depends on it.swift testwould buildCoreKitforTodoKitin any order; the order matters for what you read when something breaks. IfCoreKitis broken andTodoKitis tested first, the failures appear inTodoKit’s tests, thoughTodoKit’s code is fine. Step 11 shows the leaf-first run stopping atCoreKit.APPpicks one app forsettings,run,screenshotandarchive, andBUNDLE_ID,APP_PATHandARCHIVEfollow from it. That relies on each app’s bundle identifier beingcom.example.plus its name, which both are. Theifeqcheck rejects any other value beforexcodebuildruns, for the reason the previous article checkedCONFIG: a misspelled scheme name gives a less direct error.UI_TESTEDlists the apps with a UI-test target.xcodebuild teston Shopping’s scheme fails withScheme Shopping is not currently configured for the test action, because its test action has no test targets, sotestbuilds every app but tests only these.SETTINGSswapsDEBUG_INFORMATION_FORMATforPRODUCT_BUNDLE_IDENTIFIER, which tells the apps apart.cleandeletes each package’s.build/as well asbuild/.runbuilds onlyAPP, so running Shopping does not wait for TodoList to build.
Check the help screen:
make
Usage: make <target> [APP=TodoList|Shopping] [CONFIG=Debug|Release]
help Show this help
simulator Create the TodoList-iPhone simulator if it is missing
settings Show the key build settings for APP in CONFIG
package-test Test every package on the Mac, leaves first
build Build every app for the simulator in CONFIG
test Test the packages, build every app, run the UI tests
run Build APP in CONFIG, then install and launch it
screenshot Save a screenshot of the simulator to screenshots/APP.png
archive Archive an unsigned Release build of APP into build/
clean Delete the build folders
delete-simulator Delete the TodoList-iPhone simulator
Step 11: Test everything, then run both apps
make test covers the whole repository: the three packages on the Mac, a build
of both apps, then TodoList’s UI test on the simulator.
make test
--- CoreKit
✔ Test textIsTrimmed(raw:expected:) with 3 test cases passed after 0.001 seconds.
✔ Test blankTextIsRejected(raw:) with 3 test cases passed after 0.001 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.001 seconds.
--- DesignSystem
✔ Test doneAndNotDoneDiffer() passed after 0.001 seconds.
✔ Test symbolExists(isDone:) with 2 test cases passed after 0.019 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.019 seconds.
--- TodoKit
✔ Test summaryCountsOnlyUnfinishedItems() passed after 0.010 seconds.
✔ Test blankTitlesAreRejected(raw:) with 3 test cases passed after 0.010 seconds.
✔ Test addTodoIgnoresBlankTitles() passed after 0.010 seconds.
✔ Test addTodoStoresTheCleanedTitle() passed after 0.010 seconds.
✔ Test titlesAreTrimmed(raw:expected:) with 3 test cases passed after 0.010 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.
--- TodoList
--- Shopping
--- TodoList UI tests
Test Case '-[TodoListUITests.TodoListUITests testTodoIsSavedAcrossLaunches]' passed (17.377 seconds).
** TEST SUCCEEDED **
Tests inside a package can finish in a different order on each run, and the timings vary.
A broken leaf stops the run
To see why PACKAGES is ordered, break CoreKit for a moment by trimming
only line breaks, not spaces and tabs:
sed -i '' 's/in: \.whitespacesAndNewlines)/in: .newlines)/' Packages/CoreKit/Sources/CoreKit/TextInput.swift
make package-test 2>&1 | tail -4
✘ Test textIsTrimmed(raw:expected:) with 3 test cases failed after 0.001 seconds with 2 issues.
✘ Test blankTextIsRejected(raw:) with 3 test cases failed after 0.001 seconds with 2 issues.
✘ Test run with 2 tests in 1 suite failed after 0.001 seconds with 4 issues.
make: *** [package-test] Error 1
The order of the ✘ lines can vary between runs. The run stopped at
CoreKit, the package with the bug, and never reached
TodoKit. Testing TodoKit alone at this point would fail four of its five
tests, though none of its own code changed. Put the line back:
sed -i '' 's/in: \.newlines)/in: .whitespacesAndNewlines)/' Packages/CoreKit/Sources/CoreKit/TextInput.swift
make package-test 2>&1 | tail -1
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.
Check the settings of each app
make settings now takes APP:
make settings
make settings APP=Shopping
make settings CONFIG=Release
CONFIGURATION = Debug
INFOPLIST_KEY_CFBundleDisplayName = To-Do Dev
MARKETING_VERSION = 1.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoList
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
CONFIGURATION = Debug
MARKETING_VERSION = 1.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.Shopping
CONFIGURATION = Release
INFOPLIST_KEY_CFBundleDisplayName = To-Do
MARKETING_VERSION = 1.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoList
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
TodoList still reads its .xcconfig files from Apps/Config: To-Do Dev in
Debug, To-Do in Release. Shopping uses no .xcconfig file, so it has no
display name setting (its home-screen name is its target name) and does not
treat warnings as errors. The Release display name is also the check for the
General tab problem from Step 8: it must read To-Do.
Run both apps
Run Shopping, then screenshot it:
make run APP=Shopping
make screenshot APP=Shopping
xcrun simctl launch "TodoList-iPhone" com.example.Shopping
com.example.Shopping: 14405
xcrun simctl io "TodoList-iPhone" screenshot screenshots/Shopping.png
Detected file type from extension: PNG
Note: No display specified. Defaulting to display: F122299D-1DEB-41C4-BCDC-21708D5A8ECC (screenID: 1, name: LCD)
The first two lines are the end of make run’s output. make screenshot also
prints a last line, Wrote screenshot to: and the full path, left out here.
The process ID and the display identifier differ on your Mac. screenshots/Shopping.png shows
Apples with an empty circle and Bread struck through with a filled
check circle.
Then run TodoList on the same simulator:
xcrun simctl terminate TodoList-iPhone com.example.Shopping
make run
make screenshot
screenshots/TodoList.png shows Buy milk, saved by the UI test, struck
through with the same filled check circle, and All done below it. Both rows
are drawn by the same CheckRow.
Archive either app
make archive takes APP too, so each app archives on its own:
make archive APP=Shopping
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Shopping -destination 'generic/platform=iOS' -archivePath build/Shopping.xcarchive CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.0","CFBundleIdentifier":"com.example.Shopping","Team":"","ApplicationPath":"Applications\/Shopping.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"1"}
As in the previous article, the archive is unsigned: Team and
SigningIdentity are empty because signing needs a development team.
Step 12: Change a shared package and see both apps change
This is what the monorepo is for. Change the symbols in DesignSystem from
circles to squares:
sed -i '' 's/"checkmark.circle.fill" : "circle"/"checkmark.square.fill" : "square"/' Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
grep -n square Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
25: isDone ? "checkmark.square.fill" : "square"
Check that the new names exist with the package tests, then build every app:
make package-test 2>&1 | grep 'symbolExists'
make build
✔ Test symbolExists(isDone:) with 2 test cases passed after 0.019 seconds.
--- TodoList
--- Shopping
Both apps rebuilt against the edited package; nothing in either app changed. Run them again:
make run
make screenshot
xcrun simctl terminate TodoList-iPhone com.example.TodoList
make run APP=Shopping
make screenshot APP=Shopping
open screenshots/TodoList.png screenshots/Shopping.png
screenshots/TodoList.png now shows Buy milk with a filled check square,
and screenshots/Shopping.png shows Apples with an empty square and
Bread with a filled one. One line in one package changed both apps.
What the symbol test catches
A misspelled symbol name is a mistake the compiler cannot see. Misspell one and run both checks:
sed -i '' 's/"checkmark.square.fill"/"checkmark.sqare.fill"/' Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
make build
make package-test 2>&1 | tail -4
--- TodoList
--- Shopping
✘ Test symbolExists(isDone:) recorded an issue with 1 argument isDone → true at CheckRowTests.swift:11:9: Expectation failed: NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
✘ Test symbolExists(isDone:) with 2 test cases failed after 0.018 seconds with 1 issue.
✘ Test run with 2 tests in 1 suite failed after 0.019 seconds with 1 issue.
make: *** [package-test] Error 1
The order of the ✘ lines can vary. Both apps built, and either would have shown a blank space where a done item’s
square belongs. The package test failed. Fix the name:
sed -i '' 's/"checkmark.sqare.fill"/"checkmark.square.fill"/' Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
make package-test 2>&1 | grep symbolExists
✔ Test symbolExists(isDone:) with 2 test cases passed after 0.019 seconds.
Troubleshooting
Missing package product 'TodoKit'(orCoreKit,DesignSystem). The build used-project, or the package is missing from the workspace. Build with-workspace MyApps.xcworkspace, and checkcontents.xcworkspacedatalists the package.Could not resolve package dependencies. A project still holds its own package reference with an old path. Remove it under PROJECT → Package Dependencies, as in Step 3.- The options sheet in Add Files shows a Groups row, or the added package appears as a plain folder with its files listed. It was added inside a project. Cancel, or select the new item, press Delete and choose Remove Reference (not Move to Trash) if Xcode asks, then clear the selection in the Project navigator and add it again.
- Release builds of TodoList are named To-Do Dev. Opening the TodoList
target’s General tab wrote the Debug display name into the target. Select
the TodoList target, open Build Settings, search for
Display Name, select the row, and press Delete, so the.xcconfigfiles apply again.make settings CONFIG=Releaseshould readTo-Do. no such module 'DesignSystem'(orCoreKit). Usually the target does not link the product. Add it under Build Phases → Link Binary With Libraries. In a package, the target’sdependencieslist is missing the product.- A row shows no symbol. The SF Symbol name is misspelled.
make package-testnames the state whose symbol is missing. APP must be one of TodoList Shopping. The name is case-sensitive and must be one of the names inAPPS.
Recap
my-apps is now a monorepo: two app projects in Apps/, three packages in
Packages/, and one workspace that lists all five by path. The app projects
name the products they link and record no package paths, so the workspace is
the one place an app finds its packages, and the only package path outside the
workspace is TodoKit’s manifest pointing at its neighbor CoreKit.
Both app schemes are shared files in the repository. One Makefile tests the
packages leaves first, builds both apps, runs the UI test, and runs, inspects
or archives either app with APP=. Changing one line in DesignSystem
changed both apps on the simulator, and the package’s own test catches a
mistake the compiler cannot.
Next improvements:
- Move the settings both apps should share, such as treating warnings as
errors, into a base
.xcconfigfile that each app’s own files include. - Give Shopping a UI test target and add it to
UI_TESTED. - Persist Shopping’s list with SwiftData, with its model in a package of its
own, as
TodoKitdoes for TodoList.