若要編輯篩選後的 SwiftUI 清單而不改錯項目,請保留一個原始陣列,並透過穩定 ID 將每個可見列繫結回原始陣列。這個 macOS 範例讓你在自己的 App 中篩選、重新命名、切換及移除虛構標籤。

為什麼光是篩選無法建立可編輯的列

Swift 的 filter(_:) 會傳回一個新集合,其中包含符合條件且順序與原始集合相同的元素。它不會在該集合與其來源之間建立可寫入的關係。若陣列儲存的是結構,修改另一個分開儲存的篩選陣列中的元素,不會更新原始陣列。這些結論是根據 filter(_:) 的文件行為,以及 Swift 對結構的值語意而來。

例如,假設原始陣列包含 Grid、Tile 和 Ring,但篩選結果只顯示 Ring。它在畫面上的位置是零;在原始陣列中的位置則是二。若用畫面上的位置寫入原始陣列,就會改到 Grid。排序顯示結果也可能造成同樣的位置不符。

繫結提供缺少的讀寫連結。Apple 將 Binding 說明為連接至其他位置所擁有資料的方式,並提供 init(get:set:) 來自訂存取方式。在此範例中,每次執行時,兩個閉包都會依 ID 找出原始項目。篩選後的集合決定哪些列會顯示;編輯則寫入原始陣列。請參閱 Binding 和 init(get:set:)。

建立編輯器前,先選擇穩定的識別方式

每個項目都需要一個 ID,當名稱、納入設定或陣列位置改變時,ID 仍須保持不變。Apple 的 Identifiable 協定說明穩定的實體識別方式,而 ForEach 則使用可識別資料或明確 ID 來區分所產生的檢視畫面。請參閱 Identifiable 和 ForEach。

範例在儲存的 let id 屬性中建立一次 UUID。重新命名標籤不會改變它的 ID。請避免使用可編輯的名稱來推導識別值,也不要在每次讀取 ID 時都產生新的 UUID。也要確保不同項目的 ID 不重複:查找會刻意尋找第一個符合的 ID,因此若有重複 ID,寫入目標便會不明確。

選擇資料流時,可參考下列判斷方式:

介面需求 繫結方式
顯示不變的值集合 將值傳給唯讀列。
直接編輯原始集合 在適當情況下使用 SwiftUI 的集合繫結支援。
編輯位置與來源不同的篩選結果 透過穩定 ID 找出原始項目。
項目離開結果後仍保持編輯器開啟 為編輯器設定自己的選取狀態與生命週期策略。

以下內容會實作第三種情況,也會定義列消失時的處理方式,避免項目不存在時仍使用未檢查的陣列索引。

建立完整的 macOS 範例

在 Xcode 中使用 SwiftUI 建立 macOS App 專案,並將部署目標設為 macOS 13 或更新版本。將產生的 App 與內容檢視宣告替換為以下單一檔案。只保留一個 @main 進入點;Apple 的 App 文件說明了這項要求,以及此處使用的 WindowGroup 結構。

父檢視在私有的 @State 中擁有原始陣列;每個子檢視則接收一個 @Binding。這遵循 Apple 對與子檢視共用狀態的文件模式。此範例只包含本機虛構標籤,並將資料保留在記憶體中。

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

這是完整的 App 範例,不是要貼入現有檢視中的片段。此範例已依據取得的 API 文件檢視過,但未在此處編譯,因為此環境沒有 Swift 工具鏈或 macOS SDK。

編輯如何寫回原始陣列

計算屬性 visibleItems 會依據原始陣列和目前的篩選設定重新計算項目是否符合條件。ForEach 接收這些結果,並為每個符合條件的項目建立一列。先篩選再建立列,也可避免在每次迭代中放入條件式的零或一列分支;Apple 的 ForEach 效能指南也討論了這種模式。

列讀取繫結時,getter 會在目前的原始陣列中依 ID 搜尋。若項目已移除,快照才會作為後備值。在項目仍存在時,getter 會傳回原始陣列中的目前值,而不是建立該列時擷取的快照。

使用者編輯 Label name 時,$item.name 會將項目繫結投影至可寫入的名稱屬性。Apple 在 Binding 的動態成員下標文件中說明了這種屬性投影。接著 setter 會找出項目目前在原始陣列中的索引,並更新其可編輯欄位,同時保留儲存的 ID。

查找使用 firstIndex(where:),它會傳回可選索引;若找不到符合項目,則傳回 nil。程式碼會先檢查結果,再使用下標。由於每次寫入都會重新查找,因此即使反轉原始陣列,setter 也不會持有過期索引。請參閱 Apple 的 firstIndex(where:) 文件。

