使用 sheet(item:onDismiss:content:) 和一个可选的选中项目,并根据闭包接收的项目构建 sheet。这样,Mac 应用就能用一个值同时决定是否显示 sheet 以及其中显示的内容。

本教程实现一个小型桌面任务:点击一个本地示例项目,立即查看其详情。教程还提供了诊断步骤,用于排查打开后内容为空、显示上一次选择,或 AppKit 视图中出现过时文本的 sheet。

为什么使用基于项目的 sheet?

SwiftUI 提供了两种相关的呈现方法。布尔值重载会在其绑定变为 true 时显示内容。项目重载接受一个可选的可识别值,并将该值提供给内容闭包。固定面板适合使用布尔值形式;面板内容取决于所选项目时,适合使用项目形式。请参阅 Apple 关于基于布尔值的 sheet和基于项目的 sheet的文档。

将选中项和呈现状态分开存储,会允许出现诸如已请求呈现但没有选中项这样的组合。将它们合并为一个可选值,就能从自己的状态模型中去掉这种组合:nil 表示没有请求呈现任何项目,有值则表示要检查的项目。这是根据这两个 API 得出的设计结论,并非对 SwiftUI 未文档化调度缺陷的断言。

如果现有实现先设置选中项,再切换布尔值,仅凭这两条语句无法确定首次呈现失败的原因。在将症状归因于时序之前,先检查内容的数据路径。关键是确认 sheet 渲染的是传入的输入、读取另一个属性,还是显示单独存储的副本。

构建完整的 macOS 示例

在 Xcode 中创建一个使用 SwiftUI 界面的 macOS 应用项目。本示例的最低部署目标为 macOS 12。基于项目的 sheet API 自 macOS 10.15 起提供,而下文使用的环境关闭操作要求 macOS 12。Apple 在基于项目的 sheet和关闭环境值的文档中说明了这些可用性边界。

用下面这个完整程序替换生成的应用声明和内容视图。目标中只保留一个应用入口点。以下所有标签和描述都是虚构的本地示例数据。

import SwiftUI

struct SampleItem: Identifiable {
    let id: String
    let title: String
    let detail: String
}

@main
struct FirstSheetDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ItemListView()
        }
    }
}

struct ItemListView: View {
    private let items = [
        SampleItem(
            id: "tile-a",
            title: "Amber Tile",
            detail: "A square sample with an amber finish."
        ),
        SampleItem(
            id: "tile-b",
            title: "Blue Tile",
            detail: "A rectangular sample with a blue finish."
        )
    ]

    @State private var presentedItem: SampleItem? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Sample Items")
                .font(.title2)

            ForEach(items) { item in
                Button("Inspect \(item.title)") {
                    presentedItem = item
                }
            }
        }
        .padding(24)
        .frame(minWidth: 360, minHeight: 200)
        .sheet(item: $presentedItem) { item in
            ItemDetailView(item: item)
        }
    }
}

struct ItemDetailView: View {
    let item: SampleItem
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(item.title)
                .font(.title)

            Text(item.detail)

            Text("Item ID: \(item.id)")
                .font(.caption)

            HStack {
                Spacer()
                Button("Done") {
                    dismiss()
                }
            }
        }
        .padding(24)
        .frame(width: 380, height: 220)
    }
}

按钮会将所点击的值赋给 presentedItem。随后,呈现闭包会将自己的 item 参数传给 ItemDetailView。注意,详情视图使用的是普通输入属性 let item,而不是第二个选中项目状态属性。Apple 建议保持单一事实来源,并通过属性共享只读值;如果子视图需要写入权限,则使用绑定。请参阅 State。

关闭操作应放在 ItemDetailView 内部,让它读取 sheet 的环境。Apple 明确提醒,在呈现视图中读取该操作并从 sheet 内容调用,会使用呈现视图的环境。请像示例那样,将声明放在 sheet 自身的内容旁边。请参阅 dismiss。

我已检查程序中的输入和引用是否一致;由于此环境没有 Swift 工具链,因此尚未编译或运行。请在目标 macOS 版本对应的 Xcode 中运行以下检查。

按照指定点击顺序验证首次呈现

每次检查首次呈现时,都从全新启动开始。只在打开过其他项目后进行测试,可能会掩盖原本的问题。以下预期来自示例中明确的标签和数据赋值;它们是验收标准,不是已报告的测试结果。

操作 sheet 中预期显示的内容 检查内容
启动后先点击 Inspect Blue Tile Blue Tile、其矩形描述,以及 tile-b 首次呈现是否使用了所点击的项目
点击 Done,然后检查 Amber Tile Amber Tile、其正方形描述,以及 tile-a 后续选择是否传入内容视图
关闭后再次检查 Amber Tile 相同的琥珀色项目详情 重新打开同一项目时结果是否一致
重启后先检查 Amber Tile 立即显示琥珀色项目详情 正确性是否不受先打开哪个示例项目的影响

