Lab 6.1 — Journal App with SwiftData
Goal
Build a single-screen-into-detail journal app on SwiftData with relationships (Entries ↔ Tags), search via #Predicate, and an optional toggle to enable CloudKit private-database sync. By the end you’ll have hands-on the entire SwiftData persistence surface from Chapter 6.2 plus a working CloudKit configuration.
Time
~90 minutes. Stretch goals push this to 3 hours.
Prerequisites
- Xcode 16+ with iOS 18+ simulator (iOS 17.4 minimum if you must)
- Apple Developer account (free tier is fine for simulator; paid required for device + CloudKit)
- Read Chapters 6.1, 6.2, 6.3, 6.4, 6.5
Setup
- New project: Xcode → File → New → Project → iOS App. Product name
Journal. Interface: SwiftUI. Storage: None (we’ll add SwiftData manually so you see every line). - Bundle ID:
com.yourname.Journal. The CloudKit container will follow this name. - Delete the generated
Item.swiftand any SwiftData boilerplateAppfile’s.modelContainer(for: Item.self).
Build
Step 1 — define the schema
Create Models.swift:
import Foundation
import SwiftData
@Model
final class Entry {
var id: UUID = UUID()
var title: String = ""
var body: String = ""
var createdAt: Date = Date()
var mood: Int = 3 // 1..5
@Relationship(deleteRule: .nullify, inverse: \Tag.entries)
var tags: [Tag]? = []
init(title: String, body: String, mood: Int = 3) {
self.title = title
self.body = body
self.mood = mood
}
}
@Model
final class Tag {
var id: UUID = UUID()
var name: String = ""
@Relationship var entries: [Entry]? = []
init(name: String) {
self.name = name
}
}
Note: every attribute optional or defaulted, no @Attribute(.unique). That’s the CloudKit-ready shape from Chapter 6.5.
Step 2 — wire the container
Edit JournalApp.swift:
import SwiftUI
import SwiftData
@main
struct JournalApp: App {
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
let container: ModelContainer
init() {
let schema = Schema([Entry.self, Tag.self])
// First-launch decision: local-only by default; user opts into iCloud in Settings.
let cloudEnabled = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: cloudEnabled ? .private("iCloud.com.yourname.Journal") : .none
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
(If you toggle the setting later, prompt the user to relaunch — switching containers at runtime is not supported.)
Step 3 — the list view
ContentView.swift:
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Entry.createdAt, order: .reverse) private var entries: [Entry]
@State private var searchText = ""
@State private var showingNew = false
var body: some View {
NavigationStack {
List {
ForEach(filteredEntries) { entry in
NavigationLink(value: entry) {
EntryRow(entry: entry)
}
}
.onDelete(perform: delete)
}
.navigationTitle("Journal")
.navigationDestination(for: Entry.self) { EntryDetailView(entry: $0) }
.searchable(text: $searchText, prompt: "Search title or body")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("New", systemImage: "plus") { showingNew = true }
}
}
.sheet(isPresented: $showingNew) { NewEntrySheet() }
}
}
private var filteredEntries: [Entry] {
guard !searchText.isEmpty else { return entries }
let q = searchText
return entries.filter { entry in
entry.title.localizedStandardContains(q) ||
entry.body.localizedStandardContains(q)
}
}
private func delete(at offsets: IndexSet) {
for index in offsets { context.delete(filteredEntries[index]) }
}
}
struct EntryRow: View {
let entry: Entry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.title.isEmpty ? "Untitled" : entry.title).font(.headline)
Spacer()
Text(String(repeating: "★", count: entry.mood))
.font(.caption)
.foregroundStyle(.orange)
}
Text(entry.createdAt, style: .date)
.font(.caption).foregroundStyle(.secondary)
}
}
}
Step 4 — #Predicate-driven search (advanced)
Replace the in-memory filter with a true predicate fetch:
struct ContentView: View {
@Environment(\.modelContext) private var context
@State private var searchText = ""
@State private var entries: [Entry] = []
@State private var showingNew = false
var body: some View {
NavigationStack {
List { /* same as before, using $entries */ }
.searchable(text: $searchText, prompt: "Search…")
.task(id: searchText) { await refresh() }
}
}
private func refresh() async {
let q = searchText
let predicate: Predicate<Entry>? = q.isEmpty ? nil : #Predicate {
$0.title.localizedStandardContains(q) || $0.body.localizedStandardContains(q)
}
var descriptor = FetchDescriptor<Entry>(predicate: predicate,
sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
descriptor.fetchLimit = 200
entries = (try? context.fetch(descriptor)) ?? []
}
}
Notice the task(id:) modifier — fetch re-runs every time searchText changes.
Step 5 — the detail view with @Bindable
struct EntryDetailView: View {
@Bindable var entry: Entry
@Environment(\.modelContext) private var context
@Query(sort: \Tag.name) private var allTags: [Tag]
@State private var newTag = ""
var body: some View {
Form {
Section("Entry") {
TextField("Title", text: $entry.title)
TextEditor(text: $entry.body).frame(minHeight: 120)
Stepper("Mood: \(entry.mood)", value: $entry.mood, in: 1...5)
}
Section("Tags") {
ForEach(allTags) { tag in
Toggle(tag.name, isOn: binding(for: tag))
}
HStack {
TextField("New tag", text: $newTag)
Button("Add") { addTag() }.disabled(newTag.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.navigationTitle(entry.title.isEmpty ? "Untitled" : entry.title)
.navigationBarTitleDisplayMode(.inline)
}
private func binding(for tag: Tag) -> Binding<Bool> {
Binding {
entry.tags?.contains(where: { $0.id == tag.id }) ?? false
} set: { isOn in
if isOn {
if entry.tags == nil { entry.tags = [] }
if !(entry.tags?.contains(where: { $0.id == tag.id }) ?? false) {
entry.tags?.append(tag)
}
} else {
entry.tags?.removeAll(where: { $0.id == tag.id })
}
}
}
private func addTag() {
let trimmed = newTag.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
// Application-layer uniqueness (CloudKit can't enforce it).
if !allTags.contains(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame }) {
let tag = Tag(name: trimmed)
context.insert(tag)
}
newTag = ""
}
}
Step 6 — new-entry sheet
struct NewEntrySheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var body = ""
@State private var mood = 3
var body: some View {
NavigationStack {
Form {
TextField("Title", text: $title)
TextEditor(text: $body).frame(minHeight: 120)
Stepper("Mood: \(mood)", value: $mood, in: 1...5)
}
.navigationTitle("New Entry")
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { save() }.disabled(title.isEmpty && body.isEmpty)
}
}
}
}
private func save() {
let entry = Entry(title: title, body: body, mood: mood)
context.insert(entry)
dismiss()
}
}
Step 7 — Settings (enable CloudKit)
struct SettingsView: View {
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
var body: some View {
Form {
Toggle("Sync via iCloud", isOn: $cloudSyncEnabled)
.onChange(of: cloudSyncEnabled) { _, _ in
// Inform the user a relaunch is required.
}
Text("Changes take effect after the next app launch.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
Add a settings tab or a gear-icon button to surface this view.
Step 8 — enable CloudKit (when ready)
- Project → Signing & Capabilities → + Capability → iCloud → check CloudKit → add container
iCloud.com.yourname.Journal. -
- Capability → Background Modes → check Remote notifications.
- Run on a device or simulator signed into iCloud. Toggle the setting on. Relaunch.
- Make an entry. Open the CloudKit Dashboard → your container → Schema → you should see
CD_Entry,CD_Tagrecord types appear.
Stretch
- Stretch 1 — Core Data side-by-side: create a sibling target
JournalLegacyusingNSPersistentCloudKitContainerwith the equivalent schema. Compare lines of code, build time, and iCloud sync behavior. - Stretch 2 — Schema v2: add an
attachments: [Attachment]relationship as a versioned schema migration (SchemaV1,SchemaV2,SchemaMigrationPlan). Verify a v1 store migrates cleanly. - Stretch 3 — Background import: write a
@ModelActor-based importer that takes a JSON file (sample fixture provided in your code) and inserts 1,000 entries off the main thread. - Stretch 4 — Sync status UI: subscribe to
NSPersistentCloudKitContainer.eventChangedNotification, show a sync indicator in the toolbar with last-sync timestamp.
Notes & gotchas
- If
ModelContainerinitialization fails with a CloudKit-related error, check that every attribute is optional/defaulted and that no@Attribute(.unique)is present. The error message is rarely specific. - Don’t share the same iCloud account between your dev and personal devices while iterating — schema confusion across builds is real.
- The first sync of an empty new device can take minutes. Show a loading state, not an empty list.
- Production schema deploys are one-way; don’t deploy until you’re confident the schema is final for the next release.