ソフトウェア設計

SwiftのコマンドパターンでUndo機能を実装する

アプリを作っていると、「Undoボタンを1つだけ追加してほしい」という依頼は、思った以上によくあります。

読了 5 分
SwiftのコマンドパターンでUndo機能を実装するのカバー画像

アプリを作っていると、「Undoボタンを1つだけ追加してほしい」という依頼は、思った以上によくあります。

いざUndoを追加すると、コードは簡単にスパゲッティ化します。1つのボタンのために、削除・移動・編集の各所へ取り消し処理を散らしてしまうからです。

これを整理してくれるのが、まさにSwiftのコマンドパターンです。

この記事では、なぜコマンドパターンがUndo機能の土台になるのか、そして実際に使える最小限のコード構成まで解説します。難しい理論ではなく、「今すぐプロジェクトに組み込める形」に焦点を当てます。

コマンドパターンとは? 一言でまとめると

まずは一言で説明しましょう。

コマンドパターンは、「何かをする」という操作そのものをオブジェクトに包み、実行と取り消しを1つの単位にまとめる設計手法です。

ボタンを押した瞬間にロジックを実行するのではなく、「この操作を実行せよ」という命令書(Commandオブジェクト)を作ります。

その命令書には、実行方法(execute)と元に戻す方法(undo)の両方が含まれます。

まとめると、次のとおりです。

  1. 操作をオブジェクトにする—削除・移動・入力をそれぞれ1つのコマンドにする
  2. 各コマンドはexecute()とundo()を対で持つ
  3. 実行したコマンドをスタックに積む
  4. スタックから1つ取り出してundo()を呼べばUndo完了

この構造により、取り消し処理が各所に散らばらず、それぞれのコマンド内にまとまります。


Swiftでコマンドプロトコルから作ってみましょう

まず、すべての命令書が守る契約、つまりプロトコルを定義します。

以下は実行と取り消しを強制する最小構成です。

protocol Command {
    func execute()   // 操作を実行
    func undo()      // 実行を取り消す
}

この2つのメソッドだけで十分です。

次に、実際の操作を持つ具体的なコマンドを作ります。例として、テキストを追加する命令を見てみましょう。

以下のコードは、「文字を追加し、取り消すと再び取り除く」コマンドです。

final class AddTextCommand: Command {
    private let document: Document
    private let text: String

    init(document: Document, text: String) {
        self.document = document
        self.text = text
    }

    func execute() { document.content += text }
    func undo()    { document.content.removeLast(text.count) }
}

executeは文字を追加し、undoは追加した分だけ取り除きます。実行と取り消しが1つのオブジェクトにまとまっていることが、このパターンのすべてです。

Commandプロトコルはたった2行から始められます
Commandプロトコルはたった2行から始められます

Undoの核心はスタックです

コマンドを作ったら、それを管理する「管理者」が必要です。一般にInvokerと呼ばれる役割です。

管理者は実行したコマンドをスタックに順番に積みます。

Undoを押したら、最後に積んだコマンドを取り出してundo()を呼びます。後から行った操作から先に戻すことが、Undoの自然な順序です。

以下がその管理者の骨格です。

final class CommandManager {
    private var undoStack: [Command] = []

    func run(_ command: Command) {
        command.execute()
        undoStack.append(command)   // 実行後にスタックへ保存
    }

    func undo() {
        guard let last = undoStack.popLast() else { return }
        last.undo()                 // 最後の項目から取り消す
    }
}

使い方はとてもシンプルになります。

buttonTappedでmanager.run(command)を呼び、Undoボタンではmanager.undo()だけを呼べば構いません。

取り消しの順序やスタック管理はCommandManagerが処理するため、画面のコードをきれいに保てます。

3つのCommand要素はこのように連携します
3つのCommand要素はこのように連携します

Redoも必要なら、UndoしたコマンドをredoStackに別途保存します。構造が対称なので、拡張も難しくありません。


コマンドパターンはいつ使い、いつ避けるべき?

コマンドパターンも万能ではありません。向いているケースと過剰なケースを表にまとめました。

状況 コマンドパターン
Undo・Redoが必要 非常に適している
操作履歴を残す必要がある 適している
操作をキューに入れて後で実行する 適している
戻す必要のない単純なボタン1つ 過剰。普通の関数でよい

コマンドパターンが力を発揮するのは、取り消しや履歴が必要な場面です。ペイントアプリ、テキストエディタ、決済の取り消し、ゲームのUndoなどが代表例です。

一方、単に画面を移動するだけのボタンにコマンドパターンを適用すると、コードが増えるだけです。

すべてのボタンをコマンドにするとファイルばかり増えやすいため、取り消しが必要な操作にだけ適用しましょう。

一番上のカードから1枚ずつ取り出して戻す構造です
一番上のカードから1枚ずつ取り出して戻す構造です

まとめ

Undo機能は追加するまでが大変に感じますが、コマンドパターンという土台を1つ用意すれば、思ったよりきれいに動かせます。

今日整理したプロトコル・具体的なコマンド・マネージャーの3要素を、まずは小さな画面に組み込んでみてください。構造に慣れれば、Redoや履歴への拡張も自然に行えます。あなたのアプリにも、自信を持ってUndoボタンを追加してみましょう。

あわせて読みたい