在 Mac 应用中创建自定义的单列集合时,请使用 SwiftUI 的 List,并通过一组稳定的项目 ID 绑定选择状态。自定义行的外观,同时让原生控件处理范围选择和 Command-单击选择。

本教程将构建一个由虚构标签组成的集合,标签从 Tile 01 到 Tile 12。读者需要选择相邻项目、添加或移除单个项目,并在筛选或反转显示顺序时让选择状态保持清晰。后面的章节会介绍适用于网格或其他自定义布局集合的 AppKit 实现方式。

范围选择和 Command-单击选择应该如何工作?

Apple 的《Mac 使用手册》介绍了常见的交互方式:单击第一个项目,再按住 Shift 单击最后一个项目,即可选择相邻项目以及它们之间的项目。Command-单击可选择不相邻的单个项目;在多选状态下,Command-单击已选中的项目会将其从选择中移除。这些是此集合需要保留的基本交互。在 Mac 屏幕上选择项目。

初始实现使用一个有序列表。这样,“之间”就有了明确含义:当前显示顺序中两个端点之间的行。空间布局的集合还需要考虑另一个问题——范围应遵循数据序列,还是布局区域?这值得单独测试。

请根据用户要选择的内容来选择控件:

读者的任务 合适的起点 选择状态的表示方式
选择完整的自定义行 SwiftUI List 项目 ID 集合
选择自定义布局中的完整图块 AppKit NSCollectionView 选择索引路径,再映射到模型 ID
选择标签中的字符 SwiftUI 文本选择 文本视图提供的文本选择状态
选择文本文档中的范围 AppKit 文本系统 字符范围或文本范围

Apple 将 List 记述为支持可选选择状态的单列容器,将 NSCollectionView 记述为具有可自定义布局的有序集合。它们是实现项目选择的合适起点。List、NSCollectionView。

构建完整的 SwiftUI 示例

在 Xcode 中创建一个使用 SwiftUI 的 macOS 应用项目。本示例将部署目标设为 macOS 14 或更高版本:下方使用的双参数 onChange 闭包需要 macOS 14。用以下代码替换自动生成的应用声明,并确保目标中只有一个 @main 入口点。onChange(of:initial:_:)、App。

下面是一个完整的小型应用,包含虚构数据和窗口。它不使用外部资源。本文已根据文档中的 API 检查此代码,但未在此处的 macOS 上编译或运行。

import Foundation
import SwiftUI

struct Tile: Identifiable {
    let id: Int
    let title: String
}

@main
struct CollectionSelectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            TileCollectionView()
        }
    }
}

struct TileCollectionView: View {
    private let tiles: [Tile] = (1...12).map {
        Tile(id: $0, title: String(format: "Tile %02d", $0))
    }

    @State private var selectedIDs: Set<Int> = []
    @State private var query = ""
    @State private var reversed = false

    private var visibleTiles: [Tile] {
        let filtered = tiles.filter {
            query.isEmpty ||
            $0.title.localizedCaseInsensitiveContains(query)
        }
        return reversed ? Array(filtered.reversed()) : filtered
    }

    private var selectionDescription: String {
        let names = visibleTiles
            .filter { selectedIDs.contains($0.id) }
            .map(\.title)
        return names.isEmpty ? "None selected" : names.joined(separator: ", ")
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Filter tiles", text: $query)
                .textFieldStyle(.roundedBorder)

            HStack {
                Button("Reverse order") {
                    reversed.toggle()
                }
                Button("Clear selection") {
                    selectedIDs.removeAll()
                }
                .disabled(selectedIDs.isEmpty)
            }

            List(visibleTiles, selection: $selectedIDs) { tile in
                HStack(spacing: 12) {
                    RoundedRectangle(cornerRadius: 4)
                        .fill(Color.gray.opacity(0.2))
                        .frame(width: 32, height: 32)
                    VStack(alignment: .leading) {
                        Text(tile.title)
                        Text("Sample item \(tile.id)")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                    Spacer()
                }
                .padding(.vertical, 4)
                .tag(tile.id)
            }

            Text("\(selectedIDs.count) selected")
            Text(selectionDescription)
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 440, minHeight: 480)
        .onChange(of: query) { _, _ in
            let visibleIDs = Set(visibleTiles.map(\.id))
            selectedIDs.formIntersection(visibleIDs)
        }
    }
}

选择绑定的元素类型与 Tile.ID 一致:两者都使用 Int。Apple 文档指出,将列表的选择状态绑定到与数据 ID 类型相同的集合,即可启用多选。在 Mac 上,多选不需要进入编辑模式。在列表中支持选择。

行仍然可以自定义:它包含一个简洁的形状、标题和次要标签。这里没有行级单击处理程序,也没有单独的切换操作。让列表负责选择,可以避免一次交互触发两段独立代码来更新选择状态。

将项目身份与显示顺序分开

本示例刻意区分三个概念:有序的项目数组、已选 ID 的集合,以及经过筛选的数组视图。Swift 将数组定义为有序集合,将集合类型定义为元素唯一且没有固定顺序的集合。因此,集合适合表示成员关系,而数组提供显示所需的顺序。集合类型。

这些整数 ID 在此虚构数据集中是固定的。反转数组会改变项目的显示位置,但不会改变其身份。在实际模型中,创建或加载项目时应为其分配稳定标识符,并在后续视图更新中保留该标识符。不要在每次计算行视图时都生成新的身份。

选择摘要遵循 visibleTiles 的顺序,并检查 selectedIDs 中的成员关系。这是根据上述集合属性得出的实现选择:它会按显示顺序生成摘要,而不是依赖集合的遍历顺序。

