Tetapkan NSTextFieldDelegate, implementasikan controlTextDidEndEditing(_:), lalu salin stringValue milik field ke binding SwiftUI. Coordinator menghubungkan callback tersebut ke state tampilan, sehingga label dapat diperbarui setelah pengeditan selesai.

Tutorial ini membuat aplikasi Mac kecil dengan dua label yang dapat diedit dan ringkasan yang menampilkan nilai yang telah dikomit. Tujuannya adalah menjaga ringkasan tetap sama saat seseorang mengetik, lalu memperbaruinya ketika AppKit menyelesaikan sesi pengeditan. Apple mendokumentasikan bahwa callback akhir pengeditan terjadi setelah kontrol mengomit perubahan. Dokumentasi controlTextDidEndEditing(_:) dari Apple

Peristiwa apa yang sebaiknya memperbarui tampilan?

Untuk NSTextField yang sudah ada, gunakan callback delegasi pengeditan kontrolnya. Delegasi menerima notifikasi yang object-nya adalah kontrol yang mempostingnya, sehingga handler dapat mengenali field dan membaca nilainya. Dokumentasi textDidEndEditingNotification dari Apple

Pilih peristiwa sesuai perilaku antarmuka yang Anda perlukan:

Perilaku yang diinginkan Jalur yang sesuai
Perbarui nilai yang telah dikomit saat field teks AppKit selesai diedit Implementasikan controlTextDidEndEditing(_:) pada delegasinya.
Jalankan handler target-action AppKit yang sudah ada saat pengeditan selesai Konfigurasikan properti sendsActionOnEndEditing milik cell.
Tangani pengiriman dari TextField SwiftUI native Lampirkan .onSubmit untuk perilaku pengirimannya yang terdokumentasi.

Jalur-jalur ini memiliki kontrak berbeda. Apple mendokumentasikan bahwa mengaktifkan sendsActionOnEndEditing akan mengirimkan action setelah Return, Tab keluar dari field, atau klik pada field teks lain; menonaktifkannya membatasi action hanya pada Return. SwiftUI mendokumentasikan .onSubmit untuk TextField sebagai respons terhadap Return. Karena itu, mekanisme tersebut tidak menetapkan callback umum saat fokus hilang untuk field AppKit yang disematkan. Dokumentasi sendsActionOnEndEditing dari Apple, Dokumentasi onSubmit(of:_:) dari Apple

Untuk aplikasi ini, delegasi menjadi satu-satunya tempat untuk mengomit teks suatu field. Jika nanti Anda juga menambahkan handler target-action, hindari melakukan efek samping commit yang sama di kedua handler.

Buat contoh SwiftUI dan AppKit yang lengkap

Buat proyek aplikasi macOS di Xcode menggunakan Swift dan antarmuka SwiftUI. Gunakan toolchain terbaru yang mendukung Swift 6 dan tetapkan target deployment ke macOS 13 atau lebih baru untuk contoh ini. Ganti file entry aplikasi yang dihasilkan dengan program lengkap di bawah, lalu hapus ContentView.swift yang dihasilkan jika file tersebut mendeklarasikan ContentView lain. Pastikan hanya ada satu titik masuk @main. Dokumentasi App dari Apple menjelaskan struktur titik masuk ini. Dokumentasi App dari Apple

Target macOS 13 pada contoh ini adalah pilihan proyek, bukan versi awal bridge: Apple mencantumkan NSViewRepresentable sebagai tersedia mulai macOS 10.15. AppKit membuat implementasi ini khusus untuk target macOS. Dokumentasi NSViewRepresentable dari Apple

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
            }
        }
    }
}

Ini adalah contoh aplikasi lengkap dengan nilai lokal sintetis. Contoh ini telah ditinjau berdasarkan dokumentasi API yang diambil, tetapi belum dikompilasi atau dijalankan di sini: lingkungan eksekusi yang tersedia adalah Linux dan tidak memiliki toolchain Swift atau SDK macOS. Build dan jalankan di Xcode pada Mac.

Bagaimana nilai sampai ke ringkasan

