親ビューに確定済みテキストを保持し、シート内で別のドラフトを編集することで、Mac用エディタを作成します。ドラフトを元に戻すのはCommitだけです。Cancelはシートを閉じます。このチュートリアルでは、自分のアプリ内で使うシンプルなプレーンテキストのメモを対象に、この流れを実装します。

CommitとCancelの動作を定義する

読者が行う操作はシンプルです。既存のメモを開き、複数行を修正し、修正全体を確定するか、元の内容をそのまま残します。この例では、テスト用のテキストと1回の編集セッションを使います。確定済みテキストはメモリ上に保持します。ファイルへの保存は、アプリごとに別途決めることです。

AppleのTextEditorは、複数行のスクロール可能なテキストを表示し、編集中の値へのバインディングを受け取ります。そのため、確定済みの値にバインドすると、編集内容がただちにその値へ反映されます。確定を保留するには、代わりにドラフトへバインドします。詳しくはAppleのTextEditorのドキュメントを参照してください。

この設計は3つの要素で構成されます。親が所有する確定済みテキスト、編集開始時に作られるスナップショット、そしてシートが所有する変更可能なドラフトです。SwiftのStringは値セマンティクスを持つため、ドラフトを変更しても確定済みの文字列は変わりません。コピーオンライトのストレージは実装上の最適化ですが、独立した値として振る舞う点は変わりません。詳しくはStringを参照してください。

イベント ドラフトの動作 確定済みテキスト
エディタを開く 現在の確定済みテキストから開始 変更なし
テキストを入力または削除する シート内で変更 変更なし
Commitを選ぶ 親に渡されてからシートが閉じる ドラフトに置き換わる
Cancelを選ぶ ドラフトを渡さずにシートが閉じる 変更なし
エディタを再度開く 新しいセッションとして開始 新しい開始テキストを提供

この表は実装上の契約でもあります。シートを閉じること自体は確定を意味しません。この違いを明確にすれば、ロールバック処理なしでキャンセルを実現できます。

macOS用のサンプル全体を作成する

XcodeでSwiftUIを使ったmacOSアプリプロジェクトを作成し、デプロイメントターゲットをmacOS 12以降に設定します。生成されたアプリとコンテンツビューの宣言を次の単一ソースファイルに置き換え、ターゲット内に@mainエントリポイントが1つだけ残るよう、古いアプリ宣言を削除してください。このサンプルは、より新しい属性付きテキスト用イニシャライザではなく、プレーンテキストの編集を使います。

macOS 12以上が必要なのは、シートのDismissActionを使うためです。プレーンテキストのTextEditorはmacOS 11から利用できます。AppleはDismissActionと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)
    }
}

これは、初期テキスト、表示状態、エディタ、エントリポイントを含む、アプリケーション全体のサンプルです。調査環境ではSwiftコンパイラとmacOS SDKを利用できなかったため、このサンプルは取得したAPIドキュメントと照らして確認しましたが、ここではコンパイルも実行もしていません。

確定済みの値を親が所有する理由

親がacceptedTextを所有するのは、確定した結果を表示し、次の編集セッションの開始値を渡すためです。子がdraftを所有するのは、編集インターフェイスだけがその値を変更する必要があるためです。これは、状態をプライベートに保ち、ビュー階層の適切な位置に配置するというAppleのガイダンスに沿っています。詳しくはStateを参照してください。

親は$acceptedTextをエディタに渡しません。代わりに文字列値とクロージャを渡します。修正を確定する代入は、このクロージャ内にあるacceptedText = newTextだけです。この実装では、入力もキャンセルもその代入に到達しません。

開くたびにセッションIDを作る理由

オプショナルなEditSessionが、表示状態と初期テキストの両方を提供します。Appleの説明によると、sheet(item:onDismiss:content:)は、バインドされたアイテムがnilでないときにコンテンツを表示し、そのアイテムをコンテンツクロージャに渡します。詳しくはsheet(item:onDismiss:content:)を参照してください。

Edit Noteを押すたびに、新しいIDを作成します。そのIDをエディタに適用することで、状態の有効期間を明示します。1つのドラフトは1つの編集セッションに属します。イニシャライザは初期状態の値を設定しますが、既存のドラフトを後からの親の変更と継続的に同期する仕組みではありません。

このタスクでは、そのスナップショットの動作が望ましいものです。親ビューの別の部分が更新されたために、編集中のメモが突然リセットされるべきではありません。再度開くと、直近に確定されたテキストから新しいドラフトが作成されます。

閉じる動作とキーボード操作を意図的に設計する

サンプルのように、表示されたエディタ内で@Environment(\.dismiss)を読み取ります。Appleによると、このアクションは宣言された環境に適用されます。表示元のルートビューで読み取ると、シートではなく、そのビューのウィンドウが対象になることがあります。詳しくはDismissActionを参照してください。

Cancelボタンは単にシートを閉じます。ドラフトは確定済みの値を変更していないため、そのまま破棄できます。Commitボタンはまず確定用クロージャを呼び出し、その後シートを閉じます。onDismissに確定処理を移さないでください。このコールバックはシートが閉じたことを表し、キャンセル時にも実行されます。

