Macアプリ内で図形をドラッグする際は、ジェスチャー中に移動をプレビューし、最終位置を1つの短いUndoグループとして確定します。モデルとUndoマネージャーを1つの所有者のもとに置き、すべてのグループ開始に終了処理を対応させ、逆方向の移動も登録してRedoも機能させます。

このチュートリアルでは、メモリ内に合成した円を1つだけ表示する小さなSwiftUIキャンバスを作成します。具体的な課題は、移動更新が何回届いても、ドラッグを1回完了したときのUndoが必ず1回で済むようにすることです。また、AppKitビューでも同じ境界を保つ方法を説明します。

ドラッグ中にUndoグループを開いたままにしない理由

ドラッグは時間をかけて進む操作です。Undoグループは、元に戻せる操作をまとめます。この2つの境界の存続期間を同じにする必要はありません。Appleのドキュメントによると、groupsByEvent の既定値は true で、イベントループの各周回を基準に操作をグループ化します。この動作だけでは、ドラッグ全体のトランザクションは定義されません。明示的なグループは既存のグループ内にネストすることもできます。イベントの自動グループ化とUndoグループの開始に関するAppleのドキュメントを参照してください。

最初のSwiftUI移動コールバックでグループを開き、onEnded で閉じると、キャンセル時に隙間が生じます。Appleは、onEnded がジェスチャーの正常終了時にのみ実行されると明記しています。一方、updating を通じて維持される状態は、ジェスチャーの終了時またはキャンセル時にリセットされます。対応する endUndoGrouping() がないと例外が発生するため、コールバックがグループを開いたかどうかを推測する方法も適切ではありません。onEnded(_:)、updating(_:body:)、endUndoGrouping()を参照してください。

このキャンバスでは、次の設計を使います。これは、ドキュメントに記載された境界に基づく実装上の選択です。

操作の段階 表示状態 確定済みモデル Undoグループ化
ドラッグ前 保存された中心座標 元の中心座標 閉じている
移動更新中 元の中心座標と一時的な移動量 変更なし 閉じている
正常終了 最終中心座標 1回だけ更新 同期的に開始して終了
キャンセル プレビューをリセット 変更なし 開始しない
UndoまたはRedo 復元された中心座標 取り消し可能なセッター経由で更新 UndoManager が管理

この方法なら、プレビューの各フレームを永続的な編集として扱わずに円を連続して動かせます。完了したドラッグは、名前の付いた1つのモデル操作になります。

Undo履歴の所有者を決める

このサンプルでは、各キャンバスがそれぞれのモデルとプライベートなUndoマネージャーを持ちます。登録をすべてその所有者が管理するため、初期化時に groupsByEvent = false を一度だけ設定し、確定した各移動を明示的にグループ化します。自動グループ化が無効な場合、Undoの前にグループを明示的に閉じる必要があるとAppleは定めています。このサンプルでは、確定メソッドから戻る前にグループを閉じます。groupsByEventを参照してください。

既存のドキュメントエディターには、すでにドキュメント用のUndoマネージャーがあるかもしれません。SwiftUIは @Environment(\.undoManager) を通じてコンテキストに応じたマネージャーを公開します。Appleのドキュメントによると、コンテキストがUndoに対応していない場合、この値は nil になることがあります。そのアーキテクチャでは、引き続きドキュメントのマネージャーと、そこで確立されているグループ化方針を使ってください。このサンプルのプライベートマネージャー向け設定を共有マネージャーに適用しないでください。EnvironmentValues.undoManagerを参照してください。

共有マネージャーで自動グループ化が有効な場合、完了時に逆方向の移動を1つ登録すれば、移動のたびに履歴項目ができるのを防げます。明示的なサブグループで移動をまとめることはできますが、外側のグループから独立させることはできません。他の編集が同じ外側のグループに含まれていれば、それらも一緒に取り消されます。独立したトップレベルのトランザクションを保証するには、子ビューからグループを閉じるのではなく、共有履歴の所有者が調整する必要があります。