Setiap nilai @State di parent menyediakan binding untuk wrapper field-nya. Coordinator menulis melalui binding tersebut saat pengeditan berakhir. SwiftUI kemudian memperbarui ringkasan karena ringkasan membaca state yang berubah. Apple mendokumentasikan pembaruan tampilan yang digerakkan oleh state serta penggunaan binding agar tampilan turunan dapat mengubah state yang dimiliki parent. Dokumentasi State dari Apple

makeNSView membuat kontrol AppKit yang dapat diedit dan menetapkan coordinator sebagai delegasinya. updateNSView menangani perubahan yang mengalir dari SwiftUI ke kontrol tersebut. Callback delegasi menangani arah sebaliknya. Panduan integrasi Apple menggunakan susunan coordinator dan binding yang sama untuk menampung tampilan AppKit. Sesi “Use SwiftUI with AppKit” dari Apple

Penetapan context.coordinator.parent = self menjaga nilai representable yang disimpan coordinator tetap mutakhir. Coordinator bertahan selama masa hidup tampilan yang ditampung, sementara nilai representable dapat berubah. Apple secara eksplisit menunjukkan cara menyegarkan nilai tersimpan itu selama updateNSView. Penjelasan siklus hidup coordinator dari Apple

Pertahankan draf selama pembaruan SwiftUI

Penjagaan utamanya adalah field.currentEditor() == nil. Contoh ini sengaja menyimpan teks yang belum selesai di AppKit sementara SwiftUI mempertahankan nilai terakhir yang telah dikomit. Tanpa pemeriksaan saat pengeditan berlangsung, menyalin nilai yang telah dikomit ke field selama pembaruan SwiftUI dapat menggantikan draf yang belum selesai.

Apple mendokumentasikan bahwa currentEditor() mengembalikan editor field milik kontrol, atau nil jika kontrol tidak memilikinya. Editor field adalah NSTextView yang digunakan bersama oleh kontrol-kontrol dalam jendela. Itulah sebabnya hanya memeriksa apakah firstResponder milik jendela sama dengan NSTextField merupakan pengganti yang buruk untuk menanyakan editor kepada kontrol. Dokumentasi currentEditor() dari Apple

Kebijakan desain yang diterapkan adalah sebagai berikut:

Situasi Perilaku contoh
Field tidak aktif dan binding-nya berubah Salin binding ke field pada pembaruan representable berikutnya.
Field memiliki editor aktif Pertahankan buffer pengeditan.
Pengeditan berakhir dengan teks yang berbeda Tulis teks yang selesai diedit ke binding.
Pengeditan berakhir tanpa perubahan teks Lewati penetapan state yang berulang.

Kebijakan ini memprioritaskan draf lokal selama sesi pengeditan. Jika kode aplikasi lain mengubah binding yang sama saat seseorang sedang mengedit, commit berikutnya dapat menggantikan perubahan itu. Demonstrasi ini tidak memiliki penulis lain. Dalam editor yang lebih besar, tentukan cara menyelesaikan perubahan serentak—misalnya, selesaikan sesi saat ini sebelum beralih ke item yang dipilih, atau simpan nilai draf dan nilai yang telah dikomit secara terpisah lalu sediakan pilihan eksplisit. Jangan membuang teks yang belum selesai hanya untuk menyinkronkan gambar ulang.

Kustomisasi tata letak TextKit tidak diperlukan untuk tugas ini. Batas yang relevan adalah sesi pengeditan kontrol dan editor field-nya. Panduan arsitektur teks Apple menjelaskan hubungan antara NSTextField, delegasinya, dan editor field NSTextView yang digunakan bersama; panduan tersebut telah diarsipkan, sedangkan referensi currentEditor() saat ini mengonfirmasi model editor bersama. Panduan “Text Editing” dari Apple, Referensi editor field Apple saat ini

Verifikasi perilaku penyelesaian di aplikasi

