要在更新期间保持 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 运行环境,我无法在这里编译或运行它。
如何在不关闭工作表的情况下测试更新
- 运行应用并点击
Edit Note。 - 将示例文本替换为
Sample note Beta。 - 多次点击
Update Owner。每次点击都会递增所有者状态,并切换所有者的条件内容。 - 确认编辑器仍然可见,文本仍为
Sample note Beta,且版本显示不断递增。 - 点击
Done。确认在展开布局处于启用状态时,窗口会以该布局显示编辑后的文本。 - 重新打开编辑器,确认编辑后的文本仍然可用。
更新按钮位于工作表内,因此测试不依赖点击底层窗口。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 范围内,在界面更新时继续编辑。
来源
- Demystify SwiftUI — Apple
- sheet(isPresented:onDismiss:content:) — Apple
- id(_:) — Apple
- State — Apple
- dismiss — Apple
- TextEditor — Apple
- sheet(item:onDismiss:content:) — Apple
- Identifiable — Apple
- interactiveDismissDisabled(_:) — Apple
- NSViewRepresentable — Apple
- makeNSView(context:) — Apple
- TextKit — Apple
- beginSheet(content:completionHandler:) — Apple
- NSWindow.HostingSheetRepresentation — Apple
- beginSheet(_:completionHandler:) — Apple