Lab 5.3 — Multiplatform notes

Goal

Build a single-target Notes app that runs natively on iPhone, iPad, and Mac with one codebase. Practice NavigationSplitView, @Observable store sharing, platform-specific Commands and Settings scene on Mac, WindowGroup(for:) multi-window on iPad/Mac.

By the end you’ll have done a real multiplatform SwiftUI project — the most common modern SwiftUI app shape.

Time

120–180 minutes.

Prereqs

  • Xcode 16+
  • Chapters 5.10 (multiplatform) and 5.11 (Mac advanced)

Setup

  1. Xcode → New Project → Multiplatform App (iOS + macOS in one target)
  2. Name: MultiNotes
  3. Interface: SwiftUI, Language: Swift, no SwiftData (we’ll use a simple in-memory + Codable store for simplicity; SwiftData would also work)

Build

1. Model

Note.swift:

import Foundation

struct Note: Identifiable, Hashable, Codable {
    let id: UUID
    var title: String
    var body: String
    var folder: String
    var modified: Date

    init(id: UUID = UUID(), title: String, body: String = "", folder: String = "Inbox", modified: Date = .now) {
        self.id = id
        self.title = title
        self.body = body
        self.folder = folder
        self.modified = modified
    }
}

2. Store

NoteStore.swift:

import Foundation
import Observation

@MainActor
@Observable
final class NoteStore {
    var notes: [Note] = []
    var folders: [String] = ["Inbox", "Work", "Personal", "Archive"]

    private let url: URL = {
        let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        return dir.appendingPathComponent("notes.json")
    }()

    init() {
        load()
        if notes.isEmpty {
            notes = [
                Note(title: "Welcome", body: "This is a multiplatform note.", folder: "Inbox"),
                Note(title: "Shopping list", body: "Milk, eggs, bread", folder: "Personal"),
            ]
        }
    }

    func notes(in folder: String) -> [Note] {
        notes.filter { $0.folder == folder }
            .sorted { $0.modified > $1.modified }
    }

    func add(_ folder: String) {
        let new = Note(title: "Untitled", folder: folder)
        notes.append(new)
        save()
    }

    func update(_ note: Note) {
        if let idx = notes.firstIndex(where: { $0.id == note.id }) {
            var updated = note
            updated.modified = .now
            notes[idx] = updated
            save()
        }
    }

    func delete(_ id: Note.ID) {
        notes.removeAll { $0.id == id }
        save()
    }

    private func load() {
        guard let data = try? Data(contentsOf: url),
              let decoded = try? JSONDecoder().decode([Note].self, from: data) else { return }
        notes = decoded
    }

    private func save() {
        try? JSONEncoder().encode(notes).write(to: url)
    }
}

3. App entry

MultiNotesApp.swift:

import SwiftUI

@main
struct MultiNotesApp: App {
    @State private var store = NoteStore()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
        #if os(macOS)
        .commands {
            CommandGroup(replacing: .newItem) {
                Button("New Note") {
                    store.add("Inbox")
                }
                .keyboardShortcut("n", modifiers: .command)
            }
            SidebarCommands()
        }

        Settings {
            SettingsView()
                .frame(width: 360, height: 200)
        }
        #endif

        WindowGroup("Note", id: "note", for: Note.ID.self) { $noteID in
            if let id = noteID,
               let note = store.notes.first(where: { $0.id == id }) {
                DetachedNoteWindow(note: note)
                    .environment(store)
            }
        }
    }
}

4. Content view — NavigationSplitView

ContentView.swift:

import SwiftUI

struct ContentView: View {
    @Environment(NoteStore.self) private var store
    @State private var selectedFolder: String? = "Inbox"
    @State private var selectedNoteID: Note.ID?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(store.folders, id: \.self, selection: $selectedFolder) { folder in
                Label(folder, systemImage: icon(for: folder))
                    .tag(folder)
            }
            .navigationTitle("Folders")
            #if os(macOS)
            .frame(minWidth: 160)
            #endif
        } content: {
            // Note list
            if let folder = selectedFolder {
                NoteList(folder: folder, selection: $selectedNoteID)
            } else {
                ContentUnavailableView("No folder", systemImage: "folder")
            }
        } detail: {
            // Editor
            if let id = selectedNoteID, let note = store.notes.first(where: { $0.id == id }) {
                NoteEditor(note: note)
            } else {
                ContentUnavailableView("No note selected", systemImage: "note.text")
            }
        }
    }

    private func icon(for folder: String) -> String {
        switch folder {
        case "Inbox": return "tray"
        case "Work": return "briefcase"
        case "Personal": return "person"
        case "Archive": return "archivebox"
        default: return "folder"
        }
    }
}

NavigationSplitView adapts:

  • iPhone: stack (Folders → NoteList → NoteEditor)
  • iPad: 3-column on landscape, 2-column on portrait
  • Mac: 3-column with native split bars

5. Note list

NoteList.swift:

import SwiftUI

struct NoteList: View {
    @Environment(NoteStore.self) private var store
    @Environment(\.openWindow) private var openWindow
    let folder: String
    @Binding var selection: Note.ID?

    var notes: [Note] { store.notes(in: folder) }

