iOSやmacOSのアプリで「元に戻す」や「やり直す」を実装する場合、Foundationフレームワークに用意されている「UndoManager」を使用します。今回は、そのシンプルな実装方法をまとめていきます。

ベージックな実装

UndoManagerを使用したシンプルな実装は、以下の通りです。

import Foundation

let undoManager: UndoManager
var members: NSMutableArray = ["Taro", "Hanako"]

init() {
    // 便宜上ここに記述するが、どこに書いても問題はない
    undoManager = UndoManager()
}

func addMember(name: String) {
    // 通常のオペレーション
    members.add(name)

    // Undo操作をhandlerに登録
    undoManager.registerUndo(withTarget: members) {
        // クロージャの第一引数 $0 は、Targetで指定したmembersのこと
        $0.remove(name)
    }
}

addMember(name: "Jiro")
// member == ["Taro", "Hanako", "Jiro"]

// canUndoプロパティで、元に戻せることをチェックできる
if undoManager.canUndo {
    undoManager.undo()
    // member == ["Taro", "Hanako"]
}

このように、かんたんな実装でUndoを実現することができます。

解説と実装の注意

使用したメソッド

今回のサンプルで使用したメソッドとプロパティは以下の3種類です(.init()を除く)

  • registerUndo(withTarget:handler:)
  • canUndo
  • undo()

たったこれだけで実装できるので、かんたんですね!

registerUndo(withTarget:handler:)

Undo対象となるオブジェクトと、1回分のUndo操作を登録します。 定義は以下のようになっています。

func registerUndo<TargetType>(withTarget target: TargetType, handler: @escaping (TargetType) -> Void) where TargetType : AnyObject

handlerで渡すクロージャの第1引数に、withTargetで指定したオブジェクトが入ってきます。このオブジェクトはunownedのreferenceです。 もとに戻した際に削除or追加する要素は、別途クロージャ内でキャプチャしておく必要があります。(上の例では、addName(name: String)nameが、クロージャ内でも使用されています。)

canUndo

Undo可能かどうかを保持するBoolのプロパティです。

undo()

Undo操作を実行します。

Targetに渡せるのは参照型のみ

UndoManagerは、循環参照を避けるため、targetに渡されたものはunownedの参照で保持します。そのため、structを渡すことができません。たとえば、Arrayをtargetにしたい場合は、それの参照型表現であるNSMutableArrayを使用する必要があります。

詳しくは、以下のドキュメントを参照すると良いでしょう。 https://developer.apple.com/documentation/foundation/undomanager/2427208-registerundo

実装例

私が実際に使用したコードをもとに、実装例を示したいと思います。 実装例で想定するユースケースは、「チームメンバーの名前を、追加・更新・削除する」といったイメージです。私が実際に実装したときのソースコードをもとに、雰囲気が分かる程度に書き直しました。

この例では、NSMutableArrayを使わず、Array(struct)を使った場合の例で参考になるかなと思います。

操作自体をモデル化する

追加・更新・削除と、操作が複数あるため、UndoMangerの実装が各所に散在する可能性があります(実際そうなった)。そのため、まずは操作自体を一つのモデルにしました。

struct Person: Equatable {
    let name: String
}

enum Operation {
    case add(_ newPerson: Person)
    case update(_ before: Person, _ after: Person)
    case delete(_ person: Person)
}

機能を作り込む

追加・更新・削除の機能を作り込んでいきます。 操作を登録するためのregisterOperation(_:)を実装しました。

class MyPresenter {
    private let undoManager: UndoManager
    private var teamMembers: [Person]

    init() {
        undoManager = .init()
        teamMembers = []
    }

    func addPerson(_ newPerson: Person) {
        teamMembers.append(newPerson)
        registerOperation(.add(newPerson))
    }

    func updatePerson(before: Person, after: Person) {
        teamMembers.removeAll { $0 == before }
        teamMembers.append(after)
        registerOperation(.update(before, after))
    }

    func deletePerson(_ person: Person) {
        teamMembers.removeAll { $0 == person }
        registerOperation(.delete(person))
    }
}

private extension MyPresenter {
    func registerOperation(_ op: Operation) {
        undoManager.registerUndo(withTarget: self) { unownedSelf in
            switch op {
            case .add(let addedPerson):
                unownedSelf.deletePerson(addedPerson)
            case .update(let old, let new):
                unownedSelf.updatePerson(before: new, after: old)
            case .delete(let deletedPerson):
                unownedSelf.addPerson(deletedPerson)
            }
        }
    }
}

structをundoするには

今回の実装では、undoManager.registerUndoで、Targetにselfを渡しました。こうすることで、クロージャの中でクラス内のメソッドやプロパティを呼ぶことができるようになります。すなわち、メソッド経由でstructのArrayを操作できるようになります。わざわざNSMutableArrayに切り替えることなく、Undo機能を導入することができます。

おまけ:「やり直す」の実装方法は……

UndoManagerはredo()にも対応しています。 registerUndoをちょっと工夫して書くことになります。

func addMember(name: String) {
    members.add(name)
    undoManager.registerUndo(withTarget: members) {
        $0.remove(name)

        // 以下を追加する(元に戻すを、更に元に戻す)
        undoManager.registerUndo(withTarget: members) {
            $0.add(name)
        }
    }
}

addMember(name: "Jiro")
// member == ["Taro", "Hanako", "Jiro"]

if undoManager.canUndo {
    undoManager.undo()
    // member == ["Taro", "Hanako"]
}

if undoManager.canRedo {
    undoManager.redo()
    // member == ["Taro", "Hanako", "Jiro"]
}

まとめ

UndoManagerを使用して、アプリに「元に戻す」を実装する方法を紹介しました。用意されているAPIがシンプルなので、既存のアプリへの導入もかんたんです。他にも便利なAPIが用意されているので、一度UndoManagerのページを見てみると良いと思います。