要编辑过滤后的 SwiftUI 列表而不更改错误的项目,请保留一个原始数组,并通过稳定 ID 将每个可见行绑定回该数组。此 macOS 示例可让你在自己的应用中过滤、重命名、切换和移除合成标签。

为什么单纯过滤无法创建可编辑行

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,因此重复项会使写入目标变得模糊。

选择数据流时可参考此决策辅助:

界面需求 绑定方法
显示未更改的值集合 将值传递给只读行。
直接编辑原始集合 在适当的地方使用 SwiftUI 的集合绑定支持。
编辑位置与源不同的结果 通过稳定 ID 解析原始项目。
在项目离开结果后保持编辑器打开 赋予编辑器自己的选择和生命周期策略。

其余部分实现第三种情况。它还定义了行消失时的行为,以便缺失的项目永远不会成为未经检查的数组下标。

构建完整的 macOS 示例

在 Xcode 中使用 SwiftUI 创建一个 macOS 应用项目,并将其部署目标设置为 macOS 13 或更高版本以运行此示例。将生成的应用和内容视图声明替换为以下单个文件。保留恰好一个 @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)
        }
    }
}

这是一个完整的应用示例,而不是粘贴到现有视图中的片段。它已根据检索到的 API 文档进行了检查,但未在此处编译,因为此环境没有 Swift 工具链或 macOS SDK。

编辑如何到达原始数组

计算属性 visibleItems 根据原始数组和当前过滤设置重新计算成员资格。ForEach 接收这些结果并为每个匹配项创建一行。在构建行之前进行过滤还避免了在每次迭代中放置条件性的零行或单行分支,这一模式在 Apple 的 ForEach 性能指南 中有讨论。

当行读取其绑定时,getter 会在当前原始数组中搜索其 ID。快照仅作为已移除项目的后备值。只要项目存在,getter 就会返回当前的原始值,而不是创建行时捕获的快照。

当用户编辑 Label name 时,$item.name 将项目绑定投影到其可写的 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 记录了创建和更新所代表的视图,并使用协调器转发交互。对于未来的自定义编辑器,请保留相同的稳定 ID 写入目标。让 SwiftUI 管理所代表视图的布局,而不是直接更改其受管理的 frame 或 bounds。请参阅 NSViewRepresentable。

AppKit 扩展也有 TextKit 边界:Apple 文档指出,访问 NSTextView.layoutManager 可以使用 NSLayoutManager 将视图切换到兼容模式。在采用特定文本 API 之前,请检查其可用性和行为。集合绑定解决方案本身不需要 TextKit 自定义。请参阅 NSTextView 中的兼容性讨论。

对于较大的集合,请考虑查找工作量。Apple 文档指出 filter(_:) 和 firstIndex(where:) 都是线性复杂度;因此,随着集合增长,重复的逐行搜索可能变得显著。在引入 ID 到索引的缓存之前先测量你的界面,并在结构更改改变索引时重建任何此类缓存。请参阅 filter(_:) 和 firstIndex(where:)。

请将对这些计算绑定的 UI 访问保持在主 actor 上,正如示例所做的那样。Apple 的绑定初始化器文档解释了计算绑定的允许访问取决于其闭包的隔离性。将绑定传递到其他地方不会消除这些限制。请参阅 init(get:set:)。

对于此过滤标签编辑器,请保留一个原始数组,为行使用稳定 ID,并在编辑时刻解析写入目标。然后有意选择消失行为:被过滤掉的项目保留其编辑,而被移除的项目会拒绝进一步写入并显示可见解释。

来源