Untuk mengirim perintah menu SwiftUI ke NSTextView yang aktif, cari first responder jendela key saat perintah dijalankan, lalu kirimkan aksi ke tampilan tersebut. Tutorial ini membuat contoh dengan dua editor, di mana perintah Select First Line hanya memengaruhi editor yang sedang difokuskan.
Tugas ini adalah aksi menu khusus di dalam aplikasi macOS Anda sendiri. Contoh ini menggunakan teks sintetis, subclass NSTextView milik aplikasi, serta API SwiftUI dan AppKit publik. Keputusan desain utamanya adalah apakah perintah harus mengikuti rantai responder AppKit atau mengharuskan satu editor aktif tertentu.
Tentukan arti “text view aktif”
Untuk contoh ini, editor aktif adalah instance SampleTextView yang saat ini menjadi first responder jendela key. Apple mendefinisikan jendela key sebagai jendela yang menerima peristiwa keyboard; firstResponder mengidentifikasi responder yang biasanya menerima peristiwa dan aksi terlebih dahulu. Membaca properti ini saat pemanggilan memberi perintah tujuan terkininya. Dokumentasi Apple tentang keyWindow, dokumentasi Apple tentang firstResponder.
AppKit juga mendukung aksi tanpa target: berikan nil sebagai target kepada NSApplication.sendAction(_:to:from:), lalu aplikasi mencari responder yang menerapkan selector tersebut. Pencarian dimulai dari first responder jendela key, tetapi dapat berlanjut melalui delegat dan jendela utama sebelum mencapai penangan tingkat aplikasi. Karena itu, aksi tanpa target tidak berarti “jalankan hanya pada text view aktif di jendela key ini.” Dokumentasi Apple tentang perutean aksi.
| Perilaku yang diperlukan | Pilihan perutean | Konsekuensi |
|---|---|---|
| Biarkan AppKit mencari penangan yang sesuai | Kirim aksi dengan to: nil |
Fallback rantai responder normal berlaku. |
| Hanya memengaruhi editor milik aplikasi yang aktif | Cari dan periksa tipe first responder, lalu tetapkan sebagai target secara eksplisit | Perintah berhenti jika editor tersebut tidak aktif. |
| Mengoperasikan editor tertentu tanpa memedulikan fokus | Berikan editor tersebut sebagai target | Ini adalah interaksi yang berbeda dari perintah editor aktif. |
Implementasi di bawah memilih perilaku kedua. Pemeriksaan subclass khusus juga mencegah editor milik text field lain menjadi tujuan hanya karena ia merupakan text view.
Buat contoh lengkap SwiftUI dan AppKit
Buat proyek aplikasi macOS dengan SwiftUI dan gunakan target deployment macOS 13 atau lebih baru untuk contoh ini. Ganti titik masuk aplikasi yang dibuat otomatis dengan satu file Swift berikut; hapus deklarasi @main lainnya. Program ini menyediakan kedua editor, teks awalnya, menu, dan penangan aksi.
NSViewRepresentable milik SwiftUI adalah jembatan yang didukung untuk menyematkan tampilan AppKit. Perintah scene menyediakan isi menu, dan CommandMenu membuat menu khusus aplikasi di bilah menu macOS. Dokumentasi Apple tentang NSViewRepresentable, perintah scene, menu perintah.
import SwiftUI
import AppKit
import Foundation
@main
@MainActor
struct RoutingDemoApp: App {
var body: some Scene {
WindowGroup("Routing Demo") {
ContentView()
}
.commands {
CommandMenu("Sample Actions") {
Button("Select First Line") {
Self.routeSelectFirstLine()
}
}
}
}
private static func routeSelectFirstLine() {
let application = NSApplication.shared
guard let editor = application.keyWindow?.firstResponder
as? SampleTextView else {
return
}
_ = application.sendAction(
#selector(SampleTextView.selectFirstSampleLine(_:)),
to: editor,
from: nil
)
}
}
@MainActor
struct ContentView: View {
@State private var windowLabel = "Sample Window"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Window label", text: $windowLabel)
HStack(spacing: 12) {
SampleEditor(initialText: "Sample Alpha\nAlpha details")
SampleEditor(initialText: "Sample Beta\nBeta details")
}
Text("Click an editor, then use Sample Actions.")
.font(.caption)
}
.padding()
.frame(minWidth: 640, minHeight: 320)
}
}
@MainActor
final class SampleTextView: NSTextView {
@objc func selectFirstSampleLine(_ sender: Any?) {
guard isSelectable, !hasMarkedText() else { return }
let contents = string as NSString
guard contents.length > 0 else { return }
let firstLine = contents.lineRange(
for: NSRange(location: 0, length: 0)
)
setSelectedRange(firstLine)
scrollRangeToVisible(firstLine)
}
}
@MainActor
struct SampleEditor: NSViewRepresentable {
let initialText: String
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.borderType = .bezelBorder
let editor = SampleTextView(
frame: NSRect(x: 0, y: 0, width: 300, height: 240)
)
editor.isEditable = true
editor.isSelectable = true
editor.isRichText = false
editor.allowsUndo = true
editor.font = NSFont.monospacedSystemFont(
ofSize: 14, weight: .regular
)
editor.minSize = .zero
editor.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
editor.isVerticallyResizable = true
editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width]
editor.textContainer?.containerSize = NSSize(
width: 300,
height: CGFloat.greatestFiniteMagnitude
)
editor.textContainer?.widthTracksTextView = true
editor.string = initialText
scrollView.documentView = editor
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
// AppKit owns the editable contents in this example.
}
}
Ini adalah contoh aplikasi lengkap dengan isi editor yang disimpan di memori. Contoh ini telah diperiksa berdasarkan dokumentasi API yang diperoleh; contoh ini tidak dikompilasi atau dijalankan di sini karena lingkungan yang tersedia adalah Linux dan tidak memiliki toolchain Swift.
Mengapa selector mencapai editor yang dimaksud
Fungsi perutean mengambil first responder tepat sebelum pengiriman. Fungsi tersebut hanya menerima SampleTextView dan menetapkan objek itu sebagai target aksi secara eksplisit. Dengan demikian, menu tidak menyimpan editor yang diklik sebelumnya, dan tidak mencari di jendela lain saat pemeriksaan tipe gagal.
Metode aksi menggunakan @objc karena pengiriman target-action memakai selector Objective-C. Ekspresi #selector merujuk pada metode yang dideklarasikan, bukan string selector yang ditulis tangan. Apple mendokumentasikan kombinasi ini untuk memanggil metode Swift melalui API target-action Objective-C. Menggunakan Fitur Runtime Objective-C di Swift.
Nilai yang dikembalikan oleh sendAction menunjukkan apakah pesan terkirim. Nilai tersebut tidak menunjukkan apakah penangan mengubah seleksi: penangan dapat menerima pesan lalu langsung kembali jika isinya kosong atau ada teks yang sedang ditandai. Contoh ini sengaja mengabaikan hasil pengiriman karena tujuan dan metodenya sudah diketahui. Dokumentasi Apple tentang nilai kembalian sendAction.
Apa yang sebenarnya dipilih oleh perintah seleksi
Penangan meminta NSString.lineRange(for:) untuk mendapatkan baris logis yang memuat awal string. Rentang tersebut mencakup terminator barisnya jika ada. Karena itu, yang dipilih adalah baris pertama yang dipisahkan oleh karakter baris baru, sekalipun baris itu membungkus menjadi beberapa baris tampilan. Dokumentasi Apple tentang rentang baris.
Untuk teks yang disediakan, editor kiri memilih Sample Alpha beserta karakter baris baru setelahnya; editor kanan memilih Sample Beta beserta karakter baris baru setelahnya. Perintah ini menggantikan seleksi sebelumnya. Perintah ini tidak mengubah teks.
setSelectedRange(_:) menerapkan seleksi, dan scrollRangeToVisible(_:) menggulir agar karakter pertamanya terlihat. Pemeriksaan konten kosong menghindari upaya melakukan operasi seleksi tanpa teks. Pemeriksaan teks yang ditandai adalah kebijakan eksplisit dalam contoh ini: tunda aksi seleksi khusus ini selama komposisi input berlangsung. Rentang seleksi, menggulir rentang agar terlihat, teks yang ditandai.
Pisahkan pembuatan tampilan, pengeditan, dan fokus
Representable membuat NSScrollView dan memasang text view khusus sebagai document view-nya. Editor bertambah tinggi secara vertikal, sementara text container-nya mengikuti lebar. Pengaturan ini mengikuti susunan text view yang dapat digulir sebagaimana didokumentasikan Apple. Susunan tersebut muncul dalam panduan AppKit Apple yang telah diarsipkan; jembatan SwiftUI dan API aksi yang dikutip di sini menggunakan dokumentasi terkini. Menempatkan Objek NSTextView di NSScrollView.
SwiftUI mengatur tata letak scroll view yang direpresentasikan. Kode ini mengonfigurasi geometri awal document view yang bersarang dan perilaku pengubahan ukuran AppKit; kode ini tidak berulang kali menetapkan frame scroll view yang direpresentasikan. Apple secara khusus mengingatkan agar properti tata letak yang dikelola SwiftUI untuk representable tidak diubah. Panduan tata letak representable dari Apple.
Contoh ini menetapkan initialText hanya saat pembuatan. Metode update yang kosong sengaja membiarkan pengetikan berikutnya dikendalikan oleh AppKit. Jika aplikasi Anda memerlukan model dokumen SwiftUI, tambahkan coordinator dan sinkronisasi berbasis delegat, dengan kebijakan yang jelas untuk perubahan model yang datang selama pengeditan. Menetapkan ulang string awal pada setiap pembaruan akan mengganti konten pengguna saat ini. Apple menjelaskan bahwa coordinator berfungsi sebagai jembatan untuk meneruskan interaksi AppKit ke SwiftUI. Panduan koordinasi representable dari Apple.
Mengklik editor menetapkan fokus untuk panduan ini. Jika aplikasi Anda perlu memindahkan fokus secara terprogram, gunakan makeFirstResponder(_:) pada jendela yang menaunginya setelah tampilan menjadi bagian dari jendela tersebut. Apple secara eksplisit menyatakan agar becomeFirstResponder() milik text view tidak dipanggil secara langsung. Hindari meminta fokus pada setiap pembaruan SwiftUI karena hal itu akan terus-menerus mengalihkan tujuan pengeditan pengguna. Panduan fokus text view dari Apple.
Pertahankan konfigurasi TextKit yang sudah ada
Perutean menu tidak mengharuskan perubahan pada mesin tata letak teks. Contoh ini menggunakan NSTextView(frame:); Apple mendokumentasikan bahwa initializer tersebut menggunakan NSLayoutManager secara default. Konstruksi eksplisit dengan NSTextLayoutManager didukung mulai macOS 12. Dokumentasi Apple tentang inisialisasi text view.
Untuk editor TextKit 2 yang sudah ada, pertahankan konstruksinya dan tambahkan aksi ke subclass-nya. Hindari membaca layoutManager hanya untuk mencari atau merutekan ke editor: Apple mendokumentasikan bahwa mengakses properti tersebut pada text view TextKit 2 dapat mengalihkannya ke mode kompatibilitas. Kode perutean hanya memerlukan responder dan metode aksi. Dokumentasi Apple tentang mode kompatibilitas NSTextView.
Verifikasi perutean dan tangani perintah yang tidak tersedia
Gunakan urutan ini untuk memeriksa perilaku yang dimaksud di macOS. Ini adalah hasil yang diharapkan berdasarkan implementasi, bukan hasil eksekusi yang dilaporkan.
- Klik di editor kiri, lalu pilih
Sample Actions→Select First Line. Baris logis pertama editor kiri seharusnya terseleksi. - Klik di editor kanan dan jalankan perintah yang sama. Hanya seleksi editor kanan yang seharusnya berubah.
- Klik
Window labeldan jalankan perintah. Kedua editor khusus seharusnya tetap tidak berubah karena first responder tidak lolos pemeriksaan subclass. - Hapus seluruh isi editor dan jalankan perintah di sana. Pemeriksaan konten kosong seharusnya kembali tanpa mengubah teks.
- Tambahkan beberapa baris, gulir ke bawah, lalu jalankan perintah. Baris pertama seharusnya terseleksi dan digulir hingga terlihat.
Perintah tetap aktif dalam contoh minimal ini, termasuk saat fokus berada di tempat lain. Pemeriksaan saat pemanggilan menentukan apakah perintah akan dijalankan. Membaca keyWindow?.firstResponder hanya di dalam ekspresi SwiftUI .disabled tidak menyediakan mekanisme untuk memperbarui ekspresi itu setiap kali fokus AppKit berubah.
Untuk antarmuka SwiftUI yang lebih besar, publikasikan kemampuan perintah yang sesuai melalui state yang peka terhadap fokus, amati dengan @FocusedValue, lalu tentukan kondisi .disabled tombol dari state tersebut. Jembatan AppKit harus menjaga agar kemampuan itu selaras dengan fokus editor yang sebenarnya. Pertahankan juga pemeriksaan saat pemanggilan. Apple mendokumentasikan FocusedValue sebagai cara mengamati nilai dari tampilan yang difokuskan atau leluhurnya. Nilai terfokus, menonaktifkan interaksi.
Jika Anda membuat item menu AppKit yang targetnya adalah editor, validateMenuItem(_:) menyediakan hook validasi AppKit. Menambahkan metode tersebut ke subclass ini saja tidak akan memvalidasi tombol SwiftUI: aksinya berupa closure yang kemudian mengirimkan aksi lain. Apple mensyaratkan objek validasi menjadi target item menu. Dokumentasi Apple tentang validasi item menu.
Tentukan kebijakan target sebelum menambahkan lebih banyak perintah. Gunakan aksi tanpa target jika fallback rantai responder memang bagian dari perilaku yang diinginkan; cari dan tetapkan first responder sebagai target jika operasi hanya berlaku bagi editor aktif. Pilihan itu membuat menu tetap mudah dipahami ketika pengguna berpindah antara text view dan kontrol lain.
Sumber
sendAction(_:to:from:)— ApplekeyWindow— ApplefirstResponder— AppleNSViewRepresentable— Applecommands(content:)— AppleCommandMenu— Apple- Menggunakan Fitur Runtime Objective-C di Swift — Apple
lineRange(for:)— ApplesetSelectedRange(_:)— ApplescrollRangeToVisible(_:)— ApplehasMarkedText()— Apple- Menempatkan Objek NSTextView di NSScrollView — Apple
NSTextView— Appleinit(frame:textContainer:)— AppleFocusedValue— Appledisabled(_:)— ApplevalidateMenuItem(_:)— Apple