列消失時會發生什麼事?

有兩種不同情況。如果編輯使項目不再符合篩選條件,該項目仍存在於原始陣列中。編輯會保留,但該列會離開結果。例如,啟用 Included only 時,關閉 Included 會隱藏該列;再次關閉篩選條件就會讓它重新顯示。

若項目已從原始陣列移除,之後透過舊繫結進行的寫入會遭到拒絕。範例會顯示 Edit skipped: label was removed.,不會將快照重新插入陣列。getter 傳回快照,以及拒絕對不存在項目的寫入,都是此範例明確採用的策略,並非 SwiftUI 提供的保證。

即時名稱篩選也需要考量互動方式。Apple 文件指出,繫結字串的 TextField 會在編輯時持續更新值。因此,當列的新名稱不再符合查詢時,該列可能立即消失。若要不間斷地重新命名,請先清除查詢,或設計另一個在明確操作時才提交草稿的獨立編輯器。請參閱 TextField。

透過簡短的桌面檢查確認行為

以下檢查描述依據範例推導的預期行為,並非實際執行結果。從新的執行開始,以取得初始標籤,再依序完成下列步驟。

動作 預期結果
在 Filter labels 輸入 Ring。 只會顯示 Ring。
將它重新命名為 Ring Two。 原始陣列讀數變成 Grid · Tile · Ring Two。
清除查詢並點選 Reverse original order。 讀數變成 Ring Two · Tile · Grid。
將 Grid 重新命名為 Grid Two。 只有該項目的原始值會改變。
啟用 Included only,再關閉 Grid Two 的 Included。 該列會消失,但名稱仍保留在原始陣列讀數中。
關閉 Included only。 Grid Two 會重新顯示,且切換開關保持關閉。
輸入 Tile,然後點選它的 Remove 按鈕。 該列與原始陣列中的項目都會消失。

若改錯項目,請檢查是否把顯示位置當成原始陣列索引使用。若清除篩選後編輯結果消失,請檢查該列是否擁有獨立的 @State 副本。若重新命名後的列看起來像新項目,請檢查其 ID 是否取決於可編輯內容。這些檢查針對範例明確維持的三種關係:來源所有權、寫入目標與列識別。

將解法限制在 macOS 與 API 的適用範圍內

對於簡短的可編輯標籤,範例中的 SwiftUI TextField 已提供所需介面。較完整的文件編輯器可能需要改用 AppKit 的 NSTextView,它支援富文字,並可與 NSTextStorage 和 NSTextLayoutManager 等文字系統元件整合。這會改變編輯控制項,但應用程式仍須決定每次編輯要套用至哪個原始模型項目。請參閱 NSTextView。

在 SwiftUI 階層中使用 AppKit 檢視的支援橋接方式是 NSViewRepresentable。Apple 的文件說明如何建立與更新被包裝的檢視,以及如何使用 coordinator 傳遞互動。若未來要自訂編輯器,請維持相同的穩定 ID 寫入目標。讓 SwiftUI 管理被包裝檢視的版面配置,不要直接變更由 SwiftUI 管理的框架或邊界。請參閱 NSViewRepresentable。

AppKit 擴充也受 TextKit 範圍限制:Apple 文件指出,存取 NSTextView.layoutManager 可能會使檢視切換至使用 NSLayoutManager 的相容模式。採用特定文字 API 前,請確認其可用性與行為。集合繫結解法本身不需要自訂 TextKit。請參閱 NSTextView 中的相容性說明。

對較大的集合,請將查找所需的工作量納入考量。Apple 文件指出,filter(_:) 和 firstIndex(where:) 的複雜度都是線性;因此,集合變大時,對每一列反覆搜尋可能會造成明顯負擔。加入 ID 到索引的快取前,請先測量介面效能,並在結構性變更改變索引時重建快取。請參閱 filter(_:) 和 firstIndex(where:)。

請像此範例一樣,讓 UI 對這些計算繫結的存取都在主執行者上進行。Apple 的繫結初始化器文件說明,計算繫結可在何處存取,取決於其閉包的隔離方式。在其他地方傳遞繫結不會解除這些限制。請參閱 init(get:set:)。

對於這個篩選標籤編輯器,請保留一個原始陣列,使用穩定 ID 識別列,並在編輯當下解析寫入目標。接著明確決定項目消失時的行為:被篩選掉的項目會保留編輯;已移除的項目則會拒絕後續寫入,並顯示說明。

來源