Untuk app Mac dengan baris yang dapat diedit satu baris, tangani Return dengan onSubmit, sisipkan model baru tepat di bawah baris saat ini, lalu fokuskan bidangnya menggunakan FocusState. Beri setiap baris ID yang stabil agar penyisipan mempertahankan hubungan antara teks, baris, dan fokus.

Tutorial ini membuat editor daftar lokal sederhana dengan entri sintetis seperti Item A. Menekan Return membuat baris kosong di bawah bidang yang sedang diedit, mempertahankan teks yang ada, dan memindahkan fokus papan ketik ke bidang baru. Teks tidak dipecah pada posisi titik penyisipan.

Kontrol teks mana yang mendukung perilaku ini?

Gunakan TextField satu baris jika setiap baris mewakili satu entri pendek. Apple secara eksplisit mendokumentasikan bahwa TextField memicu aksi onSubmit saat pengguna menekan tombol Return fisik atau perangkat lunak. Apple juga mendokumentasikan bahwa menetapkan nilai FocusState memindahkan fokus ke tampilan terkait. Kedua API tersedia di macOS 12 dan versi lebih baru. Apple: onSubmit(of:_:), Apple: FocusState.

Pilih kontrol sesuai dengan hal yang diedit pengguna:

Tugas pengeditan Titik awal Perilaku Return yang perlu diterapkan
Satu entri pendek per baris TextField satu baris Kirim, sisipkan di bawah, fokuskan baris baru
Paragraf di dalam setiap baris TextEditor atau tampilan teks AppKit Tentukan perbedaan antara menyisipkan baris dan menyisipkan baris baru
Dokumen dengan perintah teks khusus NSTextView yang disematkan di SwiftUI Tangani perintah teks yang relevan dan koordinasikan perubahan model

Apple menjelaskan TextEditor sebagai editor multiline yang dapat digulir. Dokumentasinya tidak menetapkan kontrak Return-untuk-mengirim yang sama seperti yang didokumentasikan untuk TextField. Karena itu, jika contoh diubah menjadi TextEditor, penanganan perintah perlu ditinjau kembali; memasang modifier yang sama saja bukan padanan yang didokumentasikan. Apple: TextEditor, Apple: onSubmit(of:_:).

Pisahkan identitas baris dari posisi baris

Tiga nilai memiliki fungsi yang berbeda: ID baris mengidentifikasi entri, indeks array menentukan lokasi penyisipan, dan ID yang difokuskan mengidentifikasi bidang yang harus menerima input papan ketik.

ForEach milik Apple membuat tampilan dari data yang memiliki identitas. Gunakan model Identifiable dengan ID yang disimpan saat baris dibuat. Dalam rancangan ini, ID yang sama juga menjadi nilai fokus baris. Hubungan ini memungkinkan entri mempertahankan identitasnya saat entri lain disisipkan di atasnya. Apple: ForEach.

Hindari penggunaan teks yang dapat diedit sebagai ID: dua baris kosong akan memiliki identitas yang sama, dan identitas itu akan berubah saat diedit. Posisi array juga bukan identitas yang baik untuk tugas penyisipan ini karena posisinya bergeser. Ini merupakan konsekuensi dari model data yang diusulkan, bukan persyaratan SwiftUI tambahan.

Setiap bidang memerlukan nilai fokus yang berbeda. Apple memperingatkan bahwa mengikat nilai fokus yang sama ke beberapa tampilan membuat ikatan fokus menjadi ambigu. Menyimpan UUID untuk setiap baris adalah cara langsung untuk membedakan bidang-bidang tersebut. Apple: FocusState.

Buat contoh macOS lengkap

Buat proyek App macOS di Xcode menggunakan SwiftUI, lalu atur target deployment ke macOS 12 atau versi lebih baru. Ganti titik masuk app dan tampilan konten yang dibuat secara otomatis dengan satu file berikut, lalu hapus deklarasi app asli agar target hanya memiliki satu titik masuk @main.

Contoh ini menggunakan VStack biasa di dalam tampilan gulir. Baris baru dicatat sebagai target fokus yang tertunda, lalu fokus diminta saat bidang tersebut muncul. Ini adalah strategi implementasi yang diturunkan dari API fokus dan kemunculan Apple; Apple tidak mendokumentasikan callback kemunculan sebagai jaminan universal bahwa fokus siap. Waktu persis onAppear bergantung pada tampilan. Apple: onAppear(perform:).

import SwiftUI
import Foundation
import Dispatch

private struct EditableRow: Identifiable {
    let id = UUID()
    var text: String
}

@main
struct RowEntryDemoApp: App {
    var body: some Scene {
        WindowGroup {
            RowEditor()
        }
    }
}

