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

  1. 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).
  2. Bundle ID: com.yourname.Journal. The CloudKit container will follow this name.
  3. Delete the generated Item.swift and any SwiftData boilerplate App file’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)

  1. Project → Signing & Capabilities → + Capability → iCloud → check CloudKit → add container iCloud.com.yourname.Journal.
    • Capability → Background Modes → check Remote notifications.
  2. Run on a device or simulator signed into iCloud. Toggle the setting on. Relaunch.
  3. Make an entry. Open the CloudKit Dashboard → your container → Schema → you should see CD_Entry, CD_Tag record types appear.

Stretch

  • Stretch 1 — Core Data side-by-side: create a sibling target JournalLegacy using NSPersistentCloudKitContainer with 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 ModelContainer initialization 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.

Next: Lab 6.2 — CloudKit Sync App