要在更新期间保持 SwiftUI 工作表打开,请保留其呈现宿主的身份,并保持其呈现绑定处于激活状态。将工作表附加到稳定的容器,这样即使子内容发生变化,也能更新而无需替换呈现者。

本指南面向正在开发 Mac 应用的开发者:应用包含一个可编辑工作表,该工作表在底层界面变化时应保持可见。示例使用合成的本地文本,并将三个方面分开,以便你分别检查:呈现状态、呈现视图的生命周期,以及编辑器的内容。

为什么视图更新会让工作表消失?

SwiftUI 在评估 body 时可以创建新的视图值,同时保留所显示视图的身份。状态会在该身份的生命周期内持续存在。移除视图或更改其身份会结束该生命周期;在条件分支之间切换也可能创建不同的身份,即使两个分支包含相同类型的视图也是如此。Apple 在 Demystify SwiftUI 中解释了这些关系。

对于由布尔值驱动的工作表,文档所述的呈现控制项是传递给 sheet(isPresented:onDismiss:content:) 的绑定:值为 true 表示请求呈现。调查工作表消失的问题时,请同时检查该绑定,以及携带修饰符的视图是否仍然存在。存储在其他位置的 true 值并不能证明原来的呈现宿主仍然存在。请参阅 Apple 的布尔值工作表文档。

一个特别直接的原因是显式标识符发生变化。Apple 说明,更改传给 id(_:) 的值会重置视图的身份,包括其状态。检查呈现视图及其祖先,看看是否有基于版本计数器、可编辑文本或新生成值的标识符。如果重置行为并非预期,请移除这类标识符。请参阅 id(_:)。

根据实际设计经验,应该把工作表修饰符放在界面变化部分之外。如果加载视图、筛选后的行或备用详情布局可能消失,那么离它最近且合适的持久祖先通常更适合作为呈现所有者。

使用完整的 Mac 示例构建稳定的工作表所有者

在 Xcode 中创建 macOS SwiftUI 应用,将部署目标设为 macOS 12 或更高版本,并用下面这个单文件替换生成的应用和内容视图声明。只保留一个 @main 应用声明。macOS 12 的最低版本要求可满足示例中的 dismiss 环境操作;TextEditor 本身从 macOS 11 起便可用。请参阅 Apple 的 dismiss 文档和 TextEditor 文档。

窗口的根视图拥有呈现标志、可编辑文本、版本计数器和布局标志。外层 VStack 携带工作表修饰符。只有该容器内的内容会切换分支。Apple 建议在需要状态的最高层视图中私有声明状态,并将绑定传给需要修改状态的子视图。请参阅 State。

import SwiftUI

@main
struct StableSheetDemoApp: App {
    var body: some Scene {
        WindowGroup {
            SheetOwnerView()
        }
    }
}

struct SheetOwnerView: View {
    @State private var isEditorPresented = false
    @State private var note = "Sample note Alpha"
    @State private var revision = 0
    @State private var compactLayout = false

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

            if compactLayout {
                Text("Compact summary: revision \(revision)")
            } else {
                VStack(alignment: .leading, spacing: 8) {
                    Text("Expanded summary")
                    Text("Revision \(revision)")
                    Text(note)
                }
            }

            Button("Edit Note") {
                isEditorPresented = true
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 260)
        .sheet(isPresented: $isEditorPresented) {
            NoteEditorSheet(
                note: $note,
                revision: $revision,
                compactLayout: $compactLayout
            )
        }
    }
}

struct NoteEditorSheet: View {
    @Binding var note: String
    @Binding var revision: Int
    @Binding var compactLayout: Bool
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Edit Sample Note")
                .font(.headline)

            TextEditor(text: $note)
                .frame(minHeight: 160)

            Text("Owner revision: \(revision)")

            Button("Update Owner") {
                revision += 1
                compactLayout.toggle()
            }

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

这是一个完整的演示应用,已对照文档中的 API 进行检查。由于此环境没有 Swift 工具链或 macOS 运行环境,我无法在这里编译或运行它。

如何在不关闭工作表的情况下测试更新

