NSTextFieldDelegateを設定し、controlTextDidEndEditing(_:)を実装して、フィールドのstringValueをSwiftUIのバインディングにコピーします。コーディネーターがこのコールバックをビューの状態につなぐため、編集終了後にラベルを更新できます。

このチュートリアルでは、編集可能なラベル2つと、確定済みの値を表示するサマリーを備えた小さなMacアプリを作ります。入力中はサマリーを変えず、AppKitが編集セッションを終了したときに更新するのが目的です。Appleの説明によると、編集終了コールバックはコントロールが変更を確定した後に呼び出されます。AppleのcontrolTextDidEndEditing(_:)のドキュメント

どのイベントでビューを更新するか

既存のNSTextFieldには、コントロール編集のデリゲートコールバックを使います。デリゲートは、objectが通知を送信したコントロールである通知を受け取るため、ハンドラーでフィールドを特定して値を読み取れます。AppleのtextDidEndEditingNotificationのドキュメント

必要なインターフェースの動作に応じてイベントを選びます。

求める動作 適切な方法
AppKitのテキストフィールドで編集が終了したときに確定済みの値を更新する デリゲートにcontrolTextDidEndEditing(_:)を実装します。
編集完了時に既存のAppKitターゲットアクションハンドラーを呼び出す セルのsendsActionOnEndEditingプロパティを設定します。
ネイティブSwiftUIのTextFieldからの送信を処理する ドキュメントに記載された送信動作に.onSubmitを付けます。

これらの方法はそれぞれ異なる仕様です。Appleの説明では、sendsActionOnEndEditingを有効にすると、Returnキーを押したとき、フィールドからTabキーで移動したとき、または別のテキストフィールドをクリックしたときにアクションが送信されます。無効にすると、Returnキーを押したときに限られます。SwiftUIのドキュメントでは、TextFieldの.onSubmitはReturnキーへの応答として説明されています。そのため、埋め込まれたAppKitフィールドでフォーカスを失ったときの汎用コールバックにはなりません。AppleのsendsActionOnEndEditingのドキュメント、AppleのonSubmit(of:_:)のドキュメント

このアプリでは、デリゲートがフィールドのテキストを確定する唯一の場所です。後からターゲットアクションハンドラーも追加する場合は、両方のハンドラーで同じ確定処理を実行しないようにします。

SwiftUIとAppKitの完全な例を作る

XcodeでSwiftとSwiftUIインターフェースを使ってmacOSアプリのプロジェクトを作成します。この例では、最近のSwift 6対応ツールチェーンを使い、デプロイメントターゲットをmacOS 13以降に設定します。生成されたアプリのエントリーファイルを以下の完全なプログラムに置き換え、別のContentViewを宣言している場合は生成されたContentView.swiftを削除してください。@mainのエントリーポイントは1つだけにします。AppleのAppのドキュメントでは、このエントリーポイント構造が説明されています。AppleのAppのドキュメント

この例でmacOS 13をターゲットにしているのはプロジェクト上の選択であり、ブリッジが初めて導入されたバージョンという意味ではありません。Appleによると、NSViewRepresentableはmacOS 10.15以降で利用できます。AppKitを使うこの実装はmacOSターゲット専用です。AppleのNSViewRepresentableのドキュメント

import SwiftUI
import AppKit

@main
@MainActor
struct EditingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@MainActor
struct ContentView: View {
    @State private var cardTitle = "Blue Card"
    @State private var shelfLabel = "Shelf A"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Card title")
            CommitTextField(value: $cardTitle, label: "Card title")
                .frame(height: 24)

            Text("Shelf label")
            CommitTextField(value: $shelfLabel, label: "Shelf label")
                .frame(height: 24)

            Divider()

            Text("Committed values")
                .font(.headline)
            Text("Card: \(cardTitle)")
            Text("Shelf: \(shelfLabel)")
        }
        .padding(20)
        .frame(minWidth: 420)
    }
}

@MainActor
struct CommitTextField: NSViewRepresentable {
    @Binding var value: String
    let label: String

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> NSTextField {
        let field = NSTextField(string: value)
        field.placeholderString = label
        field.delegate = context.coordinator
        return field
    }

