Build a Synced Notes App with SwiftData + CloudKit: Offline-First with Sync
Every great Pixar movie starts with a story — and every great story starts with notes. Scribbled ideas on a napkin at a diner, frantic rewrites on a flight, late-night revisions at the studio. What if those notes synced seamlessly across every device, worked offline in a cabin with no Wi-Fi, and never lost a single draft to a sync conflict?
In this tutorial, you’ll build Pixar Story Notes — a collaborative notes app themed around Pixar movie screenwriting. The app stores notes locally with SwiftData for instant offline access, syncs them across devices through iCloud via CloudKit, handles conflict resolution gracefully, and manages schema versioning so your data model can evolve with future releases. Along the way, you’ll learn how to configure SwiftData with CloudKit, design models that are sync-compatible, build a rich SwiftUI interface, and handle the real-world edge cases that trip up most sync implementations.
Prerequisites
- Xcode 26+ with iOS 18 deployment target
- An Apple Developer account with iCloud capability
- A physical device (or two) signed into the same iCloud account for testing sync
- Familiarity with SwiftData + CloudKit Sync
- Familiarity with CloudKit and iCloud Sync
- Familiarity with SwiftData Relationships
Contents
- Getting Started
- Step 1: Designing the SwiftData Models
- Step 2: Configuring the Model Container with CloudKit
- Step 3: Building the Notes List View
- Step 4: Building the Note Editor View
- Step 5: Adding Folders for Organization
- Step 6: Implementing Search and Filtering
- Step 7: Handling Sync Conflicts
- Step 8: Schema Versioning and Migration
- Step 9: Adding Offline Indicators and Sync Status
- Step 10: Polish and Final Touches
- Where to Go From Here?
Getting Started
Let’s set up the Xcode project with all the capabilities needed for SwiftData persistence and iCloud sync.
- Open Xcode and create a new project using the App template.
- Set the product name to PixarStoryNotes.
- Ensure the interface is set to SwiftUI, the language is Swift, and the storage is SwiftData.
- Set the minimum deployment target to iOS 18.0.
Now configure the iCloud capability:
- Select the PixarStoryNotes target in the project navigator.
- Go to the Signing & Capabilities tab.
- Click + Capability and add iCloud.
- Under the iCloud section, check CloudKit.
- In the Containers list, click the + button and create a container named
iCloud.com.yourcompany.PixarStoryNotes(replaceyourcompanywith your actual identifier). - Also add the Background Modes capability and check Remote notifications — this allows CloudKit to notify your app of remote changes even when it’s in the background.
Warning: CloudKit sync requires a real Apple Developer account. The free account does not support CloudKit containers. If you’re following along without a paid account, the app will still work fully as a local SwiftData app — the sync portions simply won’t activate.
Your project is now configured. Let’s build the data layer.
Step 1: Designing the SwiftData Models
The data model is the foundation of everything. For CloudKit sync compatibility, SwiftData models must follow specific rules: all properties must have default values or be optional, relationships must be optional, and you cannot use unique constraints (CloudKit manages its own record identifiers).
Create a new folder called Models and add a file StoryNote.swift:
import Foundation
import SwiftData
@Model
final class StoryNote {
var title: String
var content: String
var createdAt: Date
var updatedAt: Date
var movieProject: String
var isDraft: Bool
var colorLabel: String
// Relationship — a note belongs to a folder
var folder: NoteFolder?
init(
title: String = "Untitled Scene",
content: String = "",
createdAt: Date = .now,
updatedAt: Date = .now,
movieProject: String = "Unassigned",
isDraft: Bool = true,
colorLabel: String = "blue",
folder: NoteFolder? = nil
) {
self.title = title
self.content = content
self.createdAt = createdAt
self.updatedAt = updatedAt
self.movieProject = movieProject
self.isDraft = isDraft
self.colorLabel = colorLabel
self.folder = folder
}
}
Now create NoteFolder.swift in the same folder:
import Foundation
import SwiftData
@Model
final class NoteFolder {
var name: String
var emoji: String
var createdAt: Date
var sortOrder: Int
// Inverse relationship — a folder has many notes
@Relationship(deleteRule: .nullify, inverse: \StoryNote.folder)
var notes: [StoryNote]?
init(
name: String = "New Folder",
emoji: String = "📁",
createdAt: Date = .now,
sortOrder: Int = 0
) {
self.name = name
self.emoji = emoji
self.createdAt = createdAt
self.sortOrder = sortOrder
self.notes = []
}
}
Let’s also create a helper enum for the Pixar movie projects. Add MovieProject.swift:
import Foundation
enum MovieProject: String, CaseIterable, Identifiable {
case toyStory5 = "Toy Story 5"
case insideOut3 = "Inside Out 3"
case theIncredibles3 = "The Incredibles 3"
case findingNemo3 = "Finding Nemo 3"
case coco2 = "Coco 2"
case wallE2 = "WALL-E 2"
case ratatouille2 = "Ratatouille 2"
case unassigned = "Unassigned"
var id: String { rawValue }
var emoji: String {
switch self {
case .toyStory5: return "🤠"
case .insideOut3: return "🧠"
case .theIncredibles3: return "🦸"
case .findingNemo3: return "🐠"
case .coco2: return "🎸"
case .wallE2: return "🤖"
case .ratatouille2: return "🐀"
case .unassigned: return "📝"
}
}
}
A few critical design decisions to note:
- All properties have default values. This is required for CloudKit sync — when a record arrives from the cloud, SwiftData needs to create an instance even if some fields haven’t synced yet.
- The relationship is optional on both sides.
StoryNote.folderisNoteFolder?andNoteFolder.notesis[StoryNote]?. CloudKit does not support required relationships because records sync independently. - We use
.nullifyas the delete rule instead of.cascade. When a folder is deleted, its notes simply become “unfiled” rather than being deleted. This is safer with sync because the delete could arrive before the folder’s notes are fully synced. - No
@Attribute(.unique)constraints. CloudKit uses its own record identifiers and does not support application-defined unique constraints.
Apple Docs:
@Model— SwiftDataCheckpoint: Build the project (Cmd+B). Both models should compile without errors. If you see errors about circular references, make sure the
inversekey path in@Relationshipcorrectly points to\StoryNote.folder.
Step 2: Configuring the Model Container with CloudKit
SwiftData makes CloudKit sync remarkably simple — you configure a
ModelContainer with a CloudKit database option,
and the framework handles the rest. But “the rest” includes a lot of complexity, so let’s set it up correctly.
Open PixarStoryNotesApp.swift and configure the model container:
import SwiftData
import SwiftUI
@main
struct PixarStoryNotesApp: App {
let container: ModelContainer
init() {
let schema = Schema([
StoryNote.self,
NoteFolder.self,
])
let configuration = ModelConfiguration(
"PixarStoryNotes",
schema: schema,
cloudKitDatabase: .automatic
)
do {
container = try ModelContainer(
for: schema,
configurations: [configuration]
)
} catch {
fatalError(
"Failed to initialize ModelContainer: \(error)"
)
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
The key parameter is cloudKitDatabase: .automatic. This tells SwiftData to:
- Create a local SQLite store for immediate, offline persistence.
- Mirror all changes to the default CloudKit container (the one you configured in Signing & Capabilities).
- Automatically receive remote change notifications and merge incoming records.
- Handle the underlying
NSPersistentCloudKitContainersetup that would otherwise require dozens of lines of Core Data boilerplate.
The .automatic option uses the private CloudKit database, which means data is scoped to the user’s iCloud account.
Each user sees only their own notes.
Tip: During development, you can use
cloudKitDatabase: .noneto disable sync temporarily. This is useful when you want to iterate quickly on the data model without waiting for CloudKit schema initialization.
Let’s also seed some initial data so the app isn’t empty on first launch. Create Services/SeedDataService.swift:
import Foundation
import SwiftData
struct SeedDataService {
static func seedIfNeeded(modelContext: ModelContext) {
// Check if data already exists
let descriptor = FetchDescriptor<StoryNote>()
let count = (try? modelContext.fetchCount(descriptor)) ?? 0
guard count == 0 else { return }
// Create default folders
let draftsFolder = NoteFolder(
name: "Drafts",
emoji: "📝",
sortOrder: 0
)
let pitchesFolder = NoteFolder(
name: "Pitches",
emoji: "🎬",
sortOrder: 1
)
let dialogueFolder = NoteFolder(
name: "Dialogue",
emoji: "💬",
sortOrder: 2
)
modelContext.insert(draftsFolder)
modelContext.insert(pitchesFolder)
modelContext.insert(dialogueFolder)
// Create sample notes
let sampleNotes: [(String, String, String, NoteFolder)] = [
(
"Woody's Big Decision",
"""
Act 3 revision: Woody realizes that being there \
for Bonnie means letting go of his fear of being \
replaced. The carnival scene needs more emotional \
weight — maybe a callback to the 'You've Got a \
Friend in Me' motif.
""",
"Toy Story 5",
draftsFolder
),
(
"Joy's New Emotion",
"""
PITCH: What if Joy discovers an emotion she's \
never encountered before? Not sadness, not anger \
— something entirely new that emerges when Riley \
starts college. Working title: 'Nostalgia' — the \
bittersweet feeling of missing something that \
hasn't ended yet.
""",
"Inside Out 3",
pitchesFolder
),
(
"Nemo's Dialogue - Reef Scene",
"""
NEMO: Dad, you can't keep following me to school.
MARLIN: I'm not following you! I'm just... \
swimming in the same direction.
NEMO: That IS following.
DORY: Ooh, are we playing follow the leader? I \
love that game! What are the rules again?
""",
"Finding Nemo 3",
dialogueFolder
),
]
for (title, content, project, folder) in sampleNotes {
let note = StoryNote(
title: title,
content: content,
movieProject: project,
isdraft: false,
folder: folder
)
modelContext.insert(note)
}
try? modelContext.save()
}
}
Call the seed service from ContentView. We will build the full ContentView in the next step, but for now, create a
minimal version in ContentView.swift:
import SwiftData
import SwiftUI
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
var body: some View {
NotesListView()
.onAppear {
SeedDataService.seedIfNeeded(
modelContext: modelContext
)
}
}
}
Checkpoint: Build the project. If you see a CloudKit-related error in the console about schema initialization, that’s expected on first run — CloudKit needs to create the schema in the development environment. The app should still launch and display the seeded data locally. If you see “Failed to initialize ModelContainer,” double-check that your iCloud container name matches exactly.
Step 3: Building the Notes List View
Now let’s build the main notes list. This is the first screen users see — it shows all their story notes sorted by last modified date, with visual indicators for the movie project and draft status.
Create Views/NotesListView.swift:
import SwiftData
import SwiftUI
struct NotesListView: View {
@Environment(\.modelContext) private var modelContext
@Query(
sort: \StoryNote.updatedAt,
order: .reverse
) private var notes: [StoryNote]
@State private var showingNewNote = false
@State private var selectedNote: StoryNote?
@State private var searchText = ""
var filteredNotes: [StoryNote] {
if searchText.isEmpty {
return notes
}
return notes.filter { note in
note.title.localizedCaseInsensitiveContains(searchText) ||
note.content.localizedCaseInsensitiveContains(searchText) ||
note.movieProject.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationSplitView {
List(selection: $selectedNote) {
ForEach(filteredNotes) { note in
NavigationLink(value: note) {
NoteRowView(note: note)
}
}
.onDelete(perform: deleteNotes)
}
.navigationTitle("Story Notes")
.searchable(
text: $searchText,
prompt: "Search notes..."
)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingNewNote = true
} label: {
Image(systemName: "square.and.pencil")
}
}
}
.sheet(isPresented: $showingNewNote) {
NewNoteSheet { note in
modelContext.insert(note)
try? modelContext.save()
selectedNote = note
}
}
.overlay {
if filteredNotes.isEmpty {
ContentUnavailableView(
"No Story Notes",
systemImage: "note.text",
description: Text(
"Tap the compose button to start writing your next Pixar masterpiece."
)
)
}
}
} detail: {
if let note = selectedNote {
NoteEditorView(note: note)
} else {
ContentUnavailableView(
"Select a Note",
systemImage: "doc.text",
description: Text(
"Choose a story note from the sidebar to start editing."
)
)
}
}
}
private func deleteNotes(at offsets: IndexSet) {
for index in offsets {
let note = filteredNotes[index]
modelContext.delete(note)
}
try? modelContext.save()
}
}
Now create the row view in Views/NoteRowView.swift:
import SwiftUI
struct NoteRowView: View {
let note: StoryNote
private var projectEmoji: String {
MovieProject(rawValue: note.movieProject)?.emoji ?? "📝"
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(projectEmoji)
.font(.title2)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(note.title)
.font(.headline)
.lineLimit(1)
if note.isDraft {
Text("DRAFT")
.font(.caption2)
.fontWeight(.bold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.orange.opacity(0.2))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
Text(note.content.isEmpty ? "No content" : note.content)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 8) {
Text(note.movieProject)
.font(.caption)
.foregroundStyle(.blue)
Text("·")
.foregroundStyle(.secondary)
Text(note.updatedAt, style: .relative)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
}
}
The NavigationSplitView gives us a
sidebar-detail layout that works beautifully on iPad and adapts to a navigation stack on iPhone. The
@Query macro automatically observes the SwiftData
store and re-fetches whenever notes are added, modified, or deleted — including changes arriving from CloudKit sync.
Checkpoint: Build and run the app. You should see the three seeded story notes in a list: “Woody’s Big Decision” with the cowboy emoji, “Joy’s New Emotion” with the brain emoji, and “Nemo’s Dialogue - Reef Scene” with the fish emoji. Each note shows the movie project name, a DRAFT badge, and a relative timestamp. If the list is empty, make sure the
SeedDataService.seedIfNeededcall is in yourContentView.
Step 4: Building the Note Editor View
The editor is where the creative magic happens. We need a responsive editing experience that saves changes automatically and syncs them to iCloud.
Create Views/NoteEditorView.swift:
import SwiftData
import SwiftUI
struct NoteEditorView: View {
@Bindable var note: StoryNote
@Environment(\.modelContext) private var modelContext
@State private var showingProjectPicker = false
@State private var showingFolderPicker = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Title field
TextField(
"Scene Title",
text: $note.title,
axis: .vertical
)
.font(.title)
.fontWeight(.bold)
.lineLimit(1...3)
// Metadata bar
metadataBar
Divider()
// Content editor
TextField(
"Start writing your story...",
text: $note.content,
axis: .vertical
)
.font(.body)
.lineLimit(5...1000)
.frame(minHeight: 300, alignment: .topLeading)
}
.padding()
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
note.isDraft.toggle()
saveNote()
} label: {
Label(
note.isDraft
? "Mark as Final"
: "Mark as Draft",
systemImage: note.isDraft
? "checkmark.circle"
: "pencil.circle"
)
}
Button {
showingFolderPicker = true
} label: {
Label(
"Move to Folder",
systemImage: "folder"
)
}
Divider()
Button(role: .destructive) {
modelContext.delete(note)
try? modelContext.save()
} label: {
Label("Delete Note", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showingProjectPicker) {
ProjectPickerSheet(
selectedProject: $note.movieProject
)
.presentationDetents([.medium])
}
.sheet(isPresented: $showingFolderPicker) {
FolderPickerSheet(note: note)
.presentationDetents([.medium])
}
.onChange(of: note.title) { _, _ in saveNote() }
.onChange(of: note.content) { _, _ in saveNote() }
}
private var metadataBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
// Movie project chip
Button {
showingProjectPicker = true
} label: {
Label {
Text(note.movieProject)
} icon: {
Text(
MovieProject(
rawValue: note.movieProject
)?.emoji ?? "📝"
)
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.blue.opacity(0.1))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
// Draft status chip
Text(note.isDraft ? "Draft" : "Final")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
note.isDraft
? .orange.opacity(0.1)
: .green.opacity(0.1)
)
.foregroundStyle(
note.isDraft ? .orange : .green
)
.clipShape(Capsule())
// Folder chip
if let folder = note.folder {
Label {
Text(folder.name)
} icon: {
Text(folder.emoji)
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.purple.opacity(0.1))
.foregroundStyle(.purple)
.clipShape(Capsule())
}
Spacer()
// Last edited timestamp
Text(
"Edited \(note.updatedAt, style: .relative) ago"
)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private func saveNote() {
note.updatedAt = .now
try? modelContext.save()
}
}
Now create the project picker sheet in Views/ProjectPickerSheet.swift:
import SwiftUI
struct ProjectPickerSheet: View {
@Binding var selectedProject: String
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(MovieProject.allCases) { project in
Button {
selectedProject = project.rawValue
dismiss()
} label: {
HStack {
Text(project.emoji)
.font(.title2)
Text(project.rawValue)
.foregroundStyle(.primary)
Spacer()
if project.rawValue == selectedProject {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
}
.navigationTitle("Movie Project")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
Create the new note sheet in Views/NewNoteSheet.swift:
import SwiftUI
struct NewNoteSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var selectedProject =
MovieProject.unassigned.rawValue
let onSave: (StoryNote) -> Void
var body: some View {
NavigationStack {
Form {
Section("Scene Details") {
TextField("Scene Title", text: $title)
Picker(
"Movie Project",
selection: $selectedProject
) {
ForEach(MovieProject.allCases) { project in
Label(
project.rawValue,
systemImage: "film"
)
.tag(project.rawValue)
}
}
}
Section {
Text(
"You'll be able to write the full scene after creating the note."
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.navigationTitle("New Story Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
let note = StoryNote(
title: title.isEmpty
? "Untitled Scene"
: title,
movieProject: selectedProject
)
onSave(note)
dismiss()
}
}
}
}
}
}
The @Bindable property wrapper lets us create two-way
bindings to the StoryNote model’s properties. Because StoryNote is a SwiftData @Model (which conforms to
Observable), changes to any property automatically trigger SwiftUI view updates and SwiftData persistence.
The onChange modifiers on title and content call saveNote() every time the user types, which updates the
updatedAt timestamp and persists the change. SwiftData batches these saves efficiently — you don’t need to debounce
manually.
Checkpoint: Build and run the app. Tap on “Woody’s Big Decision” in the list. You should see the note editor with the title, movie project chip showing “Toy Story 5” with a cowboy emoji, and the full note content. Edit the title or content and navigate back to the list — the changes should persist. Tap the compose button to create a new note and verify it appears in the list.
Step 5: Adding Folders for Organization
Every good note-taking app needs organization. Let’s add folder support so Pixar’s writers can group their notes by category — Drafts, Pitches, Dialogue, and custom folders.
Create Views/FolderPickerSheet.swift:
import SwiftData
import SwiftUI
struct FolderPickerSheet: View {
let note: StoryNote
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: \NoteFolder.sortOrder) private var folders: [NoteFolder]
var body: some View {
NavigationStack {
List {
// "No folder" option
Button {
note.folder = nil
try? modelContext.save()
dismiss()
} label: {
HStack {
Text("📋")
.font(.title3)
Text("No Folder")
.foregroundStyle(.primary)
Spacer()
if note.folder == nil {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
// Existing folders
ForEach(folders) { folder in
Button {
note.folder = folder
try? modelContext.save()
dismiss()
} label: {
HStack {
Text(folder.emoji)
.font(.title3)
Text(folder.name)
.foregroundStyle(.primary)
Spacer()
if note.folder?.persistentModelID ==
folder.persistentModelID {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
Text("\(folder.notes?.count ?? 0)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Move to Folder")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
Now let’s add a folder sidebar to the main list view. Create Views/FolderSidebarView.swift:
import SwiftData
import SwiftUI
struct FolderSidebarView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \NoteFolder.sortOrder) private var folders: [NoteFolder]
@Binding var selectedFolder: NoteFolder?
@Binding var showAllNotes: Bool
@State private var showingNewFolder = false
@State private var newFolderName = ""
@State private var newFolderEmoji = "📁"
private let emojiOptions = [
"📁", "🎬", "📝", "💡", "🎭", "🎨", "🎵", "⭐"
]
var body: some View {
List(selection: $selectedFolder) {
// All Notes
Section {
Button {
showAllNotes = true
selectedFolder = nil
} label: {
Label {
HStack {
Text("All Notes")
Spacer()
}
} icon: {
Image(systemName: "tray.full")
}
}
.listRowBackground(
showAllNotes
? Color.accentColor.opacity(0.1)
: nil
)
}
// Folders
Section("Folders") {
ForEach(folders) { folder in
Button {
selectedFolder = folder
showAllNotes = false
} label: {
Label {
HStack {
Text(folder.name)
Spacer()
Text("\(folder.notes?.count ?? 0)")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Text(folder.emoji)
}
}
.listRowBackground(
selectedFolder?.persistentModelID ==
folder.persistentModelID
? Color.accentColor.opacity(0.1)
: nil
)
}
.onDelete(perform: deleteFolders)
}
// New Folder button
Section {
Button {
showingNewFolder = true
} label: {
Label(
"New Folder",
systemImage: "folder.badge.plus"
)
}
}
}
.alert("New Folder", isPresented: $showingNewFolder) {
TextField("Folder Name", text: $newFolderName)
Button("Cancel", role: .cancel) {
newFolderName = ""
}
Button("Create") {
let folder = NoteFolder(
name: newFolderName.isEmpty
? "New Folder"
: newFolderName,
emoji: newFolderEmoji,
sortOrder: folders.count
)
modelContext.insert(folder)
try? modelContext.save()
newFolderName = ""
}
}
}
private func deleteFolders(at offsets: IndexSet) {
for index in offsets {
let folder = folders[index]
modelContext.delete(folder)
}
try? modelContext.save()
}
}
Now update ContentView.swift to use the folder sidebar in a tab-based layout:
import SwiftData
import SwiftUI
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@State private var selectedFolder: NoteFolder?
@State private var showAllNotes = true
var body: some View {
TabView {
Tab("Notes", systemImage: "note.text") {
NotesListView()
}
Tab("Folders", systemImage: "folder") {
FolderSidebarView(
selectedFolder: $selectedFolder,
showAllNotes: $showAllNotes
)
.navigationTitle("Folders")
}
}
.onAppear {
SeedDataService.seedIfNeeded(
modelContext: modelContext
)
}
}
}
Tip: On iPad, the
NavigationSplitViewinNotesListViewautomatically provides a three-column layout with the folder sidebar, notes list, and editor. On iPhone, the tab bar gives quick access to both views.Checkpoint: Build and run the app. You should see a tab bar with “Notes” and “Folders” tabs. Switch to the Folders tab and see the three seeded folders (Drafts, Pitches, Dialogue) with note counts. Open a note, tap the menu (ellipsis button), and choose “Move to Folder” to move it between folders. The note counts should update accordingly.
Step 6: Implementing Search and Filtering
Good search is critical for a notes app. We already added basic search in the notes list, but let’s enhance it with filtering by movie project and draft status.
Add these state properties and computed properties to NotesListView.swift:
// Add these state properties to NotesListView
@State private var filterProject: String? = nil
@State private var filterDraftOnly = false
Replace the filteredNotes computed property with one that applies both search and filters:
var filteredNotes: [StoryNote] {
var result = notes.map { $0 }
// Apply search text
if !searchText.isEmpty {
result = result.filter { note in
note.title
.localizedCaseInsensitiveContains(searchText) ||
note.content
.localizedCaseInsensitiveContains(searchText) ||
note.movieProject
.localizedCaseInsensitiveContains(searchText)
}
}
// Apply project filter
if let project = filterProject {
result = result.filter { $0.movieProject == project }
}
// Apply draft filter
if filterDraftOnly {
result = result.filter { $0.isDraft }
}
return result
}
private var hasActiveFilters: Bool {
filterProject != nil || filterDraftOnly
}
Add a filter toolbar item inside the NotesListView toolbar:
// Add as another ToolbarItem in NotesListView
ToolbarItem(placement: .secondaryAction) {
Menu {
// Project filter
Menu("Movie Project") {
Button("All Projects") {
filterProject = nil
}
Divider()
ForEach(MovieProject.allCases) { project in
Button {
filterProject = project.rawValue
} label: {
if filterProject == project.rawValue {
Label(
project.rawValue,
systemImage: "checkmark"
)
} else {
Text(project.rawValue)
}
}
}
}
// Draft filter
Toggle("Drafts Only", isOn: $filterDraftOnly)
} label: {
Image(
systemName: hasActiveFilters
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle"
)
}
}
SwiftData’s @Query macro fetches all notes sorted by
updatedAt, and we apply filters in-memory. For small to medium datasets (hundreds of notes), this is efficient and
keeps the code simple. For apps with thousands of records, you would use FetchDescriptor with predicates to push
filtering to the SQLite layer.
Checkpoint: Build and run the app. Tap the filter icon in the toolbar. Select “Toy Story 5” under Movie Project — only “Woody’s Big Decision” should appear. Toggle “Drafts Only” — all three seeded notes should show because they’re all drafts. Clear the filters and use the search bar to type “Nemo” — the dialogue note should appear.
Step 7: Handling Sync Conflicts
Sync conflicts are the hardest part of any cloud-synced app. What happens when a user edits a note on their iPhone while offline, and simultaneously edits the same note on their iPad? When both devices come back online, CloudKit detects the conflict.
SwiftData with CloudKit uses a last-writer-wins strategy by default. The record with the most recent modification date overwrites the other. This works well for most cases, but we can improve the experience by:
- Tracking when records were last synced
- Showing users when a note was modified on another device
- Providing a manual merge option for critical conflicts
Create Services/SyncMonitor.swift:
import Combine
import CoreData
import Foundation
import Observation
import SwiftData
@Observable
class SyncMonitor {
var lastSyncDate: Date?
var syncError: String?
var isSyncing = false
var pendingChanges = 0
private var cancellables = Set<AnyCancellable>()
init() {
observeSyncNotifications()
}
private func observeSyncNotifications() {
// Monitor CloudKit import notifications
NotificationCenter.default.publisher(
for: NSPersistentCloudKitContainer
.eventChangedNotification
)
.receive(on: RunLoop.main)
.sink { [weak self] notification in
guard let self,
let event = notification.userInfo?[
NSPersistentCloudKitContainer
.eventNotificationUserInfoKey
] as? NSPersistentCloudKitContainer.Event
else {
return
}
switch event.type {
case .setup:
print("CloudKit setup event")
case .import:
self.handleImportEvent(event)
case .export:
self.handleExportEvent(event)
@unknown default:
break
}
}
.store(in: &cancellables)
// Monitor remote change notifications
NotificationCenter.default.publisher(
for: .NSPersistentStoreRemoteChange
)
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.lastSyncDate = Date()
}
.store(in: &cancellables)
}
private func handleImportEvent(
_ event: NSPersistentCloudKitContainer.Event
) {
if event.endDate != nil {
// Import finished
isSyncing = false
lastSyncDate = Date()
if let error = event.error {
syncError = error.localizedDescription
print("CloudKit import error: \(error)")
} else {
syncError = nil
}
} else {
// Import started
isSyncing = true
}
}
private func handleExportEvent(
_ event: NSPersistentCloudKitContainer.Event
) {
if event.endDate != nil {
// Export finished
isSyncing = false
if let error = event.error {
syncError = error.localizedDescription
print("CloudKit export error: \(error)")
}
} else {
// Export started
isSyncing = true
}
}
}
The
NSPersistentCloudKitContainer.eventChangedNotification
lets us observe the lifecycle of CloudKit sync operations. We track import events (data coming from the cloud), export
events (local changes being pushed), and errors.
To show the user when a note was recently synced from another device, add a “recently modified remotely” indicator. Add
this computed property to StoryNote:
// Add this computed property to StoryNote
var wasRecentlyUpdated: Bool {
// Consider a note "recently updated" if modified
// in the last 30 seconds — this helps detect remote changes
abs(updatedAt.timeIntervalSinceNow) < 30
}
Add a subtle sync indicator to NoteRowView.swift:
// Add this to the HStack in NoteRowView, after the draft badge
if note.wasRecentlyUpdated {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
.foregroundStyle(.blue)
}
Note: SwiftData with CloudKit handles most conflict resolution automatically using last-writer-wins semantics. For apps that need more sophisticated conflict resolution (like merge-based strategies for collaborative editing), you would need to drop down to
NSPersistentCloudKitContainerdirectly and implement custom merge policies. For a notes app, last-writer-wins is typically the right choice.Checkpoint: Build and run the app on two devices signed into the same iCloud account. Create a note on Device A and wait 10-20 seconds. The note should appear on Device B. Edit the note on Device B and verify the changes sync back to Device A. You should see the sync indicator briefly appear when remote changes arrive.
Step 8: Schema Versioning and Migration
As your app evolves, you’ll need to add new properties, rename existing ones, or restructure relationships. SwiftData
supports schema versioning through
VersionedSchema and
SchemaMigrationPlan.
Let’s prepare our app for a future migration by setting up the versioning infrastructure. Create
Models/SchemaVersions.swift:
import Foundation
import SwiftData
// Version 1: The initial schema
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[StoryNote.self, NoteFolder.self]
}
}
// Version 2: Adding a "priority" field and "wordCount" to notes
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[StoryNoteV2.self, NoteFolderV2.self]
}
@Model
final class StoryNoteV2 {
var title: String
var content: String
var createdAt: Date
var updatedAt: Date
var movieProject: String
var isDraft: Bool
var colorLabel: String
var priority: Int // ← New field
var wordCount: Int // ← New field
var folder: NoteFolderV2?
init(
title: String = "Untitled Scene",
content: String = "",
createdAt: Date = .now,
updatedAt: Date = .now,
movieProject: String = "Unassigned",
isDraft: Bool = true,
colorLabel: String = "blue",
priority: Int = 0,
wordCount: Int = 0,
folder: NoteFolderV2? = nil
) {
self.title = title
self.content = content
self.createdAt = createdAt
self.updatedAt = updatedAt
self.movieProject = movieProject
self.isDraft = isDraft
self.colorLabel = colorLabel
self.priority = priority
self.wordCount = wordCount
self.folder = folder
}
}
@Model
final class NoteFolderV2 {
var name: String
var emoji: String
var createdAt: Date
var sortOrder: Int
@Relationship(
deleteRule: .nullify,
inverse: \StoryNoteV2.folder
)
var notes: [StoryNoteV2]?
init(
name: String = "New Folder",
emoji: String = "📁",
createdAt: Date = .now,
sortOrder: Int = 0
) {
self.name = name
self.emoji = emoji
self.createdAt = createdAt
self.sortOrder = sortOrder
self.notes = []
}
}
}
Now create the migration plan in Models/MigrationPlan.swift:
import Foundation
import SwiftData
enum StoryNotesMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
// Lightweight migration: adding new fields with defaults
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
To use the migration plan, you would update the ModelContainer initialization in PixarStoryNotesApp.swift:
// When you're ready to deploy V2, update the container setup:
// container = try ModelContainer(
// for: SchemaV2.StoryNoteV2.self, SchemaV2.NoteFolderV2.self,
// migrationPlan: StoryNotesMigrationPlan.self
// )
A few key rules for CloudKit-compatible schema migrations:
- You can only add new optional properties or properties with default values. CloudKit does not support removing or renaming fields.
- Lightweight migrations work for additive changes. If you only add new properties with defaults, SwiftData handles the migration automatically.
- Custom migrations require more care. If you need to transform data (e.g., splitting a name field into first and
last), use
MigrationStage.customwith awillMigrateclosure. - CloudKit schema changes are one-way. Once you push a schema change to the CloudKit production environment, you cannot remove those fields. Plan your schema carefully.
Warning: Always test schema migrations on a separate device or simulator with the old schema installed before deploying to production. A failed migration can corrupt the local database and force users to start fresh.
Apple Docs:
SchemaMigrationPlan— SwiftData
Step 9: Adding Offline Indicators and Sync Status
An offline-first app should always communicate its sync status to the user. Let’s add a sync status bar that shows whether the app is syncing, when it last synced, and if there are any errors.
Create Views/SyncStatusBar.swift:
import SwiftUI
struct SyncStatusBar: View {
let syncMonitor: SyncMonitor
var body: some View {
HStack(spacing: 8) {
if syncMonitor.isSyncing {
ProgressView()
.controlSize(.mini)
Text("Syncing with iCloud...")
.font(.caption2)
.foregroundStyle(.secondary)
} else if let error = syncMonitor.syncError {
Image(systemName: "exclamationmark.icloud")
.font(.caption)
.foregroundStyle(.red)
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
} else if let lastSync = syncMonitor.lastSyncDate {
Image(systemName: "checkmark.icloud")
.font(.caption)
.foregroundStyle(.green)
Text(
"Synced \(lastSync, style: .relative) ago"
)
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Image(systemName: "icloud")
.font(.caption)
.foregroundStyle(.secondary)
Text("Waiting for sync...")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 4)
.background(.ultraThinMaterial)
}
}
Integrate the sync monitor and status bar into NotesListView.swift. Add the sync monitor as a state property:
@State private var syncMonitor = SyncMonitor()
Add the SyncStatusBar at the bottom of the NavigationSplitView sidebar, below the List:
// Inside the NavigationSplitView sidebar, wrap the list
VStack(spacing: 0) {
List(selection: $selectedNote) {
// ... existing list content ...
}
SyncStatusBar(syncMonitor: syncMonitor)
}
Let’s also add a network connectivity observer to show when the device is offline. Create
Services/NetworkMonitor.swift:
import Foundation
import Network
import Observation
@Observable
class NetworkMonitor {
var isConnected = true
var connectionType: NWInterface.InterfaceType?
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(
label: "NetworkMonitor"
)
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected =
path.status == .satisfied
self?.connectionType =
path.availableInterfaces.first?.type
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
Add an offline banner to the notes list. Add the network monitor as a state property in NotesListView:
@State private var networkMonitor = NetworkMonitor()
Add the offline banner at the top of the list:
// Inside the List, before ForEach
if !networkMonitor.isConnected {
HStack {
Image(systemName: "wifi.slash")
Text(
"You're offline. Changes will sync when you reconnect."
)
.font(.caption)
}
.foregroundStyle(.orange)
.padding(.vertical, 8)
.listRowBackground(Color.orange.opacity(0.1))
}
The NWPathMonitor from the Network framework
observes real-time connectivity changes. When the device goes offline, the banner appears immediately. When it
reconnects, SwiftData automatically pushes pending changes to CloudKit.
Checkpoint: Build and run the app. You should see a sync status bar at the bottom of the notes list showing “Synced X seconds ago” with a green cloud checkmark. Toggle Airplane Mode on the simulator or device — the offline banner should appear at the top of the list. Create a note while offline, then disable Airplane Mode. The sync status should briefly show “Syncing with iCloud…” before returning to the synced state. The note you created offline should now be available on your other devices.
Step 10: Polish and Final Touches
Let’s add a few finishing touches to make Pixar Story Notes feel complete and professional.
Word Count and Reading Time
Add a live word count to the editor. Update NoteEditorView.swift to include these computed properties:
// Add these computed properties to NoteEditorView
private var wordCount: Int {
note.content.split(separator: " ").count
}
private var estimatedReadingTime: String {
let minutes = max(1, wordCount / 200)
return "\(minutes) min read"
}
Add it to the bottom of the metadata bar:
// Add inside the metadataBar HStack
Text("\(wordCount) words · \(estimatedReadingTime)")
.font(.caption2)
.foregroundStyle(.secondary)
Sorting Options
Let users sort their notes by different criteria. Add a sort picker to NotesListView:
// Add this enum and state property to NotesListView
enum SortOption: String, CaseIterable {
case updatedAt = "Last Modified"
case createdAt = "Date Created"
case title = "Title"
case movieProject = "Movie Project"
}
@State private var sortOption: SortOption = .updatedAt
Update the filteredNotes computed property to respect the sort option:
var filteredNotes: [StoryNote] {
var result = notes.map { $0 }
if !searchText.isEmpty {
result = result.filter { note in
note.title
.localizedCaseInsensitiveContains(searchText) ||
note.content
.localizedCaseInsensitiveContains(searchText) ||
note.movieProject
.localizedCaseInsensitiveContains(searchText)
}
}
if let project = filterProject {
result = result.filter {
$0.movieProject == project
}
}
if filterDraftOnly {
result = result.filter { $0.isDraft }
}
// Apply sort
switch sortOption {
case .updatedAt:
result.sort { $0.updatedAt > $1.updatedAt }
case .createdAt:
result.sort { $0.createdAt > $1.createdAt }
case .title:
result.sort {
$0.title.localizedCompare($1.title)
== .orderedAscending
}
case .movieProject:
result.sort {
$0.movieProject
.localizedCompare($1.movieProject)
== .orderedAscending
}
}
return result
}
Add a sort picker to the toolbar:
// Add as another ToolbarItem in NotesListView
ToolbarItem(placement: .secondaryAction) {
Menu {
Picker("Sort By", selection: $sortOption) {
ForEach(SortOption.allCases, id: \.self) { option in
Text(option.rawValue).tag(option)
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
}
Swipe Actions
Add quick swipe actions to the note rows for common operations:
// Update the ForEach in NotesListView
ForEach(filteredNotes) { note in
NavigationLink(value: note) {
NoteRowView(note: note)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
modelContext.delete(note)
try? modelContext.save()
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
note.isDraft.toggle()
note.updatedAt = .now
try? modelContext.save()
} label: {
Label(
note.isDraft
? "Mark Final"
: "Mark Draft",
systemImage: note.isDraft
? "checkmark.circle"
: "pencil.circle"
)
}
.tint(note.isDraft ? .green : .orange)
}
}
Note Count Badge
Add a count of total notes to the navigation title:
// Update the navigationTitle in NotesListView
.navigationTitle("Story Notes (\(filteredNotes.count))")
Checkpoint: Build and run the final app. Walk through the complete experience:
- Launch the app and see the Pixar-themed notes in the list with movie project emojis and DRAFT badges.
- Tap a note to open the editor. Edit the title and content — changes save automatically.
- Tap the movie project chip to reassign the note to “Inside Out 3.”
- Swipe left on a note to delete it. Swipe right to toggle its draft status.
- Use the search bar to find notes by title or content.
- Filter by movie project using the filter menu.
- Sort notes by different criteria using the sort menu.
- Check the sync status bar at the bottom — it should show the last sync time.
- If testing on two devices, create a note on one device and verify it appears on the other within 15-30 seconds.
The app should feel responsive, handle offline scenarios gracefully, and sync reliably across devices.
Where to Go From Here?
Congratulations! You’ve built Pixar Story Notes — a fully functional offline-first notes app with SwiftData persistence, iCloud sync via CloudKit, conflict monitoring, schema versioning infrastructure, and a polished SwiftUI interface themed around Pixar movie screenwriting.
Here’s what you learned:
- How to design SwiftData models that are compatible with CloudKit sync (optional properties, default values, no unique constraints)
- How to configure a
ModelContainerwithcloudKitDatabase: .automaticfor seamless iCloud sync - How to build a responsive note-taking UI with
NavigationSplitView,@Query, and@Bindable - How to implement folder organization with SwiftData relationships
- How to monitor CloudKit sync events using
NSPersistentCloudKitContainer.eventChangedNotification - How to prepare schema versioning with
VersionedSchemaandSchemaMigrationPlan - How to build offline indicators with
NWPathMonitorfrom the Network framework
Ideas for extending this project:
- Add Markdown rendering to the note content using
AttributedStringfor rich text formatting - Implement note sharing between iCloud users using CloudKit’s shared database (
CKShare) - Add a widget that shows the most recently edited note using WidgetKit
- Support images and sketches in notes using
PhotosPickerand SwiftData’s binary storage - Add full-text search indexing using Core Spotlight so notes appear in system search
- Implement note versioning with a history timeline so writers can revert to earlier drafts