Swift：実践的なUndoManagerの使い方

何かしらの編集ソフトの場合，ヒストリが欲しくなるが，UndoManagerを用いると楽にUndoとRedoができる．今回は実践的な最小規模の実装例をまとめる．

# 想定

- `items`という文字列を格納する配列がある
- `items`の末尾に新規の文字列を追加できる
- `items`の末尾を削除できる
- `items`の状態変化に伴うUndo/Redoができるようにしたい

# 要点

- `UndoManager.registerUndo()`を用いて一つ前の状態に戻すための処理を登録する．これに関してはUndoであるかRedoであるかは関係ないためregisterRedoは存在しない．(すなわちRedoはUndoをUndoすることでできている)
- `UndoManager.undo()`でUndoの処理が発火される．
- `UndoManager.redo()`でRedoの処理が発火される．
- 変化が定量的である場合は元に戻す処理を定量的に書けば良い（[参考](https://qiita.com/m_yukio/items/311afad894c6d67fd4a3)）．
- 変化が定量的でない場合は配列を用いてそれぞれのヒストリでの状態を保持する必要がある．

# デモ

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/67ade47d-279a-1965-116a-a49722338fa3.gif)

# 解説付きのソースコード

```swift
import Cocoa

struct Stock {
    var items = [String]()
    
    var count: Int {
        return items.count
    }
    
    var text: String {
        items.joined(separator: ", ")
    }
    
    mutating func add(_ item: String) {
        items.append(item)
    }
    
    mutating func remove() {
        items.removeLast()
    }
}

// itemsに関してヒストリを管理する例
class ViewController: NSViewController {

    // itemsの状態を表示するためのラベル
    @IBOutlet weak var label: NSTextField!
    
    let um = UndoManager()
    // ヒストリのデータベース
    var stocks = [Stock]()
    // 現在のヒストリの位置（カウンター）
    var current: Int = -1
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    // Labelに現在のitemsの状態を出力する
    func output() {
        if 0 <= current {
            label.stringValue = stocks[current].text
        } else {
            label.stringValue = ""
        }
    }
    
    // 現在のヒストリ以降を削除して，1つヒストリを進める
    // すなわち，現在の状態を残しておいて，変化した分を新しいヒストリとしてストックする
    func addStock() {
        if stocks.isEmpty {
            stocks.append(Stock())
        } else {
            stocks.removeSubrange(current + 1 ..< stocks.count)
            if current < 0 {
                stocks.append(Stock())
            } else {
                // 構造体なのでコピーが渡される
                stocks.append(stocks[current])
            }
        }
        current += 1
        // 元に戻したitemsを参照するためのコードを呼び出す登録
        um.registerUndo(withTarget: self) { [weak self] _ in
            self?.backStock()
        }
    }
    
    // ヒストリを1つ進める
    func goStock() {
        if current < stocks.count - 1 {
            // ヒストリの上限に達していなければカウンターを増やす
            current += 1
            // 元に戻したitemsを参照するためのコードを呼び出す登録
            um.registerUndo(withTarget: self) { [weak self] _ in
                self?.backStock()
            }
        }
    }
    
    // ヒストリを1つ戻す
    func backStock() {
        if 0 <= current {
            // ヒストリの下限に達していなければカウンターを減らす
            current -= 1
            // 元に戻したitemsを参照するためのコードを呼び出す登録
            um.registerUndo(withTarget: self) { [weak self] _ in
                self?.goStock()
            }
        }
    }
    
    // itemsの末尾に要素を1つ追加
    // （Addボタンに繋いでおく）
    @IBAction func add(_ sender: Any) {
        addStock()
        // 簡単のため追加する文字列は現在のアイテム数とする
        stocks[current].add(String(stocks[current].count))
        output()
    }

    // itemsから末尾の要素を1つ削除
    // （Removeボタンに繋いでおく）
    @IBAction func remove(_ sender: Any) {
        if 0 < stocks[current].count {
            addStock()
            stocks[current].remove()
            output()
        }
    }
    
    // Undoがコールされたときの処理
    // （Undoボタンに繋いでおく）
    @IBAction func undo(_ sender: Any) {
        um.undo()
        output()
    }
    
    // Redoがコールされたときの処理
    // （Redoボタンに繋いでおく）
    @IBAction func redo(_ sender: Any) {
        um.redo()
        output()
    }

}
```