    func updateNSView(_ field: NSTextField, context: Context) {
        context.coordinator.parent = self
        field.placeholderString = label
        field.isEnabled = context.environment.isEnabled

        // Leave the active editing buffer alone.
        if field.currentEditor() == nil, field.stringValue != value {
            field.stringValue = value
        }
    }

    static func dismantleNSView(
        _ field: NSTextField,
        coordinator: Coordinator
    ) {
        field.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextFieldDelegate {
        var parent: CommitTextField

        init(parent: CommitTextField) {
            self.parent = parent
            super.init()
        }

        func controlTextDidEndEditing(_ notification: Notification) {
            guard let field = notification.object as? NSTextField else {
                return
            }

            let committedValue = field.stringValue
            if parent.value != committedValue {
                parent.value = committedValue
            }
        }
    }
}

これは合成したローカル値を使う完全なアプリの例です。取得したAPIドキュメントと照らし合わせて確認しましたが、ここではコンパイルも実行もしていません。利用可能な実行環境はLinuxで、SwiftツールチェーンもmacOS SDKもありません。Mac上のXcodeでビルドして動作を確認してください。

値がサマリーに反映される仕組み

親の各@State値から、フィールドラッパーにバインディングを渡します。編集終了時に、コーディネーターがそのバインディングに書き込みます。変更された状態をSwiftUIが読み取るため、サマリーも更新されます。Appleのドキュメントでは、状態に基づくビュー更新と、子ビューから親が所有する状態を変更できるようにするバインディングの両方が説明されています。AppleのStateのドキュメント

makeNSViewは編集可能なAppKitコントロールを作成し、コーディネーターをそのデリゲートに設定します。updateNSViewはSwiftUIからそのコントロールへ流れる変更を処理します。デリゲートコールバックは逆方向の更新を処理します。Appleの連携ガイドでも、AppKitビューをホストする際に同じコーディネーターとバインディングの構成が使われています。Appleの「SwiftUIをAppKitと使う」セッション

context.coordinator.parent = selfという代入により、コーディネーターが保持するrepresentableを最新の状態に保ちます。コーディネーターはホストされたビューのライフタイム中存続しますが、representableの値は変わることがあります。AppleはupdateNSViewの間に保持値を更新する方法を明示的に示しています。Appleによるコーディネーターのライフサイクルの説明

SwiftUIの更新中に編集中のテキストを保護する

重要なガードはfield.currentEditor() == nilです。この例では、未確定のテキストをAppKit側に保持し、SwiftUIには最後に確定した値を保持させます。編集中かどうかを確認せずに、SwiftUIの更新中に確定済みの値をフィールドへコピーすると、未確定の入力が置き換えられるおそれがあります。

Appleの説明によると、currentEditor()はコントロールのフィールドエディタを返し、ない場合はnilを返します。フィールドエディタはウインドウ内の複数のコントロールで共有されるNSTextViewです。そのため、ウインドウのfirstResponderがNSTextFieldかどうかだけを調べるのは、コントロールにエディタを問い合わせる方法の代わりとして適切ではありません。AppleのcurrentEditor()のドキュメント

この設計方針による動作は次のとおりです。

状況 この例での動作
フィールドがアイドル状態で、バインディングが変わる 次回のrepresentable更新時に、バインディングの値をフィールドへコピーします。
フィールドにアクティブなエディタがある 編集バッファを保持します。
異なるテキストで編集が終了する 完了したテキストをバインディングに書き込みます。
変更のないテキストで編集が終了する 冗長な状態の代入を省きます。

この方針では、編集セッション中はローカルの未確定入力を優先します。編集中にアプリの別のコードが同じバインディングを変更すると、後で確定した値がその変更を上書きすることがあります。この例には競合する書き込み元はありません。より大きなエディタでは、たとえば現在のセッションを終了してから選択項目を切り替える、または未確定の値と確定済みの値を別々に保持して明示的な選択肢を提示するなど、同時変更の解決方法を決めてください。再描画を同期するためだけに未確定のテキストを破棄しないでください。

このタスクでは、TextKitのレイアウトをカスタマイズする必要はありません。関係する境界は、コントロールの編集セッションとそのフィールドエディタです。Appleのテキストアーキテクチャガイドでは、NSTextField、そのデリゲート、共有されるNSTextViewフィールドエディタの関係を説明しています。このガイドはアーカイブ版ですが、現在のcurrentEditor()のリファレンスでも共有エディタのモデルを確認できます。Appleの「テキスト編集」ガイド、Appleの現在のフィールドエディタのリファレンス

アプリで編集完了時の動作を確認する

ビルド後に、次の手順で例の動作を確認します。これは実行結果の報告ではなく、実装とドキュメントに記載された編集動作から導かれる確認項目です。