筛选时需要明确产品策略。本示例采用“仅保留可见项目”的策略:每次筛选条件变化时,选择状态都会与当前可见项目的 ID 取交集。隐藏项目会失去选中状态,清除筛选条件也不会恢复它们之前的选择。标签和源数组保持不变。

例如,先选中 Tile 02、Tile 03 和 Tile 04。在 Filter tiles 中输入 04。按照本示例的策略,只有 Tile 04 仍处于选中状态。移除筛选条件后,另外两个项目会重新显示,但不再选中。这是对代码的说明性演示,并非实际运行结果。

其他应用可能需要让选择状态在筛选后继续保留。在这种情况下,应将选择状态的所有权移至模型,定义如何在计数和摘要中呈现隐藏的选中项目,并测试其与显示控件的同步情况。仅移除交集操作并不能构成完整的隐藏选中项目策略。

什么时候应该使用 AppKit 集合视图?

当集合需要以多列图块显示,或需要单列表以外的布局时,请使用 NSCollectionView。Apple 将集合的数据源、项目视图控制器、委托和布局对象分开。这样可以自定义外观和位置,同时仍由集合控件负责交互。NSCollectionView。

请按以下顺序配置 AppKit 实现:

  1. 创建集合视图并指定现代布局,例如 NSCollectionViewFlowLayout。
  2. 提供数据源,并注册用于创建单元格的项目类或 nib。
  3. 将 isSelectable 设为 true,并将 allowsMultipleSelection 设为 true。
  4. 根据任务是否允许不选择任何项目,设置 allowsEmptySelection。
  5. 读取 selectionIndexPaths,然后将每个有效索引路径映射到对应的模型 ID。
  6. 在项目视图中使用其 isSelected 状态呈现选择效果。

默认情况下,多选处于禁用状态。因此,启用多选是明确的设置步骤,不能仅凭绘制出看似可选的图块就认为已启用。项目的 isSelected 属性会报告该项目是否处于选中状态。allowsMultipleSelection、isSelected。

请将索引路径视为当前数据源中的位置。Apple 文档指出,selectionIndexPaths 会在交互式选择期间更新,也可以通过编程方式赋值。为无法标识有效数据源项目的索引路径赋值会引发异常。重新排序或筛选后,应根据稳定 ID 重建选择位置,而不是重复使用过期的位置。selectionIndexPaths。

遵守文档明确说明的范围:allowsMultipleSelection 支持选择多个项目;其文档并未为每种自定义布局或修饰符组合指定完整的范围锚点算法。请在实际布局和支持的 macOS 版本上验证 Shift-单击行为。如果任务要求精确的线性范围,上面的单列实现是更清晰的起点。

如果 SwiftUI 应用需要使用 AppKit 控件,请通过 NSViewRepresentable 嵌入该控件。在 makeNSView 中创建 AppKit 视图层级,在 updateNSView 中同步模型变更,并使用协调器将委托事件传递到 SwiftUI 状态。SwiftUI 会控制所表示视图的框架和边界。NSViewRepresentable。

程序化更新也需要明确的同步路径。Apple 指出,当通过编程方式设置选择状态时,不会调用 collectionView(_:shouldSelectItemsAt:) 和 collectionView(_:didSelectItemsAt:)。应在共享操作中处理模型更新,而不要依赖每次赋值后都会触发这些回调。collectionView(_:shouldSelectItemsAt:)、collectionView(_:didSelectItemsAt:)。

文本选择能提供相同的功能吗?

SwiftUI 的 textSelection(.enabled) 控制视图内文本的选择。将它应用于容器后,其中包含的每个文本视图都可以被选中。其文档并未说明它能提供集合项目选择绑定,或支持在任意卡片之间共享范围选择模型。如果任务是选择标签中的字符,请使用它。textSelection(_:)。

对于文本文档界面,AppKit 可通过 NSTextView.selectedRanges 提供字符选择状态。这些范围按顺序排列,彼此不重叠,且可以不连续。TextKit 的 NSTextSelection.textRanges 同样表示逻辑顺序的文本范围,并从 macOS 12 起可用。这些 API 描述的是文本位置,因此将它们转换为图块 ID 属于应用特定的设计。selectedRanges、textRanges。

启用标签内的文本选择前,请先确定该行标签要响应哪种交互。对于本教程的任务,单击标签应当用于选择整行,因此示例不会配置文本选择。

用一个小型选择矩阵验证交互

请在应用自己的窗口中运行以下检查。前四项检查 Mac 的基本选择习惯,其余项目则验证本示例对身份和筛选所做的选择。Apple 文档中描述的交互是基准,而虚构标签便于检查预期的选择状态。在 Mac 屏幕上选择项目。

操作 预期结果
单击 Tile 02 一个项目处于选中状态
按住 Shift 单击 Tile 05 选中 Tile 02 到 Tile 05
Command-单击 Tile 08 Tile 08 加入选择
Command-单击已选中的 Tile 03 Tile 03 从选择中移除
单击 Clear selection 选择为空,摘要显示 None selected
选中 Tile 04,然后反转顺序 选中 ID 仍对应 Tile 04
选中多行,然后筛选 04 只有此前已选中且仍可见的项目保持选中
清除筛选条件 隐藏的行重新显示,但已丢弃的选择不会恢复

还应检查单击列表后的键盘导航、滚动选中行使其移出视野后再滚回,以及筛选后开始新的范围选择。对于 AppKit 网格,请增加跨越行边界的检查。如果结果与预期任务不同,请先将界面精简为原生基础控件,再逐一添加自定义行内容和手势处理程序。

让原生控件负责交互,让稳定 ID 负责表示成员关系,让显示数组负责顺序。明确这三项职责后,自定义行样式、筛选和排序都能支持清晰易懂的选择任务。

来源