SwiftUIの完全な例を作成する

macOS 14以降をデプロイメントターゲットとするmacOS SwiftUIアプリプロジェクトを作成します。生成されたアプリ宣言とコンテンツビューを、以下の2つのファイルに置き換えます。@main 宣言は1つだけにしてください。このサンプルでは、所有者を明確にするためにローカルの Undo ボタンと Redo ボタンを使います。アプリケーションメニューやレスポンダーチェーンとの連携は別途組み込んでください。

モデルでは、逆操作可能なセッターを使います。現在の位置を取得し、それを復元するクロージャを登録してから、新しい位置を適用します。Undo中にそのクロージャが実行されると、同じセッターが逆操作をRedoとして登録します。AppleはUndo操作の登録で、この相互登録パターンを紹介しています。現在のドキュメントでは、UndoManager とその登録ハンドラーもメインアクター分離として指定されているため、このサンプルのモデルでは @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は、早期リターンやエラーのスローがあっても、スコープを抜けるときに遅延アクションを実行します。別々に呼び出されるジェスチャーコールバックをまたいで実行することはできません。この対応関係により、通常の制御フローの終了時にモデルのグループが適切に閉じられます。制御フロー — 遅延アクションを参照してください。

Undoクロージャは、self をキャプチャするのではなく、target 引数に対して操作します。Appleのドキュメントによると、マネージャーはターゲットへの非所有参照を保持します。また、保持サイクルを避けるために、渡された引数を使うことを推奨しています。使用中はキャンバスがモデルを保持し、モデルが履歴を所有します。registerUndo(withTarget:handler:)を参照してください。

アプリとキャンバスを 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)
    }
}

ジェスチャーでは名前付きのキャンバス座標空間を1つ使うため、移動量は動いている円ではなくボードを基準に測定されます。Appleの定義では、translation はジェスチャー開始時からの合計移動量です。プレビューに代入し、移動中にモデルへ繰り返し加算しないでください。DragGestureとtranslationを参照してください。

任意のジェスチャー状態は、プレビューが有効かどうかも示します。プレビュー中は、ローカルの履歴ボタンがどちらも無効になります。正常終了時は、ジェスチャーの最終値から確定する移動量を取得します。キャンセル時はジェスチャー状態がリセットされ、保存された中心座標は変わりません。後始末が必要な開いたグループもありません。

この例では、書き込み元を意図的に1つにしています。ドラッグ中に他のコマンドが円を変更することはありません。キーボードによる移動、削除、その他の編集コマンドを追加する場合は、ドラッグ中に各コマンドを使えなくするか、まずプレビューをキャンセルするかを決めてください。すべてのコマンドをその方針に従わせます。この2つのボタンを無効にするだけでは、他のコマンド経路を制御できません。

1回のドラッグ、1回のUndo、正常なRedoを確認する

計算を確認するため、例として座標を使います。(120, 100) から始めると、累積移動量 (10, 5)、(25, 12)、(40, 20) によって、プレビュー上の中心座標は (130, 105)、(145, 112)、(160, 120) になります。完了するまでは、確定済みの中心座標は (120, 100) のままです。その後、1回だけ (160, 120) に変わります。

Undoでは、登録したセッターが (120, 100) を引数に呼び出されます。復元する前に、セッターは (160, 120) を取得し、その位置をRedo用に登録します。Undoコールバック内で登録を抑制しないでください。相互登録によってRedoが維持されます。AppleはUndo操作の登録でこの動作を説明しています。

取り消し可能なセッターの中では、追加の明示的なグループを作成しません。履歴の再生中のグループ化は UndoManager が制御します。Appleのドキュメントによると、redo() は再生された操作をUndoスタックに1つのグループとして記録します。アクション名は setActionName で設定し、合成ラベル Move Shape を使います。redo()とsetActionName(_:)を参照してください。

