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

Contents

Getting Started

Open Xcode and create a new project using the App template.

  1. Set the product name to PixarDraft.
  2. Set the organization identifier to your own bundle prefix.
  3. Ensure the interface is SwiftUI and the language is Swift.
  4. Set the Minimum Deployments target to iOS 18.0 in the project editor — this unlocks the full SwiftData macro set and the AttributedString Markdown 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 the inverse key path in Folder.swift matches the property name in Note.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 in PixarDraftApp.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.
  • selectedFolder is a Binding so 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) so List(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 NoteRowView with 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:

  • @Bindable is the correct property wrapper for SwiftData model objects in SwiftUI — it creates two-way bindings to persisted properties without requiring @State or 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.
  • ShareLink is wired up here already; you’ll learn more about what it can do in Step 9.

Apple Docs: @Bindable — SwiftUI

Apple Docs: TextEditor — SwiftUI

Note: 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 .full if 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 — Foundation

Apple Docs: AttributedStringMarkdownParsingOptions — Foundation

Tip: 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, consider swift-markdown from 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 the failurePolicy parameter 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, confirm selectedNote is being set correctly in NoteListView.createNote().

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 — Foundation

Warning: #Predicate expressions must use keyPaths that are persisted @Model properties. Computed properties like previewText cannot appear inside a #Predicate — the compiler will reject them. Only use #Predicate on title and body.

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 — SwiftUI

Checkpoint: 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 — Foundation

Apple Docs: NavigationSplitViewVisibility — SwiftUI

Checkpoint: 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 @Relationship and cascade deletion rules.
  • How to render Markdown using Foundation’s AttributedString initializer with configurable parsing options.
  • How to build an adaptive three-column layout with NavigationSplitView and fall back to NavigationStack on compact size classes.
  • How to drive dynamic @Query predicates from user input by using a parameterized child view.
  • How to expose multiple sharing formats with ShareLink inside a Menu.
  • 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 @Relationship between Note and a Tag model — the SwiftData Relationships post walks through exactly this pattern.
  • Add a custom TextEditor toolbar with formatting shortcuts (bold, italic, heading) that insert Markdown syntax at the cursor position using TextEditor’s focused modifier and UIPasteboard.
  • Explore widget extensions that surface the most recently modified note on the Home Screen using WidgetKit and a shared ModelContainer.
  • Build a Spotlight integration using CoreSpotlight so notes appear in system search results.