このサンプルでは、Appleが標準のキャンセル用ショートカットとして定めている.cancelActionを使い、EscapeキーをCancelに割り当てます。Command-Returnは、明示的なキー等価指定と修飾キーを使ってCommitに割り当てます。これらはアプリのインターフェイス内で使う操作用ショートカットです。詳しくはcancelActionとkeyboardShortcut(_:modifiers:)を参照してください。

この例ではコミットボタンに.defaultActionを割り当てていないため、複数行の編集では通常のReturnキーを使えます。Appleは.defaultActionを、修飾キーなしのReturnとして定義しています。この複数行インターフェイスでCommand-Returnを使うのは編集上の選択であり、すべてのMacエディタで使うべきだという意味ではありません。詳しくはdefaultActionを参照してください。

関連するインターフェイス内でショートカットが重複しないようにしてください。Appleの説明によると、複数のコントロールに同じショートカットが割り当てられている場合、解決時に最初に見つかった一致するコントロールが使われます。挿入位置をエディタ内に置いた状態で、表示されているボタンをクリックする場合とあわせて、ショートカットもテストしてください。詳しくはkeyboardShortcut(_:modifiers:)を参照してください。

簡単なテスト手順で確定の境界を確認する

実行中のMacアプリで、次の確認を行います。ドラフトエディタと即時反映型のエディタの違いを左右する動作を、見落としやすい空文字列のケースも含めて確認できます。

  1. Edit Noteを開き、2行目をArrange the green tiles in a row.に置き換えて、Cancelをクリックします。確定済みのメモでは、元の2行目が維持されているはずです。
  2. エディタを再度開きます。キャンセルした修正は反映されず、元の確定済みテキストが表示されるはずです。
  3. 同じ修正を行い、Commitをクリックします。確定済みのメモに、修正後の行が表示されるはずです。
  4. 再度開き、改行を追加してからCommand-Returnを使います。複数行のドラフト全体が確定済みのテキストになるはずです。
  5. 再度開いて変更を加え、編集中にEscapeを試します。キャンセルしても確定済みのテキストが変わらないことを確認してください。
  6. 再度開き、すべてのテキストを削除してからCommitをクリックします。親には(Empty note)が表示されるはずです。再度開くと、空のエディタが表示されます。

この例では空のテキストも有効です。実装では、空白の削除や改行の除去を行わず、文字列が空の場合も確定を無効にしません。独自のメモ型で内容が必須の場合は、ユーザーに見える検証ルールを追加し、ボタンの有効状態だけでなくコミットアクション内にも適用してください。

hasChangesの比較は、ステータスラベルの表示だけを制御します。変更がなくてもCommitを有効にしておけば、同じ値を代入するだけなので問題ありません。後から変更がない場合に無効化するなら、シートを明示的に閉じられるよう、Cancelは引き続き使える状態にしてください。

SwiftUIで十分な場合とAppKitが役立つ場合を知る

このプレーンテキストのタスクでは、TextEditorが必要な編集領域をすでに提供しています。ドラフトの変更と確定済みテキストを分離するために、独自のテキストレイアウトもAppKitブリッジも必要ありません。この分離は、アプリの状態設計で行います。

現在のSwiftUIは、macOS 26から利用できる、属性付き文字列を受け取るTextEditorイニシャライザもサポートしています。そのため、リッチテキストを扱うからといって、必ずしもSwiftUIを置き換える必要はありません。ただし、ドラフトの型を変更し、新しいデプロイメント要件に従う必要があります。Appleは、このエディタの書式属性の変換に関する制限も説明しています。詳しくはinit(text:selection:)を参照してください。

AppKit固有の編集動作やテキストレイアウトの直接制御が必要なら、NSViewRepresentableを通じてNSTextViewをホストするのが、サポートされている統合方法です。Appleは、AppKitビューの作成、更新、破棄を行うrepresentableのメソッドと、デリゲート経由の変更をSwiftUIへ伝えるコーディネーターについて説明しています。詳しくはNSViewRepresentableを参照してください。

このブリッジでも、同じ確定の契約を保ちます。AppKitの編集内容はドラフトを更新し、明示的な確定操作だけが確定済みモデルを更新します。実装上の注意として、SwiftUIの更新ごとにテキストビュー全体を置き換えるのは避け、実際に外部から届いた変更と、エディタ自身が出力した変更を区別してください。

TextKitにはさらに考慮すべき点があります。NSTextViewは、textLayoutManagerによる新しいレイアウトと、layoutManagerによる従来のレイアウトを公開しています。Appleの説明によると、macOS 12以降で後者にアクセスすると、NSLayoutManagerを使う互換モードにテキストビューが切り替わります。レイアウト機能を追加する際は、エンジンを意図的に選んでください。詳しくはTextKitとNSTextViewを参照してください。

最後に、このサンプルのコミット処理は同期的で、メモリ内で完結します。ドキュメントへの書き込みに拡張する場合は、確定をモデルの更新とするのか、書き込みの完了とするのかを決めてください。書き込み完了後に閉じる設計では、書き込みに失敗したらシートを開いたままにし、再試行できるようドラフトを保持してください。編集中に同じメモがアプリ内の別のインターフェイスから変更される可能性があるなら、置き換える前にリビジョンを確認してください。

基本の設計はシンプルです。編集セッションごとにドラフトを1つ作り、エディタでそのドラフトを変更し、明示的な確定経路を1つだけ設けます。この境界を保てば、編集領域を拡張してもキャンセルの動作は予測可能です。

出典