在 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 中確認以下行為:

  1. 完成一次含有多次方向變化的拖曳。按一次 Undo 應還原至起始位置。
  2. 選擇 Redo。形狀應回到該次完成拖曳的最終位置。
  3. 完成兩次分開的拖曳。復原兩次應依相反順序還原到之前的兩個位置。
  4. 完成一次已辨識的拖曳,但停在起始位置。無變更防護應不建立歷史記錄。
  5. 在包含此畫布的 app 中測試取消或檢視移除路徑。預覽應消失,而不提交移動。
  6. 復原一次移動後,再完成一次不同的移動。先前的重做分支應消失;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()。

對此記憶體內畫布而言,有效的界線是已提交的移動:自由預覽、提交一次、註冊可逆變更,並在返回前關閉群組。如此一來,每次完成的拖曳都會成為一個復原步驟,同時明確處理取消、事件傳遞和歷史記錄擁有權。

來源