Build a Mac editor by keeping accepted text in the parent view and editing a separate draft in a sheet. Only Commit copies the draft back; Cancel dismisses it. This tutorial implements that flow for a small, plain-text note inside your own app.

Define what commit and cancel should do

The reader task is straightforward: open an existing note, revise several lines, and either accept the complete revision or leave the original untouched. The example uses synthetic text and a single editing session. It keeps accepted text in memory; adding file storage is a separate application decision.

Apple’s TextEditor displays multiline, scrollable text and accepts a binding to the value being edited. A binding to the accepted value would therefore allow edits to change that value immediately. To implement deferred acceptance, bind the editor to a draft instead. See Apple’s TextEditor documentation.

The design has three pieces: accepted text owned by the parent, a snapshot created when editing starts, and mutable draft state owned by the sheet. Swift’s String has value semantics, so changing the draft does not change the accepted string. Its copy-on-write storage is an implementation optimization; the independent-value behavior remains the same. See String.

Event Draft behavior Accepted text
Open editor Starts from the current accepted text Unchanged
Type or remove text Changes within the sheet Unchanged
Choose Commit Passed to the parent, then sheet closes Replaced with the draft
Choose Cancel Sheet closes without passing the draft back Unchanged
Reopen editor Starts a fresh session Supplies the new starting text

This table is also the implementation contract. Closing the sheet is not itself acceptance. Keeping that distinction explicit makes cancellation work without a rollback operation.

Create the complete macOS example

Create a macOS app project in Xcode using SwiftUI, and set its deployment target to macOS 12 or later. Replace the generated app and content-view declarations with the following single source file, removing the old app declaration so the target contains exactly one @main entry point. The sample uses plain-text editing rather than the newer attributed-text initializer.

The macOS 12 minimum comes from the sheet’s DismissAction; the plain-text TextEditor is available from macOS 11. These availability boundaries are documented by Apple in DismissAction and TextEditor.

import SwiftUI
import Foundation

@main
struct DraftEditorApp: App {
    var body: some Scene {
        WindowGroup {
            NoteView()
        }
    }
}

private struct EditSession: Identifiable {
    let id = UUID()
    let initialText: String
}

private struct NoteView: View {
    @State private var acceptedText = """
    Sample note
    Arrange the blue tiles beside the green tiles.
    """
    @State private var editSession: EditSession?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Accepted note")
                .font(.headline)

            ScrollView {
                Text(acceptedText.isEmpty ? "(Empty note)" : acceptedText)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }

            Button("Edit Note") {
                editSession = EditSession(initialText: acceptedText)
            }
        }
        .padding(20)
        .frame(minWidth: 460, minHeight: 300)
        .sheet(item: $editSession) { session in
            DraftEditor(initialText: session.initialText) { newText in
                acceptedText = newText
            }
            .id(session.id)
        }
    }
}

private struct DraftEditor: View {
    @Environment(\.dismiss) private var dismiss
    @State private var draft: String

    private let initialText: String
    private let onCommit: (String) -> Void

    init(
        initialText: String,
        onCommit: @escaping (String) -> Void
    ) {
        self.initialText = initialText
        self.onCommit = onCommit
        _draft = State(initialValue: initialText)
    }

    private var hasChanges: Bool {
        draft != initialText
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Edit note")
                .font(.headline)

            TextEditor(text: $draft)
                .font(.body)
                .accessibilityLabel("Note draft")
                .frame(minHeight: 220)

            Text(hasChanges ? "Draft changed" : "No changes")
                .font(.caption)
                .foregroundColor(.secondary)

            HStack {
                Spacer()

                Button("Cancel") {
                    dismiss()
                }
                .keyboardShortcut(.cancelAction)

                Button("Commit") {
                    onCommit(draft)
                    dismiss()
                }
                .keyboardShortcut(.return, modifiers: .command)
            }
        }
        .padding(20)
        .frame(minWidth: 480, minHeight: 340)
    }
}

This is a complete application example, including its initial text, presentation state, editor, and entry point. A Swift compiler and macOS SDK were unavailable in the research environment, so the sample was inspected against the retrieved API documentation rather than compiled or run here.

Why the parent owns the accepted value

The parent owns acceptedText because it displays the accepted result and supplies the next editing session’s starting value. The child owns draft because only the editing interface needs to change it. This follows Apple’s guidance to keep state private and place it at the appropriate level of the view hierarchy. See State.

The parent does not pass $acceptedText into the editor. Instead, it passes a string value and a closure. The closure contains the only assignment that accepts a revision: acceptedText = newText. In this implementation, neither typing nor cancellation can reach that assignment.

Why each opening gets a session identifier

The optional EditSession supplies both presentation state and the initial text. Apple documents that sheet(item:onDismiss:content:) presents content when its bound item is non-nil, passing that item to the content closure. See sheet(item:onDismiss:content:).

