Build a Markdown Notes App with SwiftUI, SwiftData, and Live Preview
Every Pixar screenwriter juggling story pitches, character arcs, and scene rewrites knows the pain of scattered notes across sticky pads, text files, and half-remembered app settings. What they need — and what you’re about to build — is a focused Markdown notes app that renders their writing beautifully, persists it reliably, and scales from iPhone to iPad without a single extra line of layout code.
In this tutorial, you’ll build PixarDraft — a fully functional notes app where screenwriters can organize pitches
into folders, write in Markdown with a live side-by-side preview, search across all their notes, and share polished
drafts with a single tap. Along the way you’ll learn how to render Markdown with AttributedString, model hierarchical
data with SwiftData relationships, build an adaptive split-view layout for iPad, and wire up the system share sheet.
Prerequisites
- Xcode 16+ with iOS 18 deployment target
- Familiarity with SwiftData persistence
- Familiarity with SwiftUI lists and navigation
- Familiarity with navigation patterns for multi-column layouts
Contents
- Getting Started
- Step 1: Defining the SwiftData Models
- Step 2: Configuring the Model Container
- Step 3: Building the Folder Sidebar
- Step 4: Building the Note List
- Step 5: Writing the Markdown Editor View
- Step 6: Rendering Markdown with AttributedString
- Step 7: Adding the Live Split-View Preview
- Step 8: Implementing Full-Text Search
- Step 9: Adding Share Sheet Integration
- Step 10: Polishing the iPad Experience
- Where to Go From Here?
Getting Started
Open Xcode and create a new project using the App template.
- Set the product name to PixarDraft.
- Set the organization identifier to your own bundle prefix.
- Ensure the interface is SwiftUI and the language is Swift.
- Set the Minimum Deployments target to iOS 18.0 in the project editor — this unlocks the full SwiftData macro
set and the
AttributedStringMarkdown initializer we rely on throughout the tutorial.
You won’t need any additional Swift packages for this tutorial. AttributedString’s built-in Markdown support ships
with Foundation, and the share sheet is handled by the standard ShareLink view from SwiftUI. No third-party parsing
libraries needed.
Once the project is created, delete the boilerplate ContentView.swift — you’ll build the view hierarchy from scratch.
Step 1: Defining the SwiftData Models
The heart of PixarDraft is a two-level data hierarchy: a Folder contains many Note objects. Each Note stores its
raw Markdown source plus metadata like a title, creation date, and last-modified date.
Create a new Swift file at Models/Folder.swift and add:
import Foundation
import SwiftData
@Model
final class Folder {
var name: String
var colorHex: String // e.g. "#2337ff" for accent tinting
var createdAt: Date
@Relationship(deleteRule: .cascade, inverse: \Note.folder)
var notes: [Note]
init(name: String, colorHex: String = "#2337ff") {
self.name = name
self.colorHex = colorHex
self.createdAt = .now
self.notes = []
}
}
The deleteRule: .cascade on the relationship ensures that deleting a folder cascades to all its notes — you won’t be
left with orphaned records. The inverse parameter tells SwiftData which property on Note points back to its parent.
Apple Docs:
@Relationship— SwiftData
Now create Models/Note.swift:
import Foundation
import SwiftData
@Model
final class Note {
var title: String
var body: String // raw Markdown source
var createdAt: Date
var modifiedAt: Date
var folder: Folder? // optional: nil means "Unfiled"
init(title: String, body: String = "", folder: Folder? = nil) {
self.title = title
self.body = body
self.createdAt = .now
self.modifiedAt = .now
self.folder = folder
}
/// Convenience: first non-empty line after the title for list previews.
var previewText: String {
let lines = body
.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
return lines.first ?? ""
}
}
previewText is a computed property — not persisted — so it doesn’t pollute the schema. SwiftData only persists stored
properties, which is exactly the behavior we want here.
Checkpoint: The project should compile cleanly at this point. There are no views yet, but both model files should type-check without errors. If you see
Cannot find type 'Note' in scope, verify theinversekey path inFolder.swiftmatches the property name inNote.swift.
Step 2: Configuring the Model Container
SwiftData requires a ModelContainer registered
at the top of the app so the environment is available to all child views. Open PixarDraftApp.swift and replace its
contents:
import SwiftUI
import SwiftData
@main
struct PixarDraftApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
.modelContainer(for: [Folder.self, Note.self])
}
}
Passing both Folder.self and Note.self to .modelContainer(for:) registers the schema and creates the on-disk
SQLite store automatically. SwiftData infers the full object graph from your @Relationship declarations, so you don’t
need to manually configure a schema version at this stage.
Now create Views/RootView.swift. This is the adaptive entry point that switches between a NavigationSplitView on
iPad and a NavigationStack on iPhone:
import SwiftUI
struct RootView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .regular {
SplitView() // iPad: three-column layout
} else {
CompactView() // iPhone: stacked navigation
}
}
}
You’ll build SplitView and CompactView in the later steps. For now, add stub implementations so the project
continues to compile:
struct SplitView: View {
var body: some View { Text("Split layout coming soon") }
}
struct CompactView: View {
var body: some View { Text("Compact layout coming soon") }
}
Checkpoint: Build and run on both an iPhone 16 simulator and an iPad simulator. On iPhone you should see “Compact layout coming soon.” On iPad you should see “Split layout coming soon.” If the app crashes at launch, verify
.modelContainer(for: [Folder.self, Note.self])is present inPixarDraftApp.swift.
Step 3: Building the Folder Sidebar
The sidebar lists all folders and lets screenwriters create new ones. Create Views/FolderSidebarView.swift:
import SwiftUI
import SwiftData
struct FolderSidebarView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Folder.createdAt) private var folders: [Folder]
@Binding var selectedFolder: Folder?
@State private var isAddingFolder = false
@State private var newFolderName = ""
var body: some View {
List(selection: $selectedFolder) {
// "All Notes" is a virtual folder — nil selection
Label("All Notes", systemImage: "tray.2")
.tag(Optional<Folder>.none)
Section("Folders") {
ForEach(folders) { folder in
Label(folder.name, systemImage: "folder")
.tag(Optional(folder))
}
.onDelete(perform: deleteFolders)
}
}
.navigationTitle("PixarDraft")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Folder", systemImage: "folder.badge.plus") {
isAddingFolder = true
}
}
}
.alert("New Folder", isPresented: $isAddingFolder) {
TextField("Folder name", text: $newFolderName)
Button("Create") { createFolder() }
Button("Cancel", role: .cancel) { newFolderName = "" }
} message: {
Text("Enter a name for the new folder.")
}
}
// MARK: - Private helpers
private func createFolder() {
guard !newFolderName.trimmingCharacters(in: .whitespaces).isEmpty
else { return }
let folder = Folder(name: newFolderName)
context.insert(folder)
newFolderName = ""
selectedFolder = folder
}
private func deleteFolders(at offsets: IndexSet) {
for index in offsets {
context.delete(folders[index])
}
}
}
A few things worth noting here:
@Query(sort: \Folder.createdAt)is a SwiftData property wrapper that keeps the list automatically in sync with the persistent store. No manual fetch requests required.selectedFolderis aBindingso the parent split view can react to folder changes and update the note list column.- The “All Notes” row uses a
.tag(Optional<Folder>.none)soList(selection:)can distinguish it from an unselected state.
Apple Docs:
@Query— SwiftData
Step 4: Building the Note List
The note list shows all notes for the selected folder (or every note when selectedFolder is nil). Create
Views/NoteListView.swift:
import SwiftUI
import SwiftData
struct NoteListView: View {
@Environment(\.modelContext) private var context
@Binding var selectedFolder: Folder?
@Binding var selectedNote: Note?
@Query private var allNotes: [Note]
// Filter on the client side for simplicity; for large datasets
// consider a predicate-based @Query instead.
private var notes: [Note] {
let sorted = allNotes.sorted { $0.modifiedAt > $1.modifiedAt }
guard let folder = selectedFolder else { return sorted }
return sorted.filter {
$0.folder?.persistentModelID == folder.persistentModelID
}
}
var body: some View {
List(notes, selection: $selectedNote) { note in
NoteRowView(note: note)
.tag(note)
}
.navigationTitle(selectedFolder?.name ?? "All Notes")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Note", systemImage: "square.and.pencil") {
createNote()
}
}
}
.overlay {
if notes.isEmpty {
ContentUnavailableView(
"No Notes",
systemImage: "note.text",
description: Text("Tap the pencil to start your first pitch.")
)
}
}
}
private func createNote() {
let note = Note(
title: "Untitled Pitch",
body: "# Untitled Pitch\n\nOnce upon a time...",
folder: selectedFolder
)
context.insert(note)
selectedNote = note
}
}
Now create the row subview at Views/NoteRowView.swift:
import SwiftUI
struct NoteRowView: View {
let note: Note
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(note.title)
.font(.headline)
.lineLimit(1)
Text(note.previewText)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(note.modifiedAt, style: .relative)
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}
Checkpoint: Build and run. On iPhone, the app still shows the compact stub. On iPad, you’ll still see the split stub. That’s expected — you’ll wire these views into the navigation structure in Step 7. For now, verify the project compiles with zero errors and that Xcode’s canvas preview renders
NoteRowViewwith placeholder data.
Step 5: Writing the Markdown Editor View
The editor is the core of the app. It presents a TextEditor for raw Markdown input and keeps note.body in sync with
every keystroke. Create Views/NoteEditorView.swift:
import SwiftUI
import SwiftData
struct NoteEditorView: View {
@Bindable var note: Note
@State private var showPreview = false
var body: some View {
Group {
if showPreview {
MarkdownPreviewView(markdown: note.body)
} else {
TextEditor(text: $note.body)
.font(.system(.body, design: .monospaced))
.padding(.horizontal, 4)
}
}
.navigationTitle($note.title) // editable title in nav bar
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(showPreview ? "Edit" : "Preview",
systemImage: showPreview ? "pencil" : "eye") {
showPreview.toggle()
}
}
ToolbarItem(placement: .secondaryAction) {
ShareLink(
item: note.body,
subject: Text(note.title),
message: Text("Shared from PixarDraft")
)
}
}
.onChange(of: note.body) {
note.modifiedAt = .now // keep the modified timestamp fresh
}
}
}
A few design decisions here:
@Bindableis the correct property wrapper for SwiftData model objects in SwiftUI — it creates two-way bindings to persisted properties without requiring@Stateor manual observation boilerplate. This is a Swift 5.9+ feature.- The
.navigationTitle($note.title)binding variant lets users rename notes inline by tapping the title bar — a great affordance that requires zero additional UI. ShareLinkis wired up here already; you’ll learn more about what it can do in Step 9.
Apple Docs:
@Bindable— SwiftUIApple Docs:
TextEditor— SwiftUINote: The
.onChange(of:)modifier uses the Swift 5.9 single-closure form that receives no arguments. If you’re on an older SDK, use.onChange(of: note.body) { _, _ in note.modifiedAt = .now }.
Step 6: Rendering Markdown with AttributedString
Foundation’s AttributedString gained a Markdown initializer in iOS 15, and it handles the common subset that
screenwriters care about — headings, bold, italic, code spans, and bullet lists. Create
Views/MarkdownPreviewView.swift:
import SwiftUI
struct MarkdownPreviewView: View {
let markdown: String
// Cache the attributed string so it doesn't reparse on every render.
private var attributedString: AttributedString {
(try? AttributedString(
markdown: markdown,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .inlineOnlyPreservingWhitespace,
failurePolicy: .returnPartiallyParsedIfPossible
)
)) ?? AttributedString(markdown)
}
var body: some View {
ScrollView {
Text(attributedString)
.textSelection(.enabled)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
The AttributedStringMarkdownParsingOptions deserve a closer look:
allowsExtendedAttributes: true— preserves custom attributes injected by the parser (useful if you later add syntax highlighting).interpretedSyntax: .inlineOnlyPreservingWhitespace— renders inline Markdown (bold, italic, code) while keeping whitespace for code blocks intact. Use.fullif you want block-level elements like thematic breaks.failurePolicy: .returnPartiallyParsedIfPossible— never shows a blank view even if the Markdown is malformed mid-edit.
Apple Docs:
AttributedString— FoundationApple Docs:
AttributedStringMarkdownParsingOptions— FoundationTip:
AttributedString’s Markdown support does not render fenced code blocks as syntax-highlighted regions. If your use case demands full CommonMark rendering including code fences, considerswift-markdownfrom the Swift open-source ecosystem — but that introduces an external dependency outside the scope of this tutorial.Checkpoint: Build and run. Tap the plus button to create a note, type some Markdown (
**Bold text**,# Heading,- list item), then tap the Preview button. You should see your formatting rendered. Bold text should be bold, the heading should be larger, and list items should appear as bullets. If the preview is blank, check thefailurePolicyparameter spelling.
Step 7: Adding the Live Split-View Preview
On iPad, a three-column NavigationSplitView
is the right container: sidebar for folders, content column for the note list, and detail column for the editor. On
iPhone, a simple NavigationStack push model works better.
Replace the stub SplitView in Views/RootView.swift with the real implementation. For clarity, move SplitView and
CompactView to their own files.
Create Views/SplitView.swift:
import SwiftUI
import SwiftData
struct SplitView: View {
@State private var selectedFolder: Folder?
@State private var selectedNote: Note?
@State private var columnVisibility = NavigationSplitViewVisibility.all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
FolderSidebarView(selectedFolder: $selectedFolder)
} content: {
NoteListView(
selectedFolder: $selectedFolder,
selectedNote: $selectedNote
)
} detail: {
if let note = selectedNote {
SplitEditorView(note: note)
} else {
ContentUnavailableView(
"Select a Note",
systemImage: "note.text",
description: Text("Choose a note from the list or create a new one.")
)
}
}
}
}
The split editor on iPad shows the raw text and the rendered preview side by side using a HStack with a Divider.
Create Views/SplitEditorView.swift:
import SwiftUI
struct SplitEditorView: View {
@Bindable var note: Note
@State private var editorWidth: CGFloat = 0
var body: some View {
GeometryReader { geo in
HStack(spacing: 0) {
// Left pane: raw Markdown editor
TextEditor(text: $note.body)
.font(.system(.body, design: .monospaced))
.padding(8)
.frame(width: geo.size.width / 2)
Divider()
// Right pane: live rendered preview
MarkdownPreviewView(markdown: note.body)
.frame(width: geo.size.width / 2)
}
}
.navigationTitle($note.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .secondaryAction) {
ShareLink(
item: note.body,
subject: Text(note.title),
message: Text("Shared from PixarDraft")
)
}
}
.onChange(of: note.body) {
note.modifiedAt = .now
}
}
}
Now create Views/CompactView.swift for iPhone:
import SwiftUI
struct CompactView: View {
@State private var selectedFolder: Folder?
@State private var selectedNote: Note?
@State private var showNoteList = false
var body: some View {
NavigationStack {
FolderSidebarView(selectedFolder: $selectedFolder)
.onChange(of: selectedFolder) {
showNoteList = true
}
.navigationDestination(isPresented: $showNoteList) {
NoteListView(
selectedFolder: $selectedFolder,
selectedNote: $selectedNote
)
}
}
}
}
Using .navigationDestination(isPresented:) avoids the need for value-based navigation on the folder selection — the
folder sidebar drives selection through a @Binding, so the compact view simply pushes the note list whenever a folder
is selected.
On iPhone, NoteListView pushes NoteEditorView via its own .navigationDestination. Open Views/NoteListView.swift
and add after the List:
.navigationDestination(for: Note.self) { note in
NoteEditorView(note: note)
}
And update the list to use programmatic navigation:
List(notes) { note in
NavigationLink(value: note) {
NoteRowView(note: note)
}
}
Checkpoint: Build and run on the iPad simulator with Stage Manager enabled. You should see a three-column layout: the folder sidebar on the left, the note list in the center, and the split editor on the right with Markdown on one side and rendered preview on the other, updating live as you type. On iPhone, tapping a note should push to the single-column
NoteEditorView. If the iPad detail column is blank, confirmselectedNoteis being set correctly inNoteListView.createNote().
Step 8: Implementing Full-Text Search
SwiftData’s @Query macro supports a filter predicate that maps directly to SQLite WHERE clauses. You’ll layer a
searchable modifier on the note list and drive a filtered query from the search term.
Open Views/NoteListView.swift and add search support. The key challenge is that @Query’s predicate must be
constructed at query initialization time — it can’t be dynamically swapped after initialization in the same property
wrapper. The idiomatic pattern is to use a child view that receives the search string as a parameter and constructs its
own @Query with the predicate baked in.
Replace the body of NoteListView with the following two-level approach:
import SwiftUI
import SwiftData
struct NoteListView: View {
@Binding var selectedFolder: Folder?
@Binding var selectedNote: Note?
@State private var searchText = ""
var body: some View {
FilteredNoteListView(
searchText: searchText,
selectedFolder: selectedFolder,
selectedNote: $selectedNote
)
.searchable(text: $searchText, prompt: "Search notes")
.navigationTitle(selectedFolder?.name ?? "All Notes")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Note", systemImage: "square.and.pencil") {
createNote()
}
}
}
}
@Environment(\.modelContext) private var context
private func createNote() {
let note = Note(
title: "Untitled Pitch",
body: "# Untitled Pitch\n\nOnce upon a time...",
folder: selectedFolder
)
context.insert(note)
selectedNote = note
}
}
Now create the inner view that holds the dynamic @Query. Create Views/FilteredNoteListView.swift:
import SwiftUI
import SwiftData
struct FilteredNoteListView: View {
@Environment(\.modelContext) private var context
let searchText: String
let selectedFolder: Folder?
@Binding var selectedNote: Note?
@Query private var allNotes: [Note]
init(searchText: String, selectedFolder: Folder?, selectedNote: Binding<Note?>) {
self.searchText = searchText
self.selectedFolder = selectedFolder
self._selectedNote = selectedNote
// Build the @Query predicate from the current search text.
// An empty search string returns all notes.
let predicate: Predicate<Note>
if searchText.isEmpty {
predicate = #Predicate<Note> { _ in true }
} else {
predicate = #Predicate<Note> { note in
note.title.localizedStandardContains(searchText) ||
note.body.localizedStandardContains(searchText)
}
}
_allNotes = Query(filter: predicate, sort: \Note.modifiedAt, order: .reverse)
}
private var notes: [Note] {
guard let folder = selectedFolder else { return allNotes }
return allNotes.filter {
$0.folder?.persistentModelID == folder.persistentModelID
}
}
var body: some View {
List(notes) { note in
NavigationLink(value: note) {
NoteRowView(note: note)
}
}
.navigationDestination(for: Note.self) { note in
NoteEditorView(note: note)
}
.overlay {
if notes.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
}
}
The #Predicate macro compiles your Swift expression into an
NSPredicate compatible with SwiftData’s SQLite
backend. localizedStandardContains gives you case-insensitive, diacritic-insensitive matching — the same behavior as
Spotlight.
Apple Docs:
#Predicate— FoundationWarning:
#Predicateexpressions must use keyPaths that are persisted@Modelproperties. Computed properties likepreviewTextcannot appear inside a#Predicate— the compiler will reject them. Only use#Predicateontitleandbody.Checkpoint: Build and run. Type a word from one of your test notes into the search bar. The list should filter in real time, showing only matching notes. Clearing the search bar should restore the full list. On iPad, the results should update without clearing the selected note in the detail column.
Step 9: Adding Share Sheet Integration
The ShareLink view you added in Steps 5 and 7 already
handles the raw Markdown text. Let’s extend it to offer two sharing formats: the raw Markdown source and a rendered
plain text version suitable for pasting into email or a script coverage document.
Create a helper that strips Markdown syntax to produce clean plain text. Add Extensions/String+Markdown.swift:
import Foundation
extension String {
/// Produces a plain-text approximation by stripping common Markdown syntax.
/// Not a full parser — intended for share-sheet preview only.
var strippedMarkdown: String {
var result = self
// Remove ATX headings: ## Heading → Heading (multiline match)
result = result.replacingOccurrences(
of: #"(?m)^#{1,6}\s+"#,
with: "",
options: .regularExpression
)
// Remove inline code: `code` → code
result = result.replacingOccurrences(
of: #"`([^`]+)`"#,
with: "$1",
options: .regularExpression
)
// Remove bold/italic markers
result = result.replacingOccurrences(of: "**", with: "")
result = result.replacingOccurrences(of: "__", with: "")
result = result.replacingOccurrences(of: "*", with: "")
result = result.replacingOccurrences(of: "_", with: "")
// Remove unordered list bullets (multiline match)
result = result.replacingOccurrences(
of: #"(?m)^[-*+]\s+"#,
with: "",
options: .regularExpression
)
return result
}
}
Now update the toolbar in NoteEditorView.swift to present both options:
ToolbarItem(placement: .secondaryAction) {
Menu("Share", systemImage: "square.and.arrow.up") {
ShareLink(
"Share as Markdown",
item: note.body,
subject: Text(note.title),
message: Text("Shared from PixarDraft")
)
ShareLink(
"Share as Plain Text",
item: note.body.strippedMarkdown,
subject: Text(note.title),
message: Text("Shared from PixarDraft")
)
}
}
Apply the same menu update to SplitEditorView.swift for the iPad path.
Apple Docs:
ShareLink— SwiftUICheckpoint: Build and run. Open a note and tap the share button. You should see a menu with two options: “Share as Markdown” and “Share as Plain Text.” Tapping either should present the system share sheet with the correct content. Test with AirDrop, Messages, or the Copy action to verify the text is correct.
Step 10: Polishing the iPad Experience
With the core functionality complete, a few final details make PixarDraft feel genuinely polished on iPad.
Keyboard Shortcuts
Power users navigating a large pitch document expect keyboard shortcuts. Add them to SplitEditorView.swift:
.keyboardShortcut("n", modifiers: [.command]) // new note
.keyboardShortcut("f", modifiers: [.command]) // focus search (handled by .searchable)
Add a Button with .keyboardShortcut in the toolbar for new note creation:
ToolbarItem(placement: .primaryAction) {
Button("New Note", systemImage: "square.and.pencil") {
// Notify parent via a closure or environment action
}
.keyboardShortcut("n", modifiers: .command)
}
Scene Storage for Column Visibility
The three-column split view should remember whether the sidebar was collapsed between launches. Use
@SceneStorage to persist the visibility across
sessions.
NavigationSplitViewVisibility does not conform to RawRepresentable out of the box for @SceneStorage. Store its raw
value as a String and expose a computed property that maps to the enum:
// In SplitView.swift — replace the @State declaration:
@SceneStorage("splitViewColumnVisibility")
private var columnVisibilityRaw: String = "all"
private var columnVisibility: NavigationSplitViewVisibility {
get {
switch columnVisibilityRaw {
case "detailOnly": return .detailOnly
case "doubleColumn": return .doubleColumn
default: return .all
}
}
set {
switch newValue {
case .detailOnly: columnVisibilityRaw = "detailOnly"
case .doubleColumn: columnVisibilityRaw = "doubleColumn"
default: columnVisibilityRaw = "all"
}
}
}
Empty State Illustrations
Add a custom ContentUnavailableView for the initial launch state when no note is selected. Open
Views/SplitView.swift and update the detail column placeholder:
ContentUnavailableView {
Label("No Note Selected", systemImage: "note.text")
} description: {
Text("Select a note from the list, or tap the pencil to start a new story pitch.")
} actions: {
Button("Create First Note", systemImage: "square.and.pencil") {
// trigger new note creation
}
.buttonStyle(.borderedProminent)
}
Drag-and-Drop Note Reordering
Add onMove to the note list to let screenwriters re-order pitches within a folder. Because @Query returns results in
database order, you’ll maintain a displayOrder integer property on Note.
Open Models/Note.swift and add:
var displayOrder: Int // ← New property for manual ordering
init(title: String, body: String = "", folder: Folder? = nil, displayOrder: Int = 0) {
// ...existing init...
self.displayOrder = displayOrder
}
Then update the @Query sort in FilteredNoteListView to use displayOrder as the primary sort and modifiedAt as a
secondary:
_allNotes = Query(
filter: predicate,
sort: [
SortDescriptor(\Note.displayOrder),
SortDescriptor(\Note.modifiedAt, order: .reverse)
]
)
Apple Docs:
SortDescriptor— FoundationApple Docs:
NavigationSplitViewVisibility— SwiftUICheckpoint: Build and run the full app on an iPad with Stage Manager or in Split View multitasking. The three-column layout should be visible with a live Markdown preview updating as you type. Collapsing the sidebar should be remembered across launches thanks to
@SceneStorage. The share sheet should present two format options. Search should filter across all notes in real time.
Congratulations — PixarDraft is feature-complete.
Where to Go From Here?
You’ve built PixarDraft — a production-quality Markdown notes app with SwiftData persistence, live split-view editing, full-text search, and share sheet integration, all scaling gracefully from iPhone to iPad.
Here’s what you learned:
- How to model hierarchical data with SwiftData
@Relationshipand cascade deletion rules. - How to render Markdown using Foundation’s
AttributedStringinitializer with configurable parsing options. - How to build an adaptive three-column layout with
NavigationSplitViewand fall back toNavigationStackon compact size classes. - How to drive dynamic
@Querypredicates from user input by using a parameterized child view. - How to expose multiple sharing formats with
ShareLinkinside aMenu. - How to persist UI state across launches with
@SceneStorage.
Ideas for extending this project:
- Add iCloud sync by enabling the CloudKit container entitlement and switching to
.modelContainer(for:isStoredInMemoryOnly:cloudKitDatabase:). - Implement tag-based organization as a many-to-many
@RelationshipbetweenNoteand aTagmodel — the SwiftData Relationships post walks through exactly this pattern. - Add a custom
TextEditortoolbar with formatting shortcuts (bold, italic, heading) that insert Markdown syntax at the cursor position usingTextEditor’sfocusedmodifier andUIPasteboard. - Explore widget extensions that surface the most recently modified note on the Home Screen using
WidgetKitand a sharedModelContainer. - Build a Spotlight integration using
CoreSpotlightso notes appear in system search results.