はじめに (対象読者・この記事でわかること)
この記事は、SwiftでiOS/macOSアプリを開発している方や、これから始めようとしている方を対象にしています。特に、テキスト編集や描画アプリなどで「元にもどる」機能を実装したい方に最適です。
この記事を読むことで、Swift標準のUndoManagerの使い方、カスタムアクションをUndo/Redo対応にする方法、そして実際に動くサンプルコードまで、一連の流れを習得できます。UIKitでもAppKitでも動作する汎用的なパターンを身につけられるでしょう。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。 - Swiftの基本的な文法(クラス、メソッド、プロトコル) - iOS/macOSアプリ開発の基礎(Xcodeの使い方、ViewControllerの概念)
UndoManagerとは:Swiftにおける「元にもどる」の仕組み
Swiftには、FoundationフレームワークにUndoManagerという強力な仕組みが備わっています。これは、ユーザーの操作を履歴として積み上げ、いつでも「1つ前」「1つ先」にジャンプできるようにするためのクラスです。
ポイントは、「逆操作」を登録しておくこと。例えば「文字を挿入」という操作なら「文字を削除」が逆操作になります。UndoManagerは、この逆操作をスタック形式で保持しており、⌘Z(Undo)や⌘⇧Z(Redo)が押されたタイミングで自動的に呼び出してくれます。
実装編:カスタムアクションをUndo/Redo対応にするまで
ここでは、実際にUndoManagerを使って「円を配置する」シンプルなキャンバスアプリを作りながら、Undo/Redoを実装していきます。UIKitを例に解説しますが、AppKitでも同じ概念が使えます。
ステップ1:UndoManagerを取得する
まずは、UndoManagerのインスタンスを取得します。ViewControllerはデフォルトでundoManagerプロパティを持っているため、それを使います。
Swiftclass CanvasViewController: UIViewController { // タップして円を置くためのビュー private let canvasView = UIView() // 円の中心座標を保持 private var circles: [CGPoint] = [] override func viewDidLoad() { super.viewDidLoad() setupCanvas() } private func setupCanvas() { canvasView.backgroundColor = .systemGray6 view.addSubview(canvasView) // レイアウト設定(省略) // タップジェスチャーを追加 let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) canvasView.addGestureRecognizer(tap) } @objc private func handleTap(_ sender: UITapGestureRecognizer) { let location = sender.location(in: canvasView) addCircle(at: location) } }
ステップ2:円を追加する処理にUndoを登録する
次に、addCircle(at:)メソッドを実装し、ここでUndoManagerに「逆操作」を教えてあげます。
Swiftprivate func addCircle(at center: CGPoint) { // 1. 実際の処理(円を描画) let circle = UIView(frame: CGRect(x: center.x - 20, y: center.y - 20, width: 40, height: 40)) circle.backgroundColor = .systemBlue circle.layer.cornerRadius = 20 canvasView.addSubview(circle) circles.append(center) // 2. UndoManagerに「逆操作」を登録 undoManager?.registerUndo(withTarget: self) { myself in myself.removeCircle(at: center) } } private func removeCircle(at center: CGPoint) { // circles配列から該当の中心座標を削除 circles.removeAll { $0 == center } // 該当のビューを探して削除 canvasView.subviews .first { $0.frame.origin == CGPoint(x: center.x - 20, y: center.y - 20) }? .removeFromSuperview() // Redoのために「逆操作」を登録 undoManager?.registerUndo(withTarget: self) { myself in myself.addCircle(at: center) } }
これだけで、⌘Zで「円を削除」、⌘⇧Zで「円を復活」が可能になります。
ステップ3:メニューバー(Undo/Redoボタン)と連動させる
Undo/Redoボタンをナビゲーションバーに追加して、状態に応じて有効/無効を切り替えましょう。
Swiftoverride func viewDidLoad() { super.viewDidLoad() setupCanvas() setupNavigationItems() // UndoManagerの状態が変わったら通知を受け取る NotificationCenter.default.addObserver( self, selector: #selector(undoManagerDidChange), name: .NSUndoManagerDidUndoChange, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(undoManagerDidChange), name: .NSUndoManagerDidRedoChange, object: nil ) } @objc private func undoManagerDidChange() { updateUndoRedoButtons() } private func setupNavigationItems() { let undoButton = UIBarButtonItem( image: UIImage(systemName: "arrow.uturn.left"), style: .plain, target: self, action: #selector(undo) ) let redoButton = UIBarButtonItem( image: UIImage(systemName: "arrow.uturn.right"), style: .plain, target: self, action: #selector(redo) ) navigationItem.leftBarButtonItems = [undoButton, redoButton] self.undoButton = undoButton self.redoButton = redoButton updateUndoRedoButtons() } @objc private func undo() { undoManager?.undo() } @objc private func redo() { undoManager?.redo() } private func updateUndoRedoButtons() { undoButton?.isEnabled = undoManager?.canUndo ?? false redoButton?.isEnabled = undoManager?.canRedo ?? false }
ハマった点:グルーピングで「まとめてUndo」に失敗した話
複数の変更を1つのUndoとして扱いたい場合、beginUndoGrouping()とendUndoGrouping()を使います。しかし、最初は以下のように書いて失敗しました。
Swift// ❌ 間違い:グループ化した後に個別に登録してしまう undoManager?.beginUndoGrouping() for center in circlesToRemove { removeCircle(at: center) // 内部でregisterUndoを呼んでいる } undoManager?.endUndoGrouping()
これだと、グループ内で個別にregisterUndoが呼ばれるため、グループ化が無意味になってしまいます。
解決策:グループ化内では直接逆操作を登録する
正しい方法は、グループ内では直接逆操作を登録することです。
Swift// ⭕️ 正解:グループ化内では直接逆操作を登録 undoManager?.beginUndoGrouping() let snapshot = circles // 現在の状態をスナップショット // 一度すべて削除 canvasView.subviews.forEach { $0.removeFromSuperview() } circles.removeAll() undoManager?.registerUndo(withTarget: self) { myself in // スナップショットから復元 for center in snapshot { myself.addCircle(at: center) } } undoManager?.endUndoGrouping()
これで、「クリア」ボタンを押しても一度の⌘Zで全削除前の状態に戻せるようになります。
まとめ
本記事では、SwiftのUndoManagerを使って「元にもどる」機能を実装する方法を解説しました。
- UndoManagerは「逆操作」を登録するだけで、複雑な状態管理をしなくても良い
- グループ化を使えば、複数の変更を1つのUndoとして扱える
- UI部品(ボタン)と連動させるには、
NotificationCenterで状態変更を監視する
この記事を通して、iOS/macOSアプリに「元にもどる」機能を実装するハードルがぐっと下がったはずです。次回は、SwiftUIでも同じことができるように、UndoManagerをObservableObjectでラップする方法を紹介する予定です。
参考資料
- Apple Developer Documentation - UndoManager
- SwiftUIでUndo/Redoを実装する - Qiita
- iOSアプリ開発で「元にもどる」を実装する - プログラマーズ雑記帳
