对于 Mac 项目列表,将选择绑定到 SwiftUI List,并通过感知选择状态的上下文菜单提供主要操作。单击可选择行;双击可打开行。将选择状态与呈现状态分开管理。

本教程使用三个内存中的模拟项目,在你自己的 macOS 应用中实现这种交互。教程还会说明自定义 SwiftUI 界面和 AppKit 视图的适用边界,帮助你选择合适的方法,避免多个层同时处理同一次点击。

使用可选择容器的主要操作

Apple 明确记录了 macOS 可选择容器中的单击选择和双击主要操作。SwiftUI 提供的入口是 contextMenu(forSelectionType:menu:primaryAction:),从 macOS 13 起可用。将它附加到支持选择的容器,例如 List 或 Table。Apple 关于感知选择状态的上下文菜单文档。

这里有三个独立职责:模型用于标识每个项目,列表通过绑定维护选择状态,而主要操作处理程序决定打开的含义。在本例中,打开项目会显示项目详情工作表。选择项目只会更改高亮行和选择标签。

根据负责交互的视图选择实现方式:

界面 合适的起点 你仍需负责的事项
macOS 13 或更高版本上的 SwiftUI 项目列表或表格 选择绑定加感知选择状态的主要操作 解析操作 ID 并呈现对应内容
自定义 SwiftUI 磁贴或形状 显式组合手势 选择外观、激活策略和其他输入路径
嵌入 SwiftUI 的 AppKit 表格 NSTableView 选择和 doubleAction 将选择和激活桥接到 SwiftUI 状态
自定义 AppKit 绘制界面 自行处理鼠标事件 命中测试、项目标识和点击序列策略

第一种方式正好适用于这里的任务。如果界面确实需要不同的表面,自定义方式会很有用;但你也需要自行实现更多交互行为。Apple 在手势组合、AppKit 表格双击操作和鼠标按下事件处理文档中介绍了相关替代方案。

构建一个完整的单选示例

在 Xcode 中创建一个使用 SwiftUI 界面的 macOS 应用项目,并将其部署目标设为 macOS 13 或更高版本。将生成的应用和内容视图声明替换为以下单个文件。目标中只保留一个 @main 应用声明。

程序使用固定的整数 ID、三个彩色圆形和本地状态。它不读取文件,也不打开外部资源。工具栏样式的 Open 按钮和上下文菜单中的 Open 命令,与双击激活调用同一个函数。

import SwiftUI

struct SampleItem: Identifiable {
    let id: Int
    let name: String
    let tint: Color
}

@main
struct ClickSelectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ItemBrowser()
        }
    }
}

struct ItemBrowser: View {
    private let items = [
        SampleItem(id: 1, name: "Circle A", tint: .blue),
        SampleItem(id: 2, name: "Circle B", tint: .orange),
        SampleItem(id: 3, name: "Circle C", tint: .purple)
    ]

    @State private var selectedID: SampleItem.ID? = nil
    @State private var openedItem: SampleItem? = nil
    @State private var message = "Select an item."

    private var selectedItem: SampleItem? {
        items.first { $0.id == selectedID }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text(selectedItem.map {
                    "Selected: \($0.name)"
                } ?? "No selection")
                Spacer()
                Button("Open") {
                    if let id = selectedID {
                        openItems([id])
                    }
                }
                .disabled(selectedItem == nil)
            }

            List(items, selection: $selectedID) { item in
                HStack(spacing: 10) {
                    Circle()
                        .fill(item.tint)
                        .frame(width: 18, height: 18)
                    Text(item.name)
                }
                .padding(.vertical, 4)
            }
            .contextMenu(forSelectionType: SampleItem.ID.self) { ids in
                if ids.count == 1 {
                    Button("Open") {
                        openItems(ids)
                    }
                }
            } primaryAction: { ids in
                openItems(ids)
            }

            Text(message)
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 360, minHeight: 280)
        .sheet(item: $openedItem) { item in
            VStack(spacing: 16) {
                Circle()
                    .fill(item.tint)
                    .frame(width: 80, height: 80)
                Text(item.name)
                    .font(.title2)
                Button("Close") {
                    openedItem = nil
                }
            }
            .padding(32)
            .frame(minWidth: 260, minHeight: 220)
        }
    }

    private func openItems(_ ids: Set<SampleItem.ID>) {
        guard ids.count == 1, let id = ids.first else {
            message = "Choose exactly one item to open."
            return
        }
        guard let item = items.first(where: { $0.id == id }) else {
            message = "That item is no longer available."
            return
        }
        openedItem = item
        message = "Opened: \(item.name)"
    }
}

已检查这些声明和引用,但此处未编译或运行示例:当前环境没有 Swift 工具链或 macOS 图形界面。请在 Xcode 中构建,并执行下面的交互检查。

将标识、选择和打开操作分开

SampleItem 遵循 Swift 的 Identifiable 协议。其固定的 id 可在视图更新期间持续标识同一个项目。Apple 对该协议的定义以稳定标识为核心;显示名称是独立属性。在较大的应用中,即使项目名称或外观发生变化,也应保留其 ID。Apple 的 Identifiable 文档。

可选的 selectedID 会创建单选列表。若改用 ID 集合,则可启用多选。SwiftUI 的列表文档介绍了这两种绑定形式,以及容器提供的选择外观。Apple 的 List 文档。

