將復原操作註冊到長期存活的模型儲存庫,並讓檢視透過儲存庫的方法提交編輯。對於 Mac 形狀編輯器,拖曳預覽保持在本地,放開時只提交一次,並由儲存庫註冊可讓重做成為可能的反向動作。
本教學會建置一個小型 macOS SwiftUI App,其中包含一個合成矩形。你可以移動它、隱藏其編輯器、復原移動,然後再次顯示編輯器。具體目標是在編輯檢視消失、但其模型仍存活時,保留復原行為。所有內容都只留在記憶體中。
為什麼應該由儲存庫接收復原操作?
Apple 的閉包式註冊方法需要參考型別的目標。文件也說明,復原管理器會維護對該目標的非擁有參考。因此,註冊動作並不會延長目標的生命週期。Apple 建議使用處理器的 target 參數,而不要擷取另一個指向相同目標的參考。Apple 的復原註冊文件。
擁有編輯狀態的儲存庫適合作為目標。穩定的擁有者會讓該儲存庫持續存活;暫時性的檢視則負責顯示它並提出變更要求。此處由視窗的根檢視透過 @StateObject 擁有儲存庫,而可移除的畫布則觀察它。SwiftUI 會在宣告狀態物件的容器生命週期內保留該物件,但變更容器的識別值可能會建立新執行個體。Apple 的 StateObject 文件。
「由儲存庫擁有反向動作」描述的是反轉邏輯所在的位置。復原管理器仍會記錄並排程回呼。儲存庫提供舊值,以及還原該值的方法。這種分工符合 Apple 所記載的架構:復原接收者可以是被變更的物件,也可以是其擁有者。Apple 的 Undo Manager 指南。
| 職責 | 此範例中的擁有者 |
|---|---|
| 已提交的矩形位置 | 模型儲存庫 |
| 反向動作註冊與狀態變更 | 模型儲存庫方法 |
| 儲存庫生命週期 | 穩定的視窗根檢視 |
| 暫時性的拖曳位移 | 畫布手勢狀態 |
| 復原歷程與執行 | 環境中的復原管理器 |
| 選擇適用的管理器 | 互動邊界上的檢視 |
實作可反轉的儲存庫變更
建立一個部署目標為 macOS 13 或更新版本,並使用目前 Apple SDK 的 macOS SwiftUI App。以下三個檔案構成完整範例;請取代產生的 App 進入點和內容檢視,不要保留第二個 @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 程式語言:自動參考計數。
儲存庫及其變更方法隔離在主執行器上。這符合 Apple 目前的文件:UndoManager 和註冊處理器都隔離在主執行器上。該方法會同步執行註冊與變更,讓這項小型編輯在呼叫它的互動期間完成。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 的手勢更新文件、Apple 的手勢完成文件。
如此便能保留三個不同的界線:預覽更新只會變更暫時性的顯示狀態;成功完成會提出一次模型編輯要求;儲存庫則會註冊該編輯的反向動作。若在每次拖曳更新時都呼叫儲存庫變更方法,就會在整個手勢過程中註冊各個中間位置。
Apple 預設會在 RunLoop 的每輪期間,依事件分組註冊操作。拖曳可能會跨越多輪產生更新,因此僅靠事件分組無法表達整個拖曳應視為單一交易的意圖。此處透過只提交一次來避免這個問題,且不必變更共用管理器的設定。Apple 的 groupsByEvent 文件。
未來若有某個命令需要同步變更多個項目,可以使用明確且成對的復原群組來收集其反向動作。請在該命令內開始並結束群組。如果必須在拖曳期間即時變更模型,請另外設計開始、更新、提交和取消操作,並納入其他命令與作用中編輯互動的方式。此處採用僅預覽的做法,刻意不把額外的交易機制放進儲存庫。
分別驗證擁有權、重做和視窗路由
在執行中的 App 依照以下步驟操作。座標是根據提供的初始狀態和按鈕增量推算的預期示例值,並非實際測量結果。
| 操作 | 預期的已提交位置或結果 |
|---|---|
| 開啟新視窗 | (120, 100) |
點擊 Move Right |
(140, 100) |
關閉 Show Editor |
畫布消失;位置仍為 (140, 100) |
點擊 Undo |
位置變成 (120, 100),畫布仍維持隱藏 |
點擊 Redo |
位置變成 (140, 100) |
開啟 Show Editor |
矩形出現在重做後的位置 |
完成拖曳後點擊 Undo |
矩形回到該次拖曳前的位置 |
隱藏編輯器的檢查可以區分模型擁有權和呈現生命週期。如果只有某個特定面板存在時復原才有效,請檢查註冊到管理器的目標,以及保留該目標的物件。如果復原有效但重做沒有作用,請檢查反向動作是否註冊了返回操作。如果一次拖曳需要復原很多次,請檢查預覽更新是否傳到了儲存庫可復原的變更方法。
明確的按鈕讓變更路徑容易檢視。若要符合一般 Mac 選單行為,也請在預期的視窗為作用中視窗時測試標準的 Edit 選單。AppKit 的基礎回應者會從下一個回應者取得其復原管理器。視窗向委託詢問管理器時,windowWillReturnUndoManager(_:) 是文件記載的自訂點;若未實作,視窗會建立自己的管理器。在不相關的管理器上註冊,並不能建立預期的回應者鏈路由。Apple 的 NSResponder undoManager 文件、Apple 的視窗復原管理器委託文件。
儲存庫也需要明確的退役界線。如果在管理器仍存活時替換儲存庫,請在釋放儲存庫之前,使用 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