在 Mac app 中拖曳形狀時,於手勢期間預覽移動,並在一個簡短的復原群組中提交最終位置。讓模型和復原管理器由同一個擁有者管理,為每次群組開啟配對一次關閉,並註冊反向移動,讓重做也能正常運作。
本教學會建立一個小型 SwiftUI 畫布,其中包含一個合成的記憶體內圓形。具體目標是讓每次完成的拖曳恰好只需復原一次,無論收到多少次移動更新。本文也會說明如何在 AppKit 檢視中保留相同的界線。
為什麼拖曳時不該讓復原群組保持開啟
拖曳是一種隨時間展開的互動。復原群組則收集可逆的操作。兩者的生命週期不必相同。Apple 文件指出,groupsByEvent 預設為 true,並會依照執行迴圈的各次循環將操作分組;此行為並未定義完整的拖曳交易。明確群組也可以巢狀於現有群組中。請參閱 Apple 的自動事件分組與開始復原群組文件。
在第一個 SwiftUI 移動回呼中開啟群組,並在 onEnded 中關閉,會留下取消時的缺口。Apple 明確指出,只有手勢成功結束時才會執行 onEnded。相較之下,透過 updating 維護的狀態會在手勢結束或取消時重設。未配對的 endUndoGrouping() 會引發例外,因此猜測某個回呼是否開啟了群組也不適合。請參閱 onEnded(_:)、updating(_:body:)與 endUndoGrouping()。
此畫布採用以下設計。這是根據上述文件所述界線而選擇的實作方式:
| 互動階段 | 顯示狀態 | 已提交的模型 | 復原分組 |
|---|---|---|---|
| 拖曳前 | 儲存的中心點 | 原始中心點 | 已關閉 |
| 移動更新 | 原始中心點加上暫態位移 | 不變 | 已關閉 |
| 成功完成 | 最終中心點 | 更新一次 | 同步開啟並關閉 |
| 取消 | 預覽重設 | 不變 | 從未開啟 |
| 復原或重做 | 還原的中心點 | 透過可逆 setter 更新 | 由 UndoManager 管理 |
採用此方法,圓形可以持續移動,而每個預覽畫面不會被視為永久編輯。完成的拖曳會成為一個具名的模型操作。
決定由誰擁有復原記錄
此範例讓每個畫布各自擁有模型和私有復原管理器。由於該擁有者控制所有註冊,因此會在初始化時設定一次 groupsByEvent = false,並明確分組每次已提交的移動。Apple 規定,停用自動分組時,必須在復原前明確關閉群組。此範例會在提交方法返回前關閉群組。請參閱 groupsByEvent。
現有的文件編輯器可能已經有文件復原管理器。SwiftUI 會透過 @Environment(\.undoManager) 提供情境中的管理器,而 Apple 文件指出,如果情境不支援復原,該值可能是 nil。在這種架構中,請繼續使用文件的管理器及其既有分組政策;不要將此範例的私有管理器設定套用到共用管理器。請參閱 EnvironmentValues.undoManager。
使用共用管理器並啟用自動分組時,在完成時註冊一次反向移動,仍可避免每次移動都產生一筆歷史記錄。明確子群組可以將這次移動包裝起來,但無法讓它脫離外層群組而獨立存在。若其他編輯也共用該外層群組,它們就會一起復原。若要確保它是獨立的最上層交易,必須由共用歷史記錄的擁有者協調,而不是由子檢視關閉群組。
建立完整的 SwiftUI 範例
建立一個部署目標為 macOS 14 或更新版本的 macOS SwiftUI app 專案。將產生的 app 宣告和內容檢視替換為下方兩個檔案。只保留一個 @main 宣告。此範例使用本機的 Undo 和 Redo 按鈕,讓擁有權一目了然;應用程式選單和 responder chain 整合則是另一個整合步驟。
模型使用可逆 setter:擷取目前位置、註冊一個還原位置的閉包,接著套用替代位置。當該閉包在復原期間執行時,相同的 setter 會註冊反向操作作為重做。Apple 在註冊復原操作中示範了這種互相註冊的模式。現行文件也將 UndoManager 及其註冊處理器標示為隔離於主 actor,因此此範例模型使用 @MainActor。請參閱 UndoManager。
將以下完整模型放入 ShapeModel.swift:
import Foundation
import Combine
@MainActor
final class ShapeModel: ObservableObject {
@Published private(set) var center = CGPoint(x: 120, y: 100)
@Published private(set) var canUndo = false
@Published private(set) var canRedo = false
private let history: UndoManager
init() {
let manager = UndoManager()
manager.groupsByEvent = false
history = manager
}
func commitDrag(translation: CGSize) {
let destination = CGPoint(
x: center.x + translation.width,
y: center.y + translation.height
)
guard destination != center else { return }
// This owner expects no caller-owned group.
precondition(history.groupingLevel == 0)
history.beginUndoGrouping()
defer {
history.endUndoGrouping()
refreshAvailability()
}
replaceCenter(with: destination)
}
private func replaceCenter(with destination: CGPoint) {
let previous = center
guard previous != destination else { return }
history.registerUndo(withTarget: self) { target in
target.replaceCenter(with: previous)
}
history.setActionName("Move Shape")
center = destination
}
func undo() {
precondition(history.groupingLevel == 0)
guard history.canUndo else { return }
history.undo()
refreshAvailability()
}
func redo() {
precondition(history.groupingLevel == 0)
guard history.canRedo else { return }
history.redo()
refreshAvailability()
}
private func refreshAvailability() {
canUndo = history.canUndo
canRedo = history.canRedo
}
}
defer 刻意放在與 beginUndoGrouping() 相同的同步範圍內。Swift 會在該範圍結束時執行延後動作,包括提早返回或擲出錯誤的情況。它無法跨越分別呼叫的手勢回呼。這種配對方式可確保模型的群組在一般控制流程出口處保持平衡。請參閱 控制流程 — 延後動作。
復原閉包操作的是其 target 參數,而不是擷取 self。Apple 文件指出,管理器對目標持有非擁有參照,並建議使用提供的參數以避免循環參照。畫布在使用期間會保留模型,而模型則擁有其歷史記錄。請參閱 registerUndo(withTarget:handler:)。
將 app 和畫布放入 ShapeUndoApp.swift:
import SwiftUI
@main
@MainActor
struct ShapeUndoApp: App {
var body: some Scene {
WindowGroup {
ShapeCanvas()
}
}
}
@MainActor
struct ShapeCanvas: View {
@StateObject private var model = ShapeModel()
@GestureState private var previewTranslation: CGSize? = nil
private var drag: some Gesture {
DragGesture(
minimumDistance: 1,
coordinateSpace: .named("board")
)
.updating($previewTranslation) { value, state, _ in
state = value.translation
}
.onEnded { value in
model.commitDrag(translation: value.translation)
}
}
var body: some View {
let translation = previewTranslation ?? .zero
let isDragging = previewTranslation != nil
VStack(spacing: 16) {
ZStack(alignment: .topLeading) {
Color.clear
Circle()
.fill(Color.blue)
.frame(width: 44, height: 44)
.position(
x: model.center.x + translation.width,
y: model.center.y + translation.height
)
.gesture(drag)
}
.frame(width: 420, height: 300)
.background(Color.gray.opacity(0.12))
.coordinateSpace(name: "board")
.clipped()
HStack {
Button("Undo") {
guard previewTranslation == nil else { return }
model.undo()
}
.disabled(isDragging || !model.canUndo)
Button("Redo") {
guard previewTranslation == nil else { return }
model.redo()
}
.disabled(isDragging || !model.canRedo)
}
}
.padding(20)
}
}
此手勢使用一個具名的畫布座標空間,因此位移是相對於畫板測量,而非相對於正在移動的圓形。Apple 將 translation 定義為從手勢起點開始的總位移。將它指定給預覽;不要在移動期間反覆將它加到模型上。請參閱 DragGesture與 translation。
選用的手勢狀態也會指出預覽是否正在進行。預覽期間,兩個本機歷史記錄按鈕都會停用。成功完成時,最終手勢值會提供要提交的位移。取消時,手勢狀態會重設,而儲存的中心點保持不變;沒有開啟的群組需要清理。
此範例刻意只設置一個寫入者。拖曳期間沒有其他命令會變更圓形。如果加入鍵盤移動、刪除或其他編輯命令,請決定拖曳期間是否停用各命令,或先取消預覽。所有命令都應遵循此政策;只停用這兩個按鈕,無法管控額外的命令路徑。
確認一次拖曳、一次復原,以及正常運作的重做
使用示意座標檢查算術。從 (120, 100) 開始,累積位移 (10, 5)、(25, 12) 和 (40, 20) 會產生預覽中心點 (130, 105)、(145, 112) 和 (160, 120)。在完成之前,已提交的中心點會維持在 (120, 100),然後一次變更為 (160, 120)。
復原會以 (120, 100) 呼叫已註冊的 setter。還原它之前,setter 會擷取 (160, 120) 並註冊該位置以供重做。不要在復原回呼中抑制註冊:互相註冊正是保留重做功能的原因。Apple 在註冊復原操作中說明此行為。
程式碼不會在可逆 setter 中建立額外的明確群組。歷史記錄重播期間,UndoManager 會控制分組;Apple 文件指出,redo() 會將重播的操作記錄為復原堆疊上的單一群組。動作名稱透過 setActionName 指定,並使用合成標籤 Move Shape。請參閱 redo()與 setActionName(_:)。
請在 Mac app 中確認以下行為:
- 完成一次含有多次方向變化的拖曳。按一次
Undo應還原至起始位置。 - 選擇
Redo。形狀應回到該次完成拖曳的最終位置。 - 完成兩次分開的拖曳。復原兩次應依相反順序還原到之前的兩個位置。
- 完成一次已辨識的拖曳,但停在起始位置。無變更防護應不建立歷史記錄。
- 在包含此畫布的 app 中測試取消或檢視移除路徑。預覽應消失,而不提交移動。
- 復原一次移動後,再完成一次不同的移動。先前的重做分支應消失;Apple 文件指出,新的復原註冊會清除重做堆疊。
最後一項行為符合 Apple 的復原管理器架構。上述座標計算已透過簡短且具決定性的 Python 計算核對。Swift 原始碼已檢查,但此處未進行編譯或執行,因為此環境沒有 Swift 編譯器或 macOS SDK。
將相同界線套用至 AppKit 畫布
對於現有的 NSView,請保留 AppKit 的事件序列。Apple 現行參考文件將 mouseDragged(with:) 定義為按住滑鼠左鍵時的移動,並將 mouseUp(with:) 定義為放開該按鈕。其封存的事件指南說明自訂檢視所需的三方法序列和座標轉換。請參閱 mouseDragged(with:)、mouseUp(with:)與處理滑鼠事件。
將相同設計對應到這些方法:
- 在
mouseDown(with:)中,對合成形狀進行點擊測試,並儲存其起始中心點,以及轉換為畫布座標的指標位置。 - 在
mouseDragged(with:)中,根據已儲存的指標位置計算位移,並重繪暫態預覽。 - 在
mouseUp(with:)中,計算最終位移、清除追蹤狀態,並呼叫模型的同步提交方法一次。 - 在畫布擁有者的取消或拆除路徑中,捨棄預覽並清除追蹤狀態,但不提交。
互動應由單一事件擁有者處理。如果 SwiftUI 嵌入 AppKit 畫布,請選擇由 SwiftUI 手勢或 AppKit 滑鼠處理器來驅動移動和提交。同時使用兩者註冊相同的移動,會產生重複操作。繪圖座標應保持一致,包括任何翻轉座標系或畫布轉換。
Apple 的封存指南也說明,在這種三方法追蹤方式中可能會派送鍵盤命令。因此,任何選單整合都必須遵循與本機按鈕相同的拖曳期間政策。文件歷史記錄應由文件擁有者管理,並確保選單動作會操作該擁有者的管理器。
診斷分組失敗,但不要關閉其他人的群組
如果一次拖曳產生許多復原步驟,請檢查模型 setter 是否在移動更新期間註冊操作。將這些註冊移至最終提交。如果多次拖曳會一起復原,請檢查管理器的自動分組政策和任何由外層擁有者建立的群組。如果復原後無法重做,請檢查還原路徑是否註冊了反向操作。
如果分組引發例外,請先檢查擁有權,再調整關閉呼叫的次數。Apple 文件指出,groupingLevel == 0 代表沒有開啟任何群組,而當仍有多個群組開啟時,undo() 會引發例外。持續關閉群組直到層級歸零的迴圈,可能會關閉屬於其他呼叫端的群組。請將層級作為診斷依據,並遵守擁有者的契約。請參閱 groupingLevel與 undo()。
對此記憶體內畫布而言,有效的界線是已提交的移動:自由預覽、提交一次、註冊可逆變更,並在返回前關閉群組。如此一來,每次完成的拖曳都會成為一個復原步驟,同時明確處理取消、事件傳遞和歷史記錄擁有權。
來源
UndoManager — Apple
groupsByEvent — Apple
beginUndoGrouping() — Apple
endUndoGrouping() — Apple
onEnded(_:) — Apple
updating(_:body:) — Apple
undoManager — Apple
註冊復原操作 — Apple
控制流程 — Swift 程式語言來源 — Swift.org
DragGesture — Apple
translation — Apple
redo() — Apple
setActionName(_:) — Apple
復原管理器 — Apple
mouseDragged(with:) — Apple
mouseUp(with:) — Apple
處理滑鼠事件 — Apple
groupingLevel — Apple
undo() — Apple