    var body: some View {
        List(selection: $selection) {
            ForEach(notes) { note in
                NoteRow(note: note)
                    .tag(note.id)
                    .contextMenu {
                        Button("Open in New Window") {
                            openWindow(id: "note", value: note.id)
                        }
                        Button("Delete", role: .destructive) {
                            store.delete(note.id)
                        }
                    }
            }
            .onDelete { idx in
                idx.forEach { store.delete(notes[$0].id) }
            }
        }
        .navigationTitle(folder)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    store.add(folder)
                } label: {
                    Label("New Note", systemImage: "square.and.pencil")
                }
            }
        }
        #if os(macOS)
        .frame(minWidth: 220)
        #endif
    }
}

struct NoteRow: View {
    let note: Note

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(note.title.isEmpty ? "Untitled" : note.title)
                .font(.headline)
                .lineLimit(1)
            Text(note.body)
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(2)
            Text(note.modified, style: .date)
                .font(.caption2)
                .foregroundStyle(.tertiary)
        }
        .padding(.vertical, 2)
    }
}

6. Editor

NoteEditor.swift:

import SwiftUI

struct NoteEditor: View {
    @Environment(NoteStore.self) private var store
    let note: Note

    @State private var title = ""
    @State private var body = ""

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            TextField("Title", text: $title)
                .textFieldStyle(.plain)
                .font(.title.bold())
                .padding()

            Divider()

            TextEditor(text: $body)
                .padding(.horizontal)
        }
        .navigationTitle(title.isEmpty ? "Untitled" : title)
        .onAppear {
            title = note.title
            body = note.body
        }
        .onChange(of: note.id) {
            title = note.title
            body = note.body
        }
        .onChange(of: title) { commitDebounced() }
        .onChange(of: body) { commitDebounced() }
        #if os(macOS)
        .frame(minWidth: 400, minHeight: 300)
        #endif
    }

    @State private var commitTask: Task<Void, Never>?

    private func commitDebounced() {
        commitTask?.cancel()
        commitTask = Task {
            try? await Task.sleep(for: .milliseconds(400))
            guard !Task.isCancelled else { return }
            var updated = note
            updated.title = title
            updated.body = body
            store.update(updated)
        }
    }
}

7. Detached window (Mac/iPad)

DetachedNoteWindow.swift:

import SwiftUI

struct DetachedNoteWindow: View {
    let note: Note

    var body: some View {
        NoteEditor(note: note)
            #if os(macOS)
            .frame(minWidth: 500, minHeight: 400)
            #endif
    }
}

8. Settings (Mac)

SettingsView.swift:

import SwiftUI

#if os(macOS)
struct SettingsView: View {
    @AppStorage("editor.font.size") private var fontSize: Double = 14
    @AppStorage("editor.theme") private var theme: String = "Light"

    var body: some View {
        TabView {
            Form {
                Slider(value: $fontSize, in: 10...32, step: 1) {
                    Text("Editor font size: \(Int(fontSize))")
                }
                Picker("Theme", selection: $theme) {
                    Text("Light").tag("Light")
                    Text("Dark").tag("Dark")
                    Text("System").tag("System")
                }
            }
            .padding()
            .tabItem { Label("General", systemImage: "gear") }
        }
    }
}
#endif

9. Run on each platform

  • iOS Simulator (iPhone 17 / iPad Pro)
  • Mac (just hit Run with macOS destination)
  • Verify:
    • Folders → notes → editor flow on each
    • Add note button works
    • Delete works (swipe on iOS, context menu on Mac)
    • Edit a note, switch away and back: changes persisted
    • Mac: ⌘N creates note, ⌘, opens Settings, “Open in New Window” right-click works
    • Kill app, relaunch — notes persist

Stretch goals

  1. Search: .searchable(text:) on the note list, filter live.
  2. Tags: Add tags: Set<String> to Note; chip UI in the editor.
  3. iCloud sync: Use NSUbiquitousKeyValueStore for small data, or migrate to SwiftData + CloudKit for real sync.
  4. Mac: rich text editor: Replace TextEditor with NSTextView via NSViewRepresentable for full rich text + spell check.
  5. iPad keyboard shortcuts: Add .keyboardShortcut on toolbar buttons so external-keyboard iPad users get the same UX.
  6. Inspector pane on Mac/iPad: Add .inspector(isPresented:) with metadata (created date, word count, tags).
  7. Quick Look on Mac: Make Note Transferable so dragging a note row out exports a .txt file.
  8. MenuBarExtra on Mac: Recent notes shortcut in menu bar.

Notes & troubleshooting

  • @Environment(NoteStore.self) requires NoteStore to be @Observable and injected via .environment(store). Forgetting either crashes at runtime with “Missing Observable object of type NoteStore”.
  • TextEditor on macOS uses NSTextView under the hood, but doesn’t expose rich text. For real rich text, wrap NSTextView yourself.
  • Multi-window with WindowGroup(for:) requires the binding value (Note.ID = UUID) to be Codable + Hashable. UUID is both. The window then restores on relaunch.
  • @AppStorage is shared across the entire app — fine for settings, not for per-window state. Use @SceneStorage for per-window state (selected note, scroll position).
  • Editor debouncing: The simple Task-based debounce works; for production, consider Combine debounce or an actor-based debouncer.
  • Mac min frame: Without .frame(minWidth:minHeight:), the window can shrink to 0 in some configurations. Always set sane minimums on Mac.

Where to next

Lab 5.4 (Component library) packages reusable SwiftUI components as a Swift package — the design-system pattern used by Robinhood, Lyft, Airbnb’s Epoxy.


Next: Lab 5.4 — Component library