  1. Blue CardをGreen Cardに置き換えます。入力中、サマリーには引き続きCard: Blue Cardと表示されます。
  2. Returnキーを押します。サマリーがCard: Green Cardに変わります。
  3. カードのタイトルをもう一度編集し、Shelf labelフィールドをクリックします。確定したカードのタイトルがサマリーに表示されます。
  4. 棚の値を編集してからTabキーを押します。棚のサマリーが更新されることを確認し、キーボード操作も調べます。
  5. 空の値を確定します。対応するサマリーには接頭辞だけが値なしで表示されます。この例では空文字列を受け付けます。
  6. テキストを変えずに編集を終了します。等価性チェックにより、状態への再代入が省かれることを確認します。

Returnキーを押すと編集は終了しても、期待したフォーカス移動が起きない場合があります。Appleの説明によると、編集終了通知の後、NSTextFieldはアクションやその他のReturnキー処理を試み、テキストを選択することがあります。TabキーとShift-Tabキーは、次または前のキー表示順への移動を試みます。インターフェースを確認するときは、編集の完了とフォーカスの移動先を別々の動作として扱ってください。AppleのtextDidEndEditing(_:)のドキュメント

コールバックが届かない場合の確認

まず、field.delegateが引き続きコーディネーターを参照していることを確認します。このプロパティはweakなので、別の所有者がいない一時的なデリゲートオブジェクトは存続しません。この例では、representableのコーディネーターが必要なライフタイムを確保します。Appleのdelegateのドキュメント、Appleによるコーディネーターのライフサイクルの説明

次に、編集が実際に終了したことを確認します。任意の背景をクリックする方法では、この条件を確実にテストできません。この例ではReturnキーを押すか、別の編集可能なフィールドを使い、結果が異なる場合は具体的なレスポンダーの遷移を調べてください。

アプリに検証処理を追加している場合は、control(_:textShouldEndEditing:)を確認します。ドキュメントに記載された戻り値によって、編集を終了できるかどうかが決まります。フィールドから移動する試みが拒否された場合、確定に成功したものとして扱ってはいけません。Appleのcontrol(_:textShouldEndEditing:)のドキュメント

キャンセルの経路も確認してください。Appleは、保留中の編集を破棄するためにabortEditing()を呼び出しても、controlTextDidEndEditing(_:)は呼び出されないと明記しています。そのため、キャンセル操作には独自の状態処理が必要です。Appleの編集終了コールバックのドキュメント

アプリの明示的なコマンドから編集完了を要求する

将来、フィールドへの参照を持つDoneコマンドを追加する場合、サポートされているレスポンダーの方法は、そのフィールド自身のウインドウにmakeFirstResponder(nil)を呼び出させることです。処理を続ける前に戻り値を確認してください。Appleの説明によると、現在のレスポンダーが辞任を拒否すると、このメソッドはfalseを返します。アプリケーション全体のキーウインドウがこのエディタのものだと決めつけず、フィールドが実際に属するウインドウを使ってください。AppleのmakeFirstResponder(_:)のドキュメント

このブリッジのAppKitへのアクセスとUIコールバックは、メインアクター上で実行してください。現在のデリゲート宣言はメインアクターに隔離されています。Swiftの説明によると、MainActorはメインディスパッチキューと同等のエグゼキューターを使います。この例では@MainActorを明示してその隔離を表し、編集完了からバインディングへの書き込みの間に遅延タスクを挟みません。Appleのコールバック宣言、SwiftのMainActorのドキュメント

この実装には明確な確定の境界が1つあります。AppKitが編集を終了し、コーディネーターがフィールドの値を読み取り、バインディングが依存するSwiftUIのテキストを更新します。representableの更新中に未確定の入力を保護することで、カスタムのテキストレイアウト機構を必要とせず、日常的なラベル編集でもこの境界を活用できます。

出典