在 Mac 上构建编辑器:在父视图中保留已接受的文本,并在工作表中编辑单独的草稿。只有 Commit 才会将草稿复制回去;Cancel 会关闭工作表。本教程将在你自己的应用中实现这一流程,用于编辑一则简短的纯文本便笺。
定义提交和取消的行为
读者要完成的操作很简单:打开一则现有便笺,修改几行,然后接受完整修改,或保留原文不变。示例使用虚构文本和单次编辑会话。已接受的文本保存在内存中;添加文件存储属于单独的应用设计决策。
Apple 的 TextEditor 用于显示可滚动的多行文本,并接受一个绑定,指向正在编辑的值。因此,如果绑定到已接受的值,编辑就会立即改变该值。要实现延迟接受,请改为将编辑器绑定到草稿。请参阅 Apple 的 TextEditor 文档。
此设计包含三个部分:由父视图持有的已接受文本、编辑开始时创建的快照,以及由工作表持有的可变草稿状态。Swift 的 String 具有值语义,因此更改草稿不会更改已接受的字符串。写时复制存储是一项实现层面的优化;独立值的行为仍然不变。请参阅 String。
| 事件 | 草稿行为 | 已接受文本 |
|---|---|---|
| 打开编辑器 | 从当前已接受的文本开始 | 不变 |
| 输入或删除文本 | 在工作表中更改 | 不变 |
选择 Commit |
传递给父视图,然后关闭工作表 | 替换为草稿 |
选择 Cancel |
关闭工作表,不传回草稿 | 不变 |
| 重新打开编辑器 | 开始新的会话 | 提供新的起始文本 |
此表也定义了实现约定。关闭工作表本身并不代表接受修改。明确区分二者,就无需执行回滚操作也能实现取消。
创建完整的 macOS 示例
在 Xcode 中使用 SwiftUI 创建一个 macOS 应用项目,并将部署目标设为 macOS 12 或更高版本。用下面这个单一源文件替换生成的 app 和 content view 声明,并删除旧的 app 声明,确保目标中恰好只有一个 @main 入口点。此示例使用纯文本编辑,而不是较新的富文本初始化器。
最低支持 macOS 12,是因为工作表使用了 DismissAction;纯文本 TextEditor 从 macOS 11 起即可使用。Apple 在 DismissAction 和 TextEditor 文档中说明了这些可用性边界。
import SwiftUI
import Foundation
@main
struct DraftEditorApp: App {
var body: some Scene {
WindowGroup {
NoteView()
}
}
}
private struct EditSession: Identifiable {
let id = UUID()
let initialText: String
}
private struct NoteView: View {
@State private var acceptedText = """
Sample note
Arrange the blue tiles beside the green tiles.
"""
@State private var editSession: EditSession?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Accepted note")
.font(.headline)
ScrollView {
Text(acceptedText.isEmpty ? "(Empty note)" : acceptedText)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button("Edit Note") {
editSession = EditSession(initialText: acceptedText)
}
}
.padding(20)
.frame(minWidth: 460, minHeight: 300)
.sheet(item: $editSession) { session in
DraftEditor(initialText: session.initialText) { newText in
acceptedText = newText
}
.id(session.id)
}
}
}
private struct DraftEditor: View {
@Environment(\.dismiss) private var dismiss
@State private var draft: String
private let initialText: String
private let onCommit: (String) -> Void
init(
initialText: String,
onCommit: @escaping (String) -> Void
) {
self.initialText = initialText
self.onCommit = onCommit
_draft = State(initialValue: initialText)
}
private var hasChanges: Bool {
draft != initialText
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Edit note")
.font(.headline)
TextEditor(text: $draft)
.font(.body)
.accessibilityLabel("Note draft")
.frame(minHeight: 220)
Text(hasChanges ? "Draft changed" : "No changes")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button("Commit") {
onCommit(draft)
dismiss()
}
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding(20)
.frame(minWidth: 480, minHeight: 340)
}
}
这是一个完整的应用示例,包含初始文本、呈现状态、编辑器和入口点。研究环境中没有 Swift 编译器和 macOS SDK,因此这里只根据检索到的 API 文档检查了示例,未在此处编译或运行。
为什么已接受的值由父视图持有
父视图持有 acceptedText,因为它要显示已接受的结果,并为下一次编辑会话提供起始值。子视图持有 draft,因为只有编辑界面需要修改它。这样做遵循 Apple 的建议:将状态保持为私有,并放在视图层级中合适的位置。请参阅 State。
父视图不会将 $acceptedText 传入编辑器,而是传入一个字符串值和一个闭包。闭包中包含唯一一处接受修改的赋值:acceptedText = newText。在此实现中,无论输入还是取消,都无法触发该赋值。
为什么每次打开时都要创建会话标识符
可选的 EditSession 同时提供呈现状态和初始文本。Apple 说明,当绑定的值不为 nil 时,sheet(item:onDismiss:content:) 会呈现内容,并将该值传入内容闭包。请参阅 sheet(item:onDismiss:content:)。
每次按下 Edit Note 都会创建一个新标识符。将该标识符应用于编辑器,可以明确表达预期的状态生命周期:一个草稿属于一个编辑会话。初始化器会提供初始状态值;它不能持续将现有草稿与之后的父视图更改同步。
对于此任务,这种快照行为正合适。便笺编辑到一半时,不应因为父视图中某个无关部分更新而突然重置。重新打开时,会根据最近一次接受的文本创建一个新草稿。
明确处理关闭和键盘行为
像示例中那样,在呈现的编辑器内部读取 @Environment(\.dismiss)。Apple 解释说,该操作适用于声明它的环境。在呈现工作表的根视图中读取它,可能会关闭该视图所在的窗口,而不是工作表。请参阅 DismissAction。
Cancel 按钮只负责关闭工作表。由于草稿从未更改已接受的值,因此可以直接丢弃。Commit 按钮会先调用接受闭包,然后关闭工作表。不要将接受操作移入 onDismiss:该回调表示关闭,无论用户选择取消还是提交都会触发。
示例使用 Apple 文档中标准的取消快捷键 .cancelAction,将 Escape 键分配给 Cancel。它还通过显式的按键等效项和修饰键,将 Command-Return 分配给 Commit。这些都是应用界面中的操作快捷键。请参阅 cancelAction 和 keyboardShortcut(_:modifiers:)。
由于示例没有将 .defaultAction 分配给提交按钮,因此普通 Return 键仍可用于多行编辑。Apple 将 .defaultAction 定义为不带修饰键的 Return。Command-Return 是为此多行编辑界面作出的设计选择,并不意味着每个 Mac 编辑器都应采用它。请参阅 defaultAction。
在相关界面中,为快捷键指定互不冲突的按键。Apple 说明,如果多个控件使用相同快捷键,系统会采用解析过程中找到的第一个匹配控件。在插入点位于编辑器内时测试这些快捷键,并同时测试点击可见按钮的操作。请参阅 keyboardShortcut(_:modifiers:)。
通过简短的测试流程验证接受边界
在运行中的 Mac 应用里执行以下检查。这些操作会测试草稿编辑器与即时生效编辑器之间的关键差异,也包括容易遗漏的空字符串情况。
- 打开
Edit Note,将第二行替换为Arrange the green tiles in a row.,然后点击Cancel。已接受的便笺应保留原来的第二行。 - 重新打开编辑器。内容应是原先已接受的文本,不包含已取消的修改。
- 做出相同修改并点击
Commit。已接受的便笺现在应显示修改后的行。 - 重新打开,添加一个换行,然后按 Command-Return。完整的多行草稿应成为已接受的文本。
- 重新打开并进行修改,在编辑时测试 Escape 键。确认取消后已接受的文本保持不变。
- 重新打开,删除全部文本并点击
Commit。父视图应显示(Empty note)。再次打开时,编辑器应为空。
在此示例中,空文本是有效值。实现不会去除空白字符、删除换行,也不会因为字符串为空而禁用接受操作。如果你自己的便笺类型要求必须有内容,请添加明确可见的验证规则,并在提交操作中以及按钮的启用状态中应用该规则。
hasChanges 比较只控制状态标签。即使没有更改,也允许点击 Commit,接受操作也只是将相同的值再次赋给自身。如果之后选择在内容未更改时禁用它,请确保 Cancel 仍然可用,以便用户始终能明确退出工作表。
了解何时 SwiftUI 已足够,何时 AppKit 会有帮助
对于此纯文本任务,TextEditor 已经提供所需的编辑界面。无需自定义文本布局或使用 AppKit 桥接,就能将草稿更改与已接受文本分开。这种区分属于应用的状态设计。
当前的 SwiftUI 还支持基于富文本字符串的 TextEditor 初始化器,从 macOS 26 起可用。因此,富文本并不必然要求替换 SwiftUI,但需要更改草稿类型并遵守更新后的部署要求。Apple 还记录了该编辑器在格式属性转换方面的限制。请参阅 init(text:selection:)。
如果你需要 AppKit 专属的编辑行为或直接控制文本布局,受支持的集成方式是通过 NSViewRepresentable 托管 NSTextView。Apple 介绍了用于创建、更新和拆除 AppKit 视图的 representable 方法,以及用于将代理更改传回 SwiftUI 的协调器。请参阅 NSViewRepresentable。
在此桥接中保留相同的接受约定:AppKit 编辑操作会更新草稿,只有显式提交操作才会更新已接受的模型。作为实现上的注意事项,避免在每次 SwiftUI 更新时都替换文本视图的全部内容;应区分实际传入的更改和编辑器自身发出的更改。
TextKit 还有一个边界:NSTextView 通过 textLayoutManager 提供现代布局,通过 layoutManager 提供旧版布局。Apple 说明,在 macOS 12 或更高版本上访问后者,会使文本视图切换到使用 NSLayoutManager 的兼容模式。添加布局功能时,应有意识地选择引擎。请参阅 TextKit 和 NSTextView。
最后,示例中的提交操作是同步的,并且只在内存中进行。如果你将它扩展为写入文档,请明确接受操作究竟意味着更新模型,还是意味着写入完成。若设计要求先写入再关闭,写入失败时应保持工作表打开,并保留草稿供再次尝试。如果编辑期间同一便笺可能通过应用内的其他界面发生变化,请在替换之前添加版本检查。
核心设计仍然很简单:每个编辑会话创建一个草稿,让编辑器修改该草稿,并为接受操作提供唯一且明确的路径。明确这一边界后,随着编辑界面扩展,取消行为仍会保持可预测。
来源
- TextEditor — Apple
- String — Apple
- State — Apple
- sheet(item:onDismiss:content:) — Apple
- DismissAction — Apple
- cancelAction — Apple
- defaultAction — Apple
- keyboardShortcut(_:modifiers:) — Apple
- init(text:selection:) — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- NSTextView — Apple