private struct RowEditor: View {
    @State private var rows = [
        EditableRow(text: "Item A"),
        EditableRow(text: "Item B")
    ]
    @State private var pendingFocusID: UUID?
    @FocusState private var focusedRowID: UUID?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Local row editor")
                .font(.headline)

            Text("Press Return in a field to insert a row below it.")
                .font(.subheadline)

            ScrollViewReader { proxy in
                ScrollView {
                    VStack(spacing: 10) {
                        ForEach($rows) { $row in
                            TextField("New item", text: $row.text)
                                .textFieldStyle(.roundedBorder)
                                .focused($focusedRowID, equals: row.id)
                                .onSubmit {
                                    insertRow(after: row.id)
                                }
                                .id(row.id)
                                .onAppear {
                                    focusIfPending(row.id) {
                                        proxy.scrollTo(row.id, anchor: .center)
                                    }
                                }
                        }
                    }
                    .padding(4)
                }
            }
        }
        .padding(20)
        .frame(minWidth: 420, minHeight: 280)
    }

    private func insertRow(after id: UUID) {
        guard pendingFocusID == nil,
              let index = rows.firstIndex(where: { $0.id == id })
        else { return }

        let newRow = EditableRow(text: "")
        pendingFocusID = newRow.id
        rows.insert(newRow, at: index + 1)
    }

    private func focusIfPending(
        _ id: UUID,
        scroll: @escaping () -> Void
    ) {
        guard pendingFocusID == id else { return }

        DispatchQueue.main.async {
            guard pendingFocusID == id,
                  rows.contains(where: { $0.id == id })
            else { return }

            scroll()
            focusedRowID = id
            pendingFocusID = nil
        }
    }
}

Model ini memuat semua data contoh dan file tersebut mendefinisikan setiap tipe dan fungsi yang dirujuk. Kompiler Swift dan runtime macOS tidak tersedia di lingkungan riset, jadi program ini ditinjau tetapi tidak dikompilasi atau dijalankan di sini.

Cara kerja penyisipan dan fokus

Ikatan string bidang memperbarui baris terkait saat pengguna mengedit. Karena itu, penangan pengiriman dapat menyisipkan baris baru tanpa mengganti atau menghapus entri saat ini. Apple mendokumentasikan pembaruan berkelanjutan untuk bidang teks yang terikat ke string. Apple: TextField.

Penangan penyisipan menemukan baris yang mengirim berdasarkan ID dan menyisipkan pada index + 1. Untuk baris di tengah, posisi itu menempatkan entri baru sebelum entri lama berikutnya. Untuk baris terakhir, posisinya sama dengan endIndex milik array, yang merupakan posisi penyisipan valid dan menambahkan elemen di akhir. Apple: insert(_:at:).

Callback kemunculan bidang baru memeriksa apakah ID-nya cocok dengan permintaan yang tertunda. Blok yang dikirim juga memeriksanya kembali sebelum menggulir dan menetapkan fokus. Pemeriksaan ini mencegah callback kemunculan yang tidak terkait mengambil alih permintaan. Contoh ini juga mengabaikan pengiriman tambahan saat permintaan fokus masih tertunda.

Pengiriman tambahan menunda pekerjaan agar tidak dilakukan di dalam callback kemunculan; ini bukan penghalang rendering yang terdokumentasi. Anggap pilihan penjadwalan ini sebagai sesuatu yang perlu diverifikasi pada versi macOS dan hierarki kontainer yang Anda dukung.

Menggulir dan memfokuskan adalah dua operasi terpisah. scrollTo menemukan anak berdasarkan pengenal tampilannya dan menggulir ke arahnya; menetapkan focusedRowID meminta fokus papan ketik. .id(row.id) yang eksplisit menyediakan target pengguliran. Apple: scrollTo(_:anchor:).

Periksa hasil dan telusuri kegagalan umum

Gunakan urutan penerimaan berikut dengan entri contoh:

  1. Klik Item A, ketik teks tambahan, lalu tekan Return sekali.
  2. Pastikan satu bidang kosong muncul di antara dua entri asli.
  3. Langsung ketik Item C dan pastikan teks itu muncul di bidang baru.
  4. Tekan Return di bidang terakhir dan pastikan baris baru muncul di bagian akhir.
  5. Tekan Return di bidang kosong dan pastikan baris kosong lainnya diizinkan.
  6. Lanjutkan sampai pengguliran diperlukan, lalu pastikan bidang yang disisipkan terlihat dan menerima ketikan.

Hasil-hasil ini perlu diverifikasi secara lokal. Contoh ini sengaja mengizinkan entri kosong dan mempertahankan seluruh string saat ini, bahkan ketika posisi titik penyisipan berada di tengahnya. Jika tugas Anda mengharuskan string tersebut dipecah menjadi dua entri, penanganan seleksi dan rentang teks menjadi persyaratan tambahan.

