将撤销操作注册到长期存在的模型存储中,并让视图通过存储方法提交编辑。对于 Mac 图形编辑器,拖动预览保留在本地,只在松开时提交一次,并由存储注册同样支持重做的逆操作。
本教程将构建一个小型 macOS SwiftUI 应用,其中包含一个虚拟矩形。你可以移动矩形、隐藏其编辑器、撤销移动,然后再次显示编辑器。具体任务是在编辑视图消失而模型仍然存在时,保留撤销行为。所有内容都保存在内存中。
为什么要让存储接收撤销操作?
Apple 基于闭包的注册方法要求目标是引用类型。Apple 还说明,撤销管理器对该目标持有非拥有引用。因此,注册操作并不会延长目标的生命周期。Apple 建议使用处理器的 target 参数,而不是捕获对同一目标的另一个引用。Apple 的撤销注册文档。
拥有已编辑状态的存储适合作为目标。稳定的所有者会让该存储保持存活;临时视图负责显示它并请求更改。在这里,窗口的根视图通过 @StateObject 拥有存储,而可移除的画布观察该存储。SwiftUI 会在声明状态对象的容器生命周期内保留它,但更改容器的标识可能会创建一个新实例。Apple 的 StateObject 文档。
“由存储管理的逆操作”描述的是撤销逻辑所在的位置。撤销管理器仍负责记录并调度回调。存储提供旧值以及恢复该值的方法。这种分工符合 Apple 记录的架构:撤销接收方可以是发生更改的对象,也可以是该对象的所有者。Apple 的 Undo Manager 指南。
| 职责 | 本例中的负责方 |
|---|---|
| 已提交的矩形位置 | 模型存储 |
| 逆操作注册和状态变更 | 模型存储方法 |
| 存储生命周期 | 稳定的窗口根视图 |
| 临时拖动位移 | 画布手势状态 |
| 撤销历史和执行 | 环境中的撤销管理器 |
| 选择适用的管理器 | 交互边界处的视图 |
实现可逆的存储变更
创建一个 macOS SwiftUI 应用,部署目标设为 macOS 13 或更高版本,并使用当前的 Apple SDK。以下三个文件构成完整示例;请替换生成的应用入口点和内容视图,不要保留第二个 @main 声明。当前研究环境中没有 Swift 编译器和 macOS 框架,因此我检查了此示例,但未在此处编译。
创建 ShapeStore.swift。该存储只保存一个已提交的位置,并提供一个变更方法。在赋值前保存先前的位置,就能让该方法具备撤销编辑所需的信息。
import Foundation
import Combine
@MainActor
final class ShapeStore: ObservableObject {
@Published private(set) var position = CGPoint(x: 120, y: 100)
func move(to newPosition: CGPoint, undoManager: UndoManager?) {
let previousPosition = position
guard newPosition != previousPosition else { return }
if let undoManager {
undoManager.registerUndo(withTarget: self) {
[weak undoManager] store in
store.move(
to: previousPosition,
undoManager: undoManager
)
}
undoManager.setActionName("Move Rectangle")
}
position = newPosition
}
}
逆操作会调用同一个方法。假设位置从 (120, 100) 变为 (160, 100)。注册的回调会请求 (120, 100)。撤销执行该回调时,该方法会将 (160, 100) 视为先前的值,并注册一次回退操作。Apple 说明,撤销过程中进行的注册会成为重做操作。如果在 isUndoing 为 true 时跳过注册,就会破坏这种模式。Apple 的 Undo Manager 指南。
闭包通过 store 参数执行操作,并以弱引用捕获管理器。这样可以避免管理器保存的闭包反过来强引用该管理器。Swift 的引用计数文档解释,弱引用不会让被引用实例保持存活;实例释放后,弱引用会变为 nil。《Swift 编程语言:自动引用计数》。
存储及其变更方法都隔离在主 actor 上。这与 Apple 当前的文档相符:UndoManager 和注册处理器都隔离在主 actor 上。该方法会同步执行注册和变更,使这个小型编辑操作留在发起它的交互过程中。Apple 的 UndoManager 文档。
将存储放在可移除编辑器之上
创建 StoreUndoDemoApp.swift。无论画布是否可见,根视图都会拥有存储。按钮在每次操作运行时读取当前环境管理器。画布收到同一个管理器,并用它处理编辑。
import SwiftUI
@main
struct StoreUndoDemoApp: App {
var body: some Scene {
WindowGroup {
EditorRoot()
}
}
}
@MainActor
struct EditorRoot: View {
@StateObject private var store = ShapeStore()
@State private var showsEditor = true
@Environment(\.undoManager) private var undoManager
var body: some View {
VStack(spacing: 16) {
HStack {
Button("Move Right") {
store.move(
to: CGPoint(
x: store.position.x + 20,
y: store.position.y
),
undoManager: undoManager
)
}
Button("Undo") {
if let undoManager, undoManager.canUndo {
undoManager.undo()
}
}
Button("Redo") {
if let undoManager, undoManager.canRedo {
undoManager.redo()
}
}
Toggle("Show Editor", isOn: $showsEditor)
}
Text(
"Position: \(Int(store.position.x)), " +
"\(Int(store.position.y))"
)
if showsEditor {
ShapeCanvas(store: store, undoManager: undoManager)
} else {
Text("Editor hidden")
.frame(width: 400, height: 260)
}
}
.padding(20)
.frame(width: 620, height: 380)
}
}
SwiftUI 环境中的撤销管理器是可选的。Apple 明确允许在环境提供 nil 时跳过撤销注册。在这种情况下,此示例仍会移动矩形,但不会创建撤销记录。如果预期的撤销操作没有出现,请先检查交互是否收到了管理器,再更改逆操作逻辑。Apple 的环境 undoManager 文档。
将管理器传递给每次变更,也能明确体现窗口上下文。存储不会保留最先出现的某个窗口管理器。在本例中,每个窗口根视图都拥有自己的存储。如果以后多个窗口要编辑同一个共享模型,请先决定由哪个历史记录管理这些编辑,再将此方案用于多个窗口。
提交一次拖动,不要让撤销组跨越多个事件
创建 ShapeCanvas.swift。拖动期间,矩形的显示位置会加上临时位移。只有手势成功结束时,存储中的已提交位置才会改变。请先完成一次拖动,再执行其他编辑或撤销命令;这个简化示例假定交互按顺序进行,不会仲裁拖动期间同时发起的命令。
import SwiftUI
@MainActor
struct ShapeCanvas: View {
@ObservedObject var store: ShapeStore
let undoManager: UndoManager?
@GestureState private var translation: CGSize = .zero
var body: some View {
ZStack(alignment: .topLeading) {
Color.gray.opacity(0.12)
Rectangle()
.fill(Color.blue)
.frame(width: 70, height: 50)
.position(
x: store.position.x + translation.width,
y: store.position.y + translation.height
)
.gesture(
DragGesture()
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
store.move(
to: CGPoint(
x: store.position.x
+ value.translation.width,
y: store.position.y
+ value.translation.height
),
undoManager: undoManager
)
}
)
}
.frame(width: 400, height: 260)
.clipped()
}
}
手势使用最终位移来提交移动。它不依赖预览属性重置的时机。Apple 说明,updating(_:body:) 会在手势结束或取消时重置手势状态,而 onEnded(_:) 只会在手势成功完成后运行。因此,按此设计取消手势不会产生已提交的移动,也不会新增撤销操作。Apple 的手势 updating 文档,Apple 的手势 completion 文档。
这样就保留了三个彼此独立的边界:预览更新会改变临时显示状态;成功完成后会请求一次模型编辑;存储会注册该编辑的逆操作。如果在每次拖动更新时都调用存储变更方法,就会在整个手势过程中注册各个中间位置。
Apple 的默认事件分组会收集围绕运行循环各次执行过程的注册操作。一次拖动可能跨越多个执行过程产生更新,因此仅靠事件分组无法表达“整次拖动作为一个事务”的意图。在这里,一次性提交可以解决这个问题,无需更改共享管理器的配置。Apple 的 groupsByEvent 文档。
对于未来需要同步更改多个项目的命令,可以使用显式且配对完整的撤销组来收集逆操作。请在该命令内部开始和结束撤销组。如果必须在拖动期间实时修改模型,就需要设计单独的开始、更新、提交和取消操作,并明确其他命令如何与进行中的编辑交互。此处只使用预览的方案,是为了让存储不必承担这些额外的事务处理机制。
分别验证所有权、重做和窗口路由
运行应用后按以下顺序操作。坐标是根据给定初始状态和按钮增量推算出的预期示例值,并非实测结果。
| 操作 | 预期的已提交位置或结果 |
|---|---|
| 打开一个新窗口 | (120, 100) |
点击 Move Right |
(140, 100) |
关闭 Show Editor |
画布消失;位置仍为 (140, 100) |
点击 Undo |
画布仍不可见,位置变为 (120, 100) |
点击 Redo |
位置变为 (140, 100) |
打开 Show Editor |
矩形出现在重做后的位置 |
完成一次拖动,再点击 Undo |
矩形回到拖动前的位置 |
隐藏编辑器的检查可以区分模型所有权与界面呈现的生命周期。如果只有某个面板存在时撤销才有效,请检查向管理器注册的目标以及持有该目标的对象。如果撤销有效但重做无效,请检查逆操作是否注册了返回操作。如果一次拖动需要多次撤销,请检查预览更新是否进入了存储中可撤销的变更方法。
显式按钮让变更路径更容易检查。要验证 Mac 的常规菜单行为,还应在目标窗口处于活动状态时测试标准的 Edit 菜单。AppKit 的基类 responder 会从下一个 responder 获取其撤销管理器。窗口向其 delegate 请求管理器时,windowWillReturnUndoManager(_:) 是文档所述的自定义入口;若没有实现该方法,窗口就会创建自己的管理器。在无关的管理器上注册操作,并不能保证按预期通过 responder 链路由。Apple 的 NSResponder undoManager 文档,Apple 的窗口撤销管理器 delegate 文档。
存储还需要明确的退役边界。如果替换存储时其管理器仍然存在,应在释放该存储前,从每个持有其操作的管理器中移除该存储的注册项,使用 removeAllActions(withTarget:) 即可。不要仅仅因为画布消失就清除历史记录:此处要实现的行为,正是在画布消失期间仍能撤销存储中的编辑。Apple 的按目标清理撤销操作文档。
此示例只有一个固定矩形,没有删除路径。扩展到集合时,应捕获稳定的项目标识符以及撤销每项操作所需的值。删除操作需要一个能恢复项目及其预期顺序的逆操作;如果集合可能发生变化,单独保存旧数组索引并不足够。
完成后的设计由根视图负责生命周期,画布负责临时交互状态,存储负责可逆编辑。隐藏画布后,编辑仍可撤销,因为相应的逆操作方法作用于仍然存活的存储。
来源
- registerUndo(withTarget:handler:) — Apple
- StateObject — Apple
- Undo Manager — Apple
- Automatic Reference Counting — Swift.org
- UndoManager — Apple
- undoManager — EnvironmentValues — Apple
- updating(_:body:) — Apple
- onEnded(_:) — Apple
- groupsByEvent — Apple
- undoManager — NSResponder — Apple
- windowWillReturnUndoManager(_:) — Apple
- removeAllActions(withTarget:) — Apple