openedItem 独立控制呈现状态。将它设为某个项目会呈现工作表;将它设为 nil 则会关闭工作表。这符合文档所述的由项目驱动的工作表 API。由于选择行时不会给 openedItem 赋值,因此在此实现中,单独选择项目不会触发工作表。Apple 关于由项目驱动的工作表的文档。

解析传递给操作的 ID

上下文菜单 API 使用容器的标识符类型,其操作会接收一个 ID 集合。因此,本例使用 SampleItem.ID.self,与列表选择类型一致。它会解析回调参数,而不是用另行读取的选择值替代该参数。Apple 的上下文菜单参数文档。

该处理程序有意只接受一个 ID。空集合会显示一条消息,无法解析的 ID 则会显示另一条消息。如果之后启用多选,请先定义打开策略,再更改此检查:呈现分组详情视图、打开多个独立窗口,或要求只选择一个项目。随意取集合中的第一个成员会掩盖尚未定义的策略。

为什么两个点击处理程序需要不同的设计决策

自定义磁贴可能需要自己的手势。SwiftUI 的 onTapGesture(count:perform:) 可指定所需的点击或轻点次数。不过,其文档并未说明在视图上放置两个独立修饰符就能形成互斥的单击与双击约定。Apple 的轻点操作文档。

首先要决定双击期间如何处理选择。对于一般的项目浏览器,在打开项目之前先选中它很有用,也与基于容器的方案一致。若另有要求,规定只有在点击序列最终没有成为双击时才执行单击操作,就需要进行协调。

对于自定义界面,SwiftUI 提供了 exclusively(before:):只有一个组件手势会成功,并优先处理第一个手势。以下是手势表达式片段,并非列表示例的完整替代方案:Apple 的互斥手势文档。

TapGesture(count: 2)
    .exclusively(before: TapGesture(count: 1))

这样会优先识别双击。将组合后的手势附加到自定义项目上,并根据你的选择和打开策略处理其结果。TapGesture 文档说明了如何根据所需轻点次数进行识别。Apple 的 TapGesture 文档。

这种策略会带来时间上的影响:要判断一组点击是否仅为单击,就必须先排除双击。这是交互要求带来的结果,并非文档规定的固定 SwiftUI 延迟。若同时要求单击操作绝不在双击序列中发生,就不要承诺单击会立即完成。

对于自定义磁贴,还要定义预期的可点击区域。contentShape(Rectangle()) 可让填充后的矩形区域参与命中测试;它并不决定手势优先级。排查点击未响应的问题时,应将这两个方面分开处理。Apple 的内容形状文档。

使用 AppKit 时保持事件归属清晰

如果现有的 AppKit 表格负责管理行,就使用该表格支持的激活机制。NSTableView.doubleAction 会在双击时向其目标发送选择器。Apple 还记录了 clickedRow 和 clickedColumn,用于确定激活发生的位置,包括列标题。打开项目之前,请先确认该位置对应一个项目。Apple 的 doubleAction 文档。

对于自定义 AppKit 界面,mouseDown(with:) 会接收鼠标左键按下事件,而 NSEvent.clickCount 会报告与鼠标按下或松开事件关联的点击次数。对无关事件读取该属性不属于其文档规定的用法,并可能引发异常。应在处理相应鼠标事件的视图中解释该属性。Apple 的鼠标按下事件文档、Apple 的点击次数文档。

使用 NSViewRepresentable 将由 AppKit 管理的视图嵌入 SwiftUI。在 makeNSView 中创建视图,在 updateNSView 中同步当前配置;若目标操作或代理通信需要桥接,则使用协调器。Apple 将创建、更新、协调和拆除都列为这种集成的组成部分。Apple 的 NSViewRepresentable 文档。

接收鼠标事件的覆盖层可能改变由哪个视图处理点击。AppKit 使用命中测试来确定鼠标按下事件的接收者,因此不应将捕获事件的覆盖层视为不可见的观察者。对于此任务,应将识别逻辑放在可选择容器或实际负责项目交互的自定义界面中。Apple 的命中测试文档。

将选择和打开视为两个独立结果进行验证

构建示例后,按以下顺序操作:

  1. 单击 Circle A。该行应变为选中状态,选择标签应显示其名称,并且不应出现工作表。
  2. 双击 Circle B。确认详情工作表显示 Circle B。
  3. 关闭工作表,然后单击 Circle C。确认选择发生变化,但不会打开另一个工作表。
  4. 激活可见的 Open 按钮。确认它通过同一个处理程序打开当前选中的项目。
  5. 调用某一行的上下文菜单中的 Open 命令,并检查它打开的项目。
  6. 单击或双击列表中未使用的空白区域。确认这不会打开无关项目。
  7. 如果你添加了嵌入式控件、拖动操作或覆盖层,请在这些区域内重复检查。

如果选择时意外打开了详情,请检查呈现状态的赋值位置。如果激活操作打开了错误项目,请检查回调 ID 和模型查找逻辑。如果自定义磁贴的某些部分无法响应点击,请先检查布局和命中测试形状,再更改手势优先级。这些检查能分别定位状态、操作目标和事件传递问题,避免同时更改这三者。

对于常见的 Mac 项目列表,应以选择绑定和容器的主要操作为核心实现。只有在界面或交互确有需要时才添加自定义识别,并让每条打开路径都通过同一个明确的处理程序解析到预期项目。

来源