同时显示标题和 ID,比只检查标题更有用。如果真实界面中允许显示名称重复,开发期间可使用两个标题相同、ID 和描述不同的虚构项目进行测试。这样可以暴露按显示文本意外选择项目的问题。

从按钮到最终视图诊断过时内容

按顺序完成以下检查。每一步都能缩小不匹配所在的位置,让你可以修改责任层,而不必添加无关的呈现变通方案。

  1. 检查操作的输入。 在所点击按钮的操作中设置断点,并检查 item.id。确认操作接收到的是预期记录。
  2. 检查呈现输入。 检查 sheet 闭包中的 item 参数。将该参数传给详情视图,而不要再次查询另一个选中项。
  3. 检查最终显示的属性。 确认标题和描述读取自详情视图的输入。留意是否有缓存属性或本地草稿保留着旧值。
  4. 检查 AppKit 桥接层。 如果普通 SwiftUI 文本正确,而封装的文本仍然过时,请检查 representable 的更新方法。

保持标识稳定

基于项目的 API 要求类型遵循 Identifiable。Swift 将该协议定义为稳定标识,而标识的持续时间和作用范围由遵循协议的类型及其使用方决定。示例使用存储的字符串 ID 来区分两条记录。请参阅 Identifiable。

对于真实列表,请选择一个能在界面支持的更新过程中持续标识同一条记录的标识符。避免从可能变化或被多个项目共用的标题生成标识。也不要使用计算属性 id,让它每次读取时都创建一个新的 UUID;这无法提供稳定标识。

将显示输入与可编辑草稿分开

检查器可以直接显示 item.title。编辑器可以有意创建本地草稿,但草稿会引入另一个需要定义生命周期的值。要确定何时创建草稿、后续模型变化是否应替换它,以及用户完成或取消时如何处理。

Apple 解释说,SwiftUI 会根据视图的身份保留状态存储,并在该身份的生命周期开始时初始化状态。重新创建视图值本身并不会自动产生新状态。因此,从输入初始化本地状态并不是让该状态与后续输入保持同步的通用机制。请参阅 Demystify SwiftUI。

对于这个只读任务,请保留普通输入属性。如果要扩展为编辑功能,请明确设计草稿与提交流程,或绑定到预期的模型值。不要仅为刷新文本而更改视图身份:更改身份会替换关联状态,可能丢弃未完成的草稿。这一结果是根据 Apple 文档中的状态生命周期模型得出的。

当 sheet 包含 AppKit 文本视图时,哪些方面会变化?

sheet 中即使包含 NSTextView,仍然需要相同的选中项目数据路径,同时也需要正确实现桥接。Apple 的 NSViewRepresentable 协议将初次创建(在 makeNSView(context:) 中)与后续配置(在 updateNSView(_:context:) 中)分开。只在创建期间设置文本的包装器,没有实现如何应用后续文本输入。请参阅 NSViewRepresentable。

对于只读包装器,请使用提供的文本初始化原生视图,并在有需要时通过更新方法让显示文本与当前输入一致。Apple 说明,当影响相应 AppKit 视图的内容发生变化时,SwiftUI 会调用更新方法。请参阅 updateNSView(_:context:)。

可编辑的包装器还需要额外的策略:不要在每次更新时都盲目替换文本,以免覆盖进行中的编辑。按照 Apple 在 representable 文档中的说明,使用协调器将原生代理或 target-action 的更改传回 SwiftUI。让 SwiftUI 控制被呈现视图的 frame 和 bounds;Apple 不建议直接设置这些布局属性。

TextKit 负责文本存储、布局和渲染。Apple 记录了 NSTextView 提供的现代 NSTextLayoutManager 路径和旧版 NSLayoutManager 路径。这些功能不会提供 sheet 的选中记录。在调查文本布局之前,先确认桥接层收到的是正确的项目文本;引入其他 TextKit API 前,也要单独检查其可用性。请参阅 TextKit。

选择适合任务的呈现模型

当内容固定且只有可见性会变化时,使用布尔值重载。对于本检查器任务,使用项目重载。Apple 的文档指出,更改传入的项目可能会关闭现有 sheet 并替换它,因此不要把替换项目理解为同一次呈现中可以不中断编辑的保证。请参阅 sheet(item:onDismiss:content:)。

要确保首次呈现正确,让选中记录易于追踪:按钮操作中赋值一次,sheet 闭包中接收一个项目参数,详情视图中使用一个输入。全新启动后分别验证两个示例项目。如果结果仍然不一致,诊断步骤可以判断问题来自选择过程、保留的状态,还是原生视图的更新路径。

来源