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
- Xcode → New Project → Multiplatform App (iOS + macOS in one target)
- Name:
MultiNotes - Interface: SwiftUI, Language: Swift, no SwiftData (we’ll use a simple in-memory +
Codablestore 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
- Search:
.searchable(text:)on the note list, filter live. - Tags: Add
tags: Set<String>toNote; chip UI in the editor. - iCloud sync: Use
NSUbiquitousKeyValueStorefor small data, or migrate to SwiftData + CloudKit for real sync. - Mac: rich text editor: Replace
TextEditorwithNSTextViewviaNSViewRepresentablefor full rich text + spell check. - iPad keyboard shortcuts: Add
.keyboardShortcuton toolbar buttons so external-keyboard iPad users get the same UX. - Inspector pane on Mac/iPad: Add
.inspector(isPresented:)with metadata (created date, word count, tags). - Quick Look on Mac: Make
NoteTransferableso dragging a note row out exports a.txtfile. MenuBarExtraon Mac: Recent notes shortcut in menu bar.
Notes & troubleshooting
@Environment(NoteStore.self)requiresNoteStoreto be@Observableand injected via.environment(store). Forgetting either crashes at runtime with “Missing Observable object of type NoteStore”.TextEditoron macOS usesNSTextViewunder the hood, but doesn’t expose rich text. For real rich text, wrapNSTextViewyourself.- Multi-window with
WindowGroup(for:)requires the binding value (Note.ID = UUID) to beCodable + Hashable.UUIDis both. The window then restores on relaunch. @AppStorageis shared across the entire app — fine for settings, not for per-window state. Use@SceneStoragefor per-window state (selected note, scroll position).- Editor debouncing: The simple Task-based debounce works; for production, consider Combine
debounceor 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.