  1. 运行应用并点击 Edit Note。
  2. 将示例文本替换为 Sample note Beta。
  3. 多次点击 Update Owner。每次点击都会递增所有者状态,并切换所有者的条件内容。
  4. 确认编辑器仍然可见,文本仍为 Sample note Beta,且版本显示不断递增。
  5. 点击 Done。确认在展开布局处于启用状态时,窗口会以该布局显示编辑后的文本。
  6. 重新打开编辑器,确认编辑后的文本仍然可用。

更新按钮位于工作表内,因此测试不依赖点击底层窗口。AppKit 将工作表定义为文档模态窗口:工作表可见时,大多数发往其父窗口的事件都会被禁止。通过绑定更新所有者,可以让此示例直接测试状态变化。请参阅 beginSheet(_:completionHandler:)。

确定关闭编辑器应代表什么

此示例会立即编辑所有者的文本。Done 会关闭编辑器,但不会执行单独的保存操作。字符串会保留在窗口本地状态中,而此示例不提供跨应用启动的持久化功能。这种行为符合 State 中所述的共享绑定设计。

如果任务需要支持取消的草稿,请使用单独的草稿值,并仅在明确的确认操作中将其复制到文档。实现关闭行为前,先定义好这种行为:决定提交还是丢弃草稿,与决定哪个视图拥有工作表是两回事。

通过三项独立检查诊断关闭问题

在自己的应用中按以下顺序检查。这能将一般性的身份规则转化为有针对性的调查,无需假设每个工作表消失的问题都有相同原因。

检查项 检查内容 实际修正方法
呈现值 所有将布尔值设为 false 或清除可选呈现项的写入操作 只在有意的关闭操作中写入关闭状态
呈现者身份 条件分支、已移除的行、变化的祖先标识符 将呈现状态和修饰符移到合适的稳定祖先上
内容生命周期 工作表本地状态、变化的内容标识符、编辑器同步 保留预期的编辑会话,并有意更新现有内容

首先,在项目中搜索对呈现属性的赋值,以及对 dismiss() 的调用。刷新回调可能显式关闭了工作表。像示例中那样,在工作表自身的视图中读取 dismiss:Apple 说明,该操作适用于获取它时所在的环境。在窗口的根视图中读取它,可能会操作那个窗口。请参阅 dismiss。

其次,检查修饰符上方的层级结构。如果工作表附加到会在刷新时被移除的详情视图,请将它移到替换边界之上,同时也要移动本地拥有的呈现状态。根据 Apple 对视图生命周期的说明,如果标志仍留在生命周期已结束的子视图中,其存储就容易被替换。

第三,区分呈现已关闭与编辑器仍然可见但草稿丢失这两种情况。先从简单的 TextEditor 示例开始,再分阶段恢复自定义内容。这种对比是一种诊断方法:它有助于判断故障源于呈现所有权,还是你的内容更新逻辑。

应该改用由项目驱动的工作表吗?

当某个特定的选定实体是呈现内容的数据源时,请使用 sheet(item:onDismiss:content:)。Apple 文档说明,该方式使用遵循 Identifiable 的可选项目,将非 nil 项目传入内容闭包,并描述了项目变化时的替换行为。切换重载本身并不能保留生命周期已结束的呈现者。请参阅由项目驱动的工作表。

选择一个能在预期编辑会话期间代表该实体的标识符。Swift 的 Identifiable 协议用于表达稳定身份,其文档明确指出,所需的持续时间和作用范围由参与的类型自行决定。每次读取属性时都生成的新标识符无法代表一个持续存在的实体。请参阅 Identifiable。

对于演示中的简单编辑器,使用布尔型呈现标志和单独的文本绑定,便于检查二者的预期关系。对于编辑选定项目的编辑器,应将选择身份与频繁变化的字段分开。把选择另一个实体视为有意的呈现决策,并先定义当前草稿应如何处理。

禁用交互式关闭能让工作表保持存在吗?

interactiveDismissDisabled(_:) 控制非程序化关闭。Apple 明确指出,它无法阻止由更改呈现绑定或调用 dismiss 引起的关闭。因此,其文档所述的作用无法解决身份被替换的问题。请参阅 interactiveDismissDisabled(_:)。

只有当界面确实需要这种关闭行为时,才在工作表层级结构内部应用此修饰符,并保留明确的完成路径。对于此任务,请先确保宿主及其状态保持稳定。在找出原因前先限制关闭,可能无法解决原有的所有权问题。

工作表包含 AppKit 或 TextKit 内容时会有哪些变化?

如果通过 NSViewRepresentable 嵌入 NSTextView,请将创建与更新分开。Apple 将 makeNSView(context:) 定义为创建步骤,将 updateNSView(_:context:) 定义为应用后续 SwiftUI 信息的位置。协调器负责将委托或 target-action 的变化传回 SwiftUI。请参阅 NSViewRepresentable 和 makeNSView(context:)。

根据这一生命周期,可以得出一条实用的实现规则:不要仅仅因为无关的所有者状态发生变化,就重建编辑器。在替换其内容之前,先确定传入的模型文本是否确实不同,以及应如何处理同时发生的本地编辑。工作表保持稳定,本身并不能解决文本的冲突写入问题。

TextKit 提供文本存储、布局和渲染功能。AppKit 的 NSTextView 将这些功能整合起来,并支持现代和传统布局引擎。这些机制与编辑器内容有关;呈现所有权仍需要独立且稳定的生命周期。只有在编辑任务需要时才使用 TextKit,并将文本更新行为与工作表关闭问题分开调查。请参阅 Apple 的 TextKit 概览。

何时适合使用由窗口所有的 AppKit 呈现方式?

如果现有应用已在 AppKit 中管理窗口生命周期,macOS 26 引入了 NSWindow.beginSheet(content:completionHandler:),可直接在该窗口上呈现 SwiftUI 内容。Apple 文档说明,它支持工作表的 dismiss 环境操作、尺寸设置、工具栏和交互式关闭控制。采用此 API 前,请使用 macOS 26 SDK 并确保遵守 macOS 26 的可用性边界。请参阅 beginSheet(content:completionHandler:)。

返回的 NSWindow.HostingSheetRepresentation 可以更改所呈现的根视图,也可以支持从 AppKit 进行程序化关闭。如果窗口级所有权适合应用架构,这是一种有文档依据的选择。对于上面的稳定 SwiftUI 容器示例,则无需使用它。请参阅 NSWindow.HostingSheetRepresentation。

对于更早的 macOS 版本,AppKit 现有的 beginSheet(_:completionHandler:) 接受 NSWindow 工作表,并说明了呈现排队机制。不要假设这些系统支持较新的 SwiftUI 内容重载。请参阅 beginSheet(_:completionHandler:)。

保持所有者稳定、保持呈现值处于激活状态,并保留编辑器预期的数据来源。恢复更复杂的内容前,先用一个小型本地更新操作验证这些条件。这样,你便能在 Apple 文档规定的生命周期和 API 范围内,在界面更新时继续编辑。

来源