Macアプリで次の動作を確認します。

  1. 方向を何度か変えながらドラッグを1回完了します。Undo を1回実行すると、開始位置に戻るはずです。
  2. Redo を選びます。図形が、完了したドラッグの最終位置に戻るはずです。
  3. 別々のドラッグを2回完了します。Undoを2回実行すると、2つ前の位置に逆順で戻るはずです。
  4. 認識されたドラッグを開始位置で終了します。変更がない場合のガードにより、履歴項目は作成されないはずです。
  5. アプリ内でキャンセルまたはビュー削除の経路を試します。確定済みの移動は発生せず、プレビューが消えるはずです。
  6. 移動をUndoしてから別の移動を完了します。以前のRedo分岐は消えるはずです。新しいUndo登録によってRedoスタックがクリアされるとAppleは説明しています。

最後の動作は、AppleのUndoマネージャーのアーキテクチャに従っています。上記の座標計算は、小さな決定論的Python計算で確認しました。SwiftコンパイラーとmacOS SDKがないため、ここではSwiftソースを確認しましたが、コンパイルも実行もしていません。

AppKitキャンバスでも同じ境界を適用する

既存の NSView では、AppKitのイベント順序を保ちます。Appleの現在のリファレンスでは、mouseDragged(with:) は左ボタンを押した状態での移動、mouseUp(with:) はそのボタンを離す操作と定義されています。アーカイブ版のイベントガイドでは、カスタムビューに必要な3つのメソッドからなるシーケンスと座標変換について説明しています。mouseDragged(with:)、mouseUp(with:)、マウスイベントの処理を参照してください。

同じ設計をこれらのメソッドに対応づけます。

  • mouseDown(with:) では、合成図形をヒットテストし、開始時の中心座標と、キャンバス座標に変換したポインター位置を保存します。
  • mouseDragged(with:) では、保存したポインター位置からの移動量を計算し、一時的なプレビューを再描画します。
  • mouseUp(with:) では、最終的な移動量を計算し、追跡状態をクリアしてから、モデルの同期的な確定メソッドを1回呼び出します。
  • キャンバス所有者のキャンセルまたは終了処理では、確定せずにプレビューを破棄し、追跡状態をクリアします。

操作を担当するイベント所有者は1つにしてください。SwiftUIがAppKitキャンバスを埋め込む場合、SwiftUIのジェスチャーとAppKitのマウスハンドラーのどちらかを選び、移動と確定を行わせます。両方で同じ移動を登録すると、操作が重複します。反転座標系やキャンバス変換を含め、描画座標に一貫性を持たせてください。

Appleのアーカイブ版ガイドでは、3つのメソッドからなる追跡処理の途中でキーボードコマンドがディスパッチされる場合があることも説明しています。そのため、メニューとの連携でもローカルボタンと同じドラッグ中の方針を守る必要があります。ドキュメントの履歴はドキュメント所有者に保持させ、メニューアクションがその所有者のマネージャーを参照するようにしてください。

他の所有者のグループを閉じずにグループ化の失敗を診断する

ドラッグが多くのUndo段階を作る場合は、移動更新中にモデルのセッターが登録を行っていないか確認します。登録を最終確定時に移してください。複数回のドラッグがまとめてUndoされる場合は、マネージャーの自動グループ化方針と、外側の所有者が作成したグループを調べます。Undo後にRedoできない場合は、復元処理が逆操作を登録しているか確認します。

グループ化で例外が発生する場合は、終了呼び出しの回数を変える前に所有関係を調べます。Appleのドキュメントによると、groupingLevel == 0 は開いているグループがないことを示し、undo() は開いているグループが2つ以上残っていると例外を発生させます。グループ化レベルがゼロになるまで閉じるループは、別の呼び出し元が所有するグループまで閉じる可能性があります。レベルは診断に使い、所有者の契約を守ってください。groupingLevelとundo()を参照してください。

このメモリ内キャンバスでは、確定した移動を境界とします。自由にプレビューし、1回だけ確定し、取り消し可能な変更を登録してから、戻る前にグループを閉じます。これにより、キャンセル、イベントの配信、履歴の所有権を明確に保ちながら、完了した各ドラッグを1回のUndo操作にできます。

出典