Gunakan urutan berikut untuk memeriksa contoh setelah membuatnya. Ini adalah pemeriksaan yang diharapkan berdasarkan implementasi dan perilaku pengeditan yang terdokumentasi, bukan hasil eksekusi yang dilaporkan.

  1. Ganti Blue Card dengan Green Card. Saat mengetik, ringkasan seharusnya tetap menampilkan Card: Blue Card.
  2. Tekan Return. Ringkasan seharusnya berubah menjadi Card: Green Card.
  3. Edit judul kartu lagi, lalu klik field Shelf label. Judul kartu yang telah selesai diedit seharusnya muncul di ringkasan.
  4. Edit nilai shelf, lalu tekan Tab. Pastikan ringkasan shelf diperbarui dan periksa navigasi keyboard.
  5. Commit nilai kosong. Ringkasan terkait seharusnya menampilkan prefiksnya tanpa nilai; contoh ini menerima string kosong.
  6. Akhiri pengeditan tanpa mengubah teksnya. Pemeriksaan kesetaraan seharusnya mencegah penetapan state lain yang tidak diperlukan.

Return dapat menyelesaikan pengeditan tanpa menghasilkan perpindahan fokus yang Anda harapkan. Apple mendokumentasikan bahwa setelah notifikasi akhir pengeditan, NSTextField mencoba menjalankan action dan penanganan Return lainnya, serta dapat memilih teksnya. Tab dan Shift-Tab mencoba menavigasi ke key view berikutnya atau sebelumnya. Saat memeriksa antarmuka, perlakukan penyelesaian dan tujuan fokus sebagai perilaku terpisah. Dokumentasi textDidEndEditing(_:) dari Apple

Diagnosis saat callback tidak diterima

Pertama, pastikan field.delegate masih merujuk ke coordinator. Properti ini bersifat weak, jadi objek delegasi sementara tanpa pemilik terpisah tidak akan tetap tersedia. Coordinator representable menyediakan masa hidup yang diperlukan di sini. Dokumentasi delegate dari Apple, Penjelasan siklus hidup coordinator dari Apple

Berikutnya, pastikan pengeditan benar-benar berakhir. Klik pada area latar sembarang bukan cara yang andal untuk menguji kondisi ini. Dalam demonstrasi ini, gunakan Return atau field lain yang dapat diedit, lalu periksa transisi responder tertentu jika hasilnya berbeda.

Jika aplikasi Anda menambahkan validasi, periksa control(_:textShouldEndEditing:). Nilai yang dikembalikannya menentukan apakah pengeditan boleh berakhir. Upaya keluar dari field yang ditolak tidak boleh dianggap sebagai commit yang berhasil. Dokumentasi control(_:textShouldEndEditing:) dari Apple

Periksa juga jalur pembatalan. Apple secara eksplisit menyatakan bahwa memanggil abortEditing() untuk membuang perubahan tertunda tidak memanggil controlTextDidEndEditing(_:). Karena itu, operasi pembatalan memerlukan perilaku state tersendiri yang dirancang dengan sengaja. Dokumentasi callback akhir pengeditan dari Apple

Minta penyelesaian melalui perintah aplikasi yang eksplisit

Untuk perintah Done mendatang yang memiliki referensi ke field ini, jalur responder yang didukung adalah meminta jendelanya sendiri memanggil makeFirstResponder(nil). Periksa Boolean yang dikembalikan sebelum melanjutkan: Apple mendokumentasikan bahwa responder saat ini dapat menolak untuk mengundurkan diri, sehingga metode mengembalikan false. Gunakan jendela aktual milik field, alih-alih menganggap key window aplikasi merupakan milik editor ini. Dokumentasi makeFirstResponder(_:) dari Apple

Jalankan akses AppKit dan callback UI bridge ini di main actor. Deklarasi delegasi saat ini diisolasi pada main actor, dan Swift mendokumentasikan MainActor sebagai menggunakan executor yang setara dengan antrean dispatch utama. Contoh ini menyatakan isolasi tersebut secara langsung dengan @MainActor; contoh tidak menambahkan task tertunda di antara penyelesaian dan penulisan ke binding. Deklarasi callback dari Apple, Dokumentasi MainActor dari Swift

Implementasi ini memiliki satu batas commit yang jelas: AppKit menyelesaikan pengeditan, coordinator membaca field, lalu binding memperbarui teks SwiftUI yang bergantung padanya. Dengan melindungi draf selama pembaruan representable, batas ini berguna untuk pengeditan label sehari-hari tanpa memerlukan perangkat tata letak teks khusus.

Sumber