Untuk mengedit daftar SwiftUI yang difilter tanpa mengubah item yang keliru, gunakan satu array asli dan hubungkan setiap baris yang terlihat kembali ke array tersebut berdasarkan ID yang stabil. Contoh macOS ini memungkinkan Anda memfilter, mengganti nama, mengubah status, dan menghapus label sintetis di aplikasi Anda sendiri.
Mengapa pemfilteran saja tidak membuat baris yang dapat diedit
filter(_:) milik Swift mengembalikan koleksi baru yang berisi elemen yang cocok dalam urutan aslinya. Operasi ini tidak membuat hubungan yang dapat ditulisi antara koleksi tersebut dan sumbernya. Pada array berisi struktur, mengubah elemen di array hasil filter yang disimpan secara terpisah tidak memperbarui array asli. Kesimpulan ini mengikuti perilaku yang didokumentasikan untuk filter(_:) dan semantik nilai untuk struktur milik Swift.
Misalnya, array asli berisi Grid, Tile, dan Ring, tetapi filter hanya menampilkan Ring. Posisi yang terlihat adalah nol; posisi aslinya adalah dua. Menggunakan posisi yang terlihat untuk menulis ke array asli akan mengubah Grid. Mengurutkan hasil yang ditampilkan dapat menyebabkan ketidakcocokan yang sama.
Binding menyediakan hubungan baca-tulis yang hilang. Apple mendokumentasikan Binding sebagai koneksi ke data yang dimiliki di tempat lain, dan menyediakan init(get:set:) untuk akses kustom. Di sini, kedua closure mencari item asli berdasarkan ID setiap kali dijalankan. Koleksi hasil filter menentukan baris yang muncul; array asli menerima perubahan. Lihat Binding dan init(get:set:).
Pilih identitas stabil sebelum membuat editor
Setiap item memerlukan ID yang tidak berubah ketika nama, pengaturan penyertaan, atau posisi array-nya berubah. Protokol Identifiable milik Apple menjelaskan identitas entitas yang stabil, sedangkan ForEach menggunakan data yang dapat diidentifikasi atau ID eksplisit untuk membedakan tampilan yang dibuatnya. Lihat Identifiable dan ForEach.
Contoh ini membuat setiap UUID satu kali dalam properti tersimpan let id. Mengganti nama label tidak mengubah ID-nya. Hindari membuat identitas dari nama yang dapat diedit atau menghasilkan UUID baru setiap kali ID dibaca. Pastikan juga setiap item memiliki ID yang berbeda: pencarian sengaja menemukan ID cocok pertama, sehingga ID duplikat akan membuat target penulisan ambigu.
Gunakan panduan keputusan ini saat memilih alur data:
| Kebutuhan antarmuka | Pendekatan binding |
|---|---|
| Menampilkan koleksi nilai yang tidak berubah | Berikan nilai kepada baris hanya-baca. |
| Mengedit koleksi asli secara langsung | Gunakan dukungan binding koleksi SwiftUI jika sesuai. |
| Mengedit hasil yang posisinya berbeda dari sumber | Cari item asli berdasarkan ID yang stabil. |
| Membiarkan editor tetap terbuka setelah item-nya tidak lagi muncul di hasil | Berikan editor pilihan dan kebijakan siklus hidupnya sendiri. |
Bagian selanjutnya menerapkan kasus ketiga. Bagian itu juga menetapkan apa yang terjadi ketika sebuah baris menghilang, sehingga item yang hilang tidak pernah menjadi indeks array yang digunakan tanpa pemeriksaan.
Buat contoh macOS yang lengkap
Buat proyek aplikasi macOS di Xcode menggunakan SwiftUI, lalu tetapkan target deployment ke macOS 13 atau lebih baru untuk contoh ini. Ganti deklarasi aplikasi dan tampilan konten yang dibuat otomatis dengan satu file berikut. Pertahankan tepat satu titik masuk @main; dokumentasi App dari Apple menjelaskan persyaratan tersebut dan struktur WindowGroup yang digunakan di sini.
Parent memiliki array asli di @State privat; setiap child menerima @Binding. Ini mengikuti pola yang didokumentasikan Apple untuk berbagi state dengan subview. Contoh ini hanya berisi label sintetis lokal dan menyimpan datanya di memori.
import SwiftUI
import Foundation
private struct LabelItem: Identifiable, Sendable {
let id = UUID()
var name: String
var isIncluded: Bool
}
@main
struct FilteredLabelsApp: App {
var body: some Scene {
WindowGroup {
LabelEditor()
}
}
}
@MainActor
private struct LabelEditor: View {
@State private var items = [
LabelItem(name: "Grid", isIncluded: true),
LabelItem(name: "Tile", isIncluded: false),
LabelItem(name: "Ring", isIncluded: true)
]
@State private var query = ""
@State private var includedOnly = false
@State private var notice = ""
private var visibleItems: [LabelItem] {
items.filter { item in
(!includedOnly || item.isIncluded) &&
(query.isEmpty ||
item.name.localizedCaseInsensitiveContains(query))
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Filter labels", text: $query)
Toggle("Included only", isOn: $includedOnly)
Button("Reverse original order") {
items.reverse()
}
List {
ForEach(visibleItems) { snapshot in
LabelRow(item: itemBinding(for: snapshot)) {
items.removeAll { $0.id == snapshot.id }
}
}
}
Text("Original array:")
.font(.headline)
Text(items.map(\.name).joined(separator: " · "))
Text(notice)
}
.padding()
.frame(minWidth: 540, minHeight: 360)
}
private func itemBinding(
for snapshot: LabelItem
) -> Binding<LabelItem> {
let id = snapshot.id
return Binding(
get: {
items.first { $0.id == id } ?? snapshot
},
set: { updated in
guard let index = items.firstIndex(where: {
$0.id == id
}) else {
notice = "Edit skipped: label was removed."
return
}
items[index].name = updated.name
items[index].isIncluded = updated.isIncluded
notice = ""
}
)
}
}
@MainActor
private struct LabelRow: View {
@Binding var item: LabelItem
let remove: () -> Void
var body: some View {
HStack {
TextField("Label name", text: $item.name)
Toggle("Included", isOn: $item.isIncluded)
Button("Remove", action: remove)
}
}
}
Ini adalah contoh aplikasi lengkap, bukan fragmen untuk ditempelkan di dalam tampilan yang sudah ada. Contoh ini telah diperiksa berdasarkan dokumentasi API yang diperoleh, tetapi tidak dikompilasi di sini karena lingkungan ini tidak memiliki toolchain Swift atau SDK macOS.
Bagaimana perubahan sampai ke array asli
Properti visibleItems yang dihitung ulang menentukan keanggotaan berdasarkan array asli dan pengaturan filter saat ini. ForEach menerima hasil tersebut dan membuat satu baris untuk setiap item yang cocok. Memfilter sebelum membuat baris juga menghindari cabang kondisional yang menghasilkan nol atau satu baris di dalam setiap iterasi, pola yang dibahas dalam panduan performa ForEach dari Apple.
Saat sebuah baris membaca binding-nya, getter mencari ID-nya di array asli saat ini. Snapshot hanya menjadi nilai cadangan untuk item yang sudah dihapus. Selama item tersebut masih ada, getter mengembalikan nilai asli saat ini, bukan snapshot yang diambil ketika baris dibuat.
Saat pengguna mengedit Label name, $item.name memproyeksikan binding item ke properti namanya yang dapat ditulisi. Apple mendokumentasikan proyeksi properti ini melalui subskrip dynamic-member milik Binding. Setter kemudian mencari indeks asli item saat ini dan memperbarui bidang yang dapat diedit, sambil mempertahankan ID tersimpannya.
Pencarian menggunakan firstIndex(where:), yang mengembalikan indeks opsional dan mengembalikan nil jika tidak ada kecocokan. Kode memeriksa hasil tersebut sebelum mengakses subskrip. Karena pencarian diulang untuk setiap penulisan, membalik urutan array asli tidak membuat setter menyimpan indeks lama. Lihat dokumentasi firstIndex(where:) dari Apple.
Apa yang terjadi ketika sebuah baris menghilang?
Ada dua kasus yang berbeda. Jika suatu perubahan membuat item tidak lagi cocok dengan filter, item itu tetap ada di array asli. Perubahannya tersimpan, tetapi barisnya tidak lagi muncul dalam hasil. Misalnya, menonaktifkan Included saat Included only aktif akan menyembunyikan baris tersebut; menonaktifkan filter akan menampilkannya kembali.
Jika item telah dihapus dari array asli, penulisan berikutnya melalui binding lama akan ditolak. Contoh ini menampilkan Edit skipped: label was removed. Contoh tersebut tidak memasukkan kembali snapshot ke array. Mengembalikan snapshot dari getter dan menolak penulisan ke item yang hilang adalah kebijakan eksplisit contoh ini, bukan jaminan dari SwiftUI.
Pemfilteran nama secara langsung juga memerlukan keputusan interaksi. Apple mendokumentasikan bahwa TextField yang terikat ke string memperbarui nilainya terus-menerus selama pengeditan. Akibatnya, sebuah baris dapat langsung menghilang ketika nama barunya tidak lagi cocok dengan kueri. Agar penggantian nama tidak terputus, kosongkan kueri terlebih dahulu atau rancang editor terpisah yang menerapkan draf setelah tindakan eksplisit. Lihat TextField.
Periksa perilakunya dengan pemeriksaan desktop singkat
Pemeriksaan berikut menjelaskan perilaku yang diharapkan berdasarkan contoh ini; ini bukan hasil eksekusi yang dilaporkan. Mulai dengan menjalankan ulang aplikasi agar label awal muncul, lalu lakukan langkah-langkah berikut secara berurutan.
| Tindakan | Hasil yang diharapkan |
|---|---|
Masukkan Ring di Filter labels. |
Hanya Ring yang muncul. |
Ganti namanya menjadi Ring Two. |
Tampilan array asli menjadi Grid · Tile · Ring Two. |
Hapus kueri dan klik Reverse original order. |
Tampilannya menjadi Ring Two · Tile · Grid. |
Ganti nama Grid menjadi Grid Two. |
Hanya nilai asli item itu yang berubah. |
Aktifkan Included only, lalu nonaktifkan Included untuk Grid Two. |
Baris itu menghilang, tetapi namanya tetap ada di tampilan array asli. |
Nonaktifkan Included only. |
Grid Two muncul kembali dengan toggle-nya dalam keadaan mati. |
Masukkan Tile, lalu klik tombol Remove miliknya. |
Baris dan entri array aslinya sama-sama menghilang. |
Jika item yang keliru berubah, periksa apakah offset yang ditampilkan digunakan sebagai indeks array asli. Jika perubahan hilang setelah filter dihapus, periksa apakah baris memiliki salinan @State terpisah. Jika baris yang namanya diubah berperilaku seperti item baru, periksa apakah ID-nya bergantung pada konten yang dapat diedit. Pemeriksaan ini menyoroti tiga hubungan yang dibuat eksplisit oleh contoh: kepemilikan sumber, tujuan penulisan, dan identitas baris.
Pahami batas macOS dan API solusi ini
Untuk label pendek yang dapat diedit, TextField SwiftUI dalam contoh ini menyediakan antarmuka yang diperlukan. Editor dokumen yang lebih kaya mungkin memerlukan NSTextView dari AppKit, yang mendukung teks kaya dan terintegrasi dengan komponen sistem teks seperti NSTextStorage dan NSTextLayoutManager. Itu mengubah kontrol pengeditannya; aplikasi tetap harus menentukan item model asli mana yang menerima setiap perubahan. Lihat NSTextView.
Jembatan yang didukung untuk tampilan AppKit di dalam hierarki SwiftUI adalah NSViewRepresentable. Apple mendokumentasikan cara membuat dan memperbarui tampilan yang direpresentasikan serta menggunakan coordinator untuk meneruskan interaksi. Untuk editor kustom di masa mendatang, pertahankan tujuan penulisan dengan ID stabil yang sama. Biarkan SwiftUI mengelola tata letak tampilan yang direpresentasikan, alih-alih mengubah frame atau batas yang dikelolanya secara langsung. Lihat NSViewRepresentable.
Ekstensi AppKit juga memiliki batas TextKit: Apple mendokumentasikan bahwa mengakses NSTextView.layoutManager dapat mengalihkan tampilan ke mode kompatibilitas yang menggunakan NSLayoutManager. Periksa ketersediaan dan perilaku API teks tertentu sebelum menggunakannya. Solusi binding koleksi itu sendiri tidak memerlukan penyesuaian TextKit. Lihat pembahasan kompatibilitas di NSTextView.
Untuk koleksi yang lebih besar, perhitungkan biaya pencarian. Apple mendokumentasikan kompleksitas linear untuk filter(_:) dan firstIndex(where:); karena itu, pencarian berulang untuk setiap baris dapat menjadi signifikan seiring bertambahnya koleksi. Ukur antarmuka Anda sebelum menambahkan cache ID-ke-indeks, dan bangun ulang cache seperti itu setiap kali perubahan struktur mengubah indeks. Lihat filter(_:) dan firstIndex(where:).
Pertahankan akses UI ke binding yang dihitung ini di main actor, seperti dalam contoh. Dokumentasi Apple untuk penginisialisasi binding menjelaskan bahwa akses yang diizinkan pada binding hasil komputasi bergantung pada isolasi closure-nya. Meneruskan binding ke tempat lain tidak menghapus batasan tersebut. Lihat init(get:set:).
Untuk editor label yang difilter ini, pertahankan satu array asli, gunakan ID stabil untuk baris, dan tentukan target penulisan saat pengeditan berlangsung. Lalu tetapkan perilaku ketika baris menghilang dengan sengaja: item yang terfilter keluar tetap menyimpan perubahannya, sedangkan item yang dihapus menolak penulisan selanjutnya disertai penjelasan yang terlihat.
Sumber
- filter(_:) — Apple
- Structures and Classes — Swift.org
- Binding — Apple
- init(get:set:) — Apple
- Identifiable — Apple
- ForEach — Apple
- App — Apple
- State — Apple
- firstIndex(where:) — Apple
- TextField — Apple
- NSTextView — Apple
- NSViewRepresentable — Apple