Jika sebuah baris muncul tetapi ketikan tetap masuk ke bidang lama, periksa ID yang difokuskan, callback kemunculan bidang baru, dan apakah jendela sedang aktif. Periksa juga apakah callback lain mengatur ulang fokus. Hindari membuat ulang ID baris saat menghitung tampilan.

Jika penyisipan terjadi dua kali, periksa penangan pengiriman di bagian hierarki tampilan yang lebih tinggi. Apple mengizinkan onSubmit pada kontrol individual dan tampilan yang menampungnya, serta mendokumentasikan submitScope sebagai cara untuk menghentikan pengiriman agar tidak diteruskan ke penangan yang lebih tinggi. Tetapkan aksi penyisipan pada satu penangan saja. Apple: onSubmit(of:_:).

Mengubah label pengiriman tidak menerapkan navigasi atau penyisipan. Apple mendefinisikan submitLabel sebagai pengaturan label pengiriman semantik. Mutasi model dan penetapan fokus tetap memerlukan kodenya sendiri. Apple: submitLabel(_:).

Untuk koleksi yang jauh lebih besar, tinjau ulang penggunaan stack yang memuat semua elemen sekaligus. Apple mencatat bahwa kontainer seperti List dan LazyVStack menanyakan elemen ForEach secara lazy. Karena itu, bidang yang berada di luar layar mungkin perlu dibawa ke tampilan sebelum callback kemunculannya dapat menyelesaikan permintaan fokus. Jangan mengganti kontainer tanpa menguji urutan tersebut. Apple: ForEach.

Kapan sebaiknya menggunakan AppKit dan TextKit?

Gunakan contoh SwiftUI untuk entri pendek yang berdiri sendiri. Jika baris berisi teks multiline atau memerlukan perilaku perintah teks yang presisi, alternatif yang didukung adalah menyematkan NSTextView sendiri melalui NSViewRepresentable. Apple mendokumentasikan representable sebagai jembatan untuk membuat dan memperbarui tampilan AppKit di dalam SwiftUI, dengan coordinator yang meneruskan interaksi delegate. Apple: NSViewRepresentable.

Untuk alternatif tersebut, pertahankan model ID baris yang sama dan terapkan urutan berikut:

  1. Buat tampilan teks dan tetapkan coordinator sebagai delegate-nya.
  2. Teruskan perubahan teks ke model baris terkait.
  3. Implementasikan textView(_:doCommandBy:) dan kenali perintah insertNewline(_:) sesuai perilaku yang dipilih untuk editor Anda.
  4. Saat menangani perintah itu sebagai penyisipan baris, pertahankan teks saat ini, sisipkan baris baru, lalu kembalikan true untuk menandai bahwa perintah telah ditangani. Kembalikan false untuk perintah yang tidak ditangani delegate.
  5. Setelah tampilan teks baru terpasang ke jendela yang menampungnya, minta fokus melalui metode makeFirstResponder(_:) milik jendela tersebut.

Apple mendokumentasikan perintah baris baru dan kontrak Boolean delegate: true berarti delegate telah menangani perintah tersebut, sedangkan false memungkinkan tampilan teks mencobanya. Ini menyediakan jalur penanganan perintah eksplisit untuk editor yang dimiliki app. Apple: insertNewline(_:), Apple: textView(_:doCommandBy:).

Pertahankan komposisi teks aktif saat menambahkan penanganan perintah khusus. AppKit menyediakan hasMarkedText() untuk melaporkan teks yang ditandai; gunakan status ini saat menentukan apakah delegate Anda harus menyerahkan perintah kepada sistem teks. Uji komposisi di editor yang dihasilkan, alih-alih berasumsi bahwa setiap interaksi terkait Return harus menyisipkan baris. Apple: hasMarkedText().

Untuk fokus, Apple secara eksplisit menginstruksikan pemanggil agar menggunakan makeFirstResponder(_:) milik jendela yang menampungnya, alih-alih memanggil becomeFirstResponder() milik tampilan teks secara langsung. Akses tata letak TextKit juga memiliki konsekuensi khusus versi: pada macOS 12 dan versi lebih baru, mengakses layoutManager tampilan teks secara eksplisit akan mengalihkannya ke mode kompatibilitas yang menggunakan NSLayoutManager. Penyisipan baris sendiri tidak memerlukan akses tersebut. Apple: NSTextView.

Untuk tugas satu baris, mulailah dengan contoh SwiftUI lengkap dan verifikasi urutan penyisipan, visibilitas, dan pengetikan di app Mac Anda. Identitas yang stabil, posisi penyisipan yang eksplisit, dan permintaan fokus yang terarah menyediakan perilaku inti; beralihlah ke tampilan teks AppKit jika tugas pengeditan memerlukan kontrol perintah dan seleksi.

Sumber