Each press of Edit Note creates a new identifier. Applying that identifier to the editor makes the intended state lifetime explicit: one draft belongs to one editing session. The initializer supplies the initial state value; it is not a mechanism for continuously synchronizing an existing draft with later parent changes.

For this task, that snapshot behavior is desirable. A partially edited note should not suddenly reset because some unrelated part of the parent view updates. Reopening creates a fresh draft from whatever text was most recently accepted.

Make dismissal and keyboard behavior deliberate

Read @Environment(\.dismiss) inside the presented editor, as the example does. Apple explains that the action applies to the environment in which it is declared. Reading it in the presenting root view can target that view’s window instead of the sheet. See DismissAction.

The Cancel button simply dismisses the sheet. The draft can disappear because it has never changed the accepted value. The Commit button first invokes the acceptance closure and then dismisses. Do not move acceptance into onDismiss: that callback describes dismissal, which also happens when someone cancels.

The sample assigns Escape to Cancel using .cancelAction, Apple’s documented standard cancellation shortcut. It assigns Command-Return to Commit using an explicit key equivalent and modifier. These are control shortcuts within the application’s interface. See cancelAction and keyboardShortcut(_:modifiers:).

Plain Return remains available for multiline editing because the example does not assign .defaultAction to the commit button. Apple defines .defaultAction as Return without modifiers. Command-Return is an editorial choice for this multiline interface, rather than a claim that every Mac editor should use it. See defaultAction.

Keep shortcuts unique within the relevant interface. Apple documents that when several controls have the same shortcut, the first matching control found during resolution is used. Test the shortcuts while the insertion point is inside the editor, alongside clicking the visible buttons. See keyboardShortcut(_:modifiers:).

Verify the acceptance boundary with a short test sequence

Use the following checks in the running Mac app. They exercise the behavior that distinguishes a draft editor from a live editor, including the easy-to-miss empty-string case.

  1. Open Edit Note, replace the second line with Arrange the green tiles in a row., and click Cancel. The accepted note should retain its original second line.
  2. Reopen the editor. It should contain the original accepted text, with none of the canceled revision.
  3. Make the same revision and click Commit. The accepted note should now show the revised line.
  4. Reopen, add a line break, and use Command-Return. The complete multiline draft should become the accepted text.
  5. Reopen, make a change, and test Escape while editing. Check that cancellation leaves the accepted text unchanged.
  6. Reopen, remove all text, and click Commit. The parent should display (Empty note). Reopening should show an empty editor.

Empty text is valid in this example. The implementation does not trim whitespace, remove line breaks, or disable acceptance for an empty string. If your own note type requires content, add a visible validation rule and apply it inside the commit action as well as to the button’s enabled state.

The hasChanges comparison only controls the status label. Leaving Commit enabled without changes makes acceptance a harmless assignment of the same value. If you later disable it when unchanged, keep Cancel available so the sheet always has an explicit exit.

Know when SwiftUI is enough and when AppKit helps

For this plain-text task, TextEditor already provides the required editing surface. Neither custom text layout nor an AppKit bridge is necessary to separate draft changes from accepted text. That separation belongs to the application’s state design.

Current SwiftUI also supports an attributed-string TextEditor initializer, available from macOS 26. Rich text therefore does not automatically require replacing SwiftUI. It does require changing the draft’s type and respecting the newer deployment requirement. Apple also documents formatting-attribute conversion limits for that editor. See init(text:selection:).

If you need AppKit-specific editing behavior or direct text-layout control, the supported integration path is an NSTextView hosted through NSViewRepresentable. Apple describes representable methods for creating, updating, and dismantling an AppKit view, with a coordinator to communicate delegated changes back to SwiftUI. See NSViewRepresentable.

Preserve the same acceptance contract in that bridge: AppKit editing changes update the draft, and only the explicit commit action updates the accepted model. As an implementation precaution, avoid replacing the text view’s entire contents on every SwiftUI update; distinguish actual incoming changes from the editor’s own outgoing changes.

TextKit has a further boundary: NSTextView exposes modern layout through textLayoutManager and legacy layout through layoutManager. Apple documents that accessing the latter on macOS 12 or later switches the text view to compatibility mode using NSLayoutManager. Choose the engine deliberately when adding layout features. See TextKit and NSTextView.

Finally, the sample’s commit operation is synchronous and in memory. If you extend it to write a document, define whether acceptance means updating the model or completing that write. For a write-before-close design, keep the sheet open when writing fails and preserve the draft for another attempt. If the same note can change through another app-owned interface while editing, add a revision check before replacing it.

The essential design remains small: create one draft per editing session, let the editor change that draft, and give acceptance a single explicit path. With that boundary in place, cancellation stays predictable as the editing surface grows.

Sources