【iOS】自由にUndo/Redoできるテキスト入力欄を作る

[UndoManager](https://developer.apple.com/documentation/foundation/undomanager)を使ってUITextViewにUndo/Redo機能を追加し、保存タイミングの制御も行う例を紹介します。

※ ここで使うUndoManagerは特にUITextView専用というものではなく、UITextFiledその他何にでも使えます
※ 「保存タイミングの制御」とは「1文字ずつ保存」「変換中は記録しない」「10秒に1度保存」などのことです

# コード例
```swift
import UIKit
class MemoEditorViewController: UIViewController {
    // [1] UndoManagerと「直前の内容」（Undo時に戻したい内容）
    var textUndoManager: UndoManager = UndoManager()
    private var editingText: NSAttributedString = NSAttributedString()

    // [2] UITextViewとUndo/Redoボタン
    @IBOutlet weak var textArea: UITextView! {
        didSet { textArea.delegate = self }
    }
    @IBAction func tapUndoButton(_ sender: Any) {
        undo()
    }
    @IBAction func tapRedoButton(_ sender: Any) {
        redo()
    }

    // [5] Undo/Redoの呼び出し
    private func undo() {
        textUndoManager.undo()
        editingText = textArea.attributedText
    }
    private func redo() {
        textUndoManager.redo()
        editingText = textArea.attributedText
    }
    // [3] 直前の内容をUndo登録し、現在の内容をRedo登録する
    func registerUndo(text: NSAttributedString) {
        if textUndoManager.isUndoRegistrationEnabled {
            textUndoManager.registerUndo(withTarget: self, handler: { _ in
                if let currentText = self.textArea.attributedText { self.registerUndo(text: currentText) }
                self.textArea.attributedText = text
            })
        }
    }
}
extension MemoEditorViewController: UITextViewDelegate {
    // [4] 条件を満たした時にUndoを登録
    func textViewDidChange(_ textView: UITextView) {
        if (textView.markedTextRange == nil) {
            registerUndo(text: editingText)
            editingText = textArea.attributedText
        }
    }
}
```

## [1] UndoManagerと「直前の内容」（Undo時に戻したい内容）
Undo/Redoの記録には、UndoManagerに「現在の内容」と「直前の内容」を渡す必要があります。
ので、UndoManagerと「直前の内容」の変数を用意しましょう。
「現在の内容」はUITextViewから直接とれるので不要。

```swift
    var textUndoManager: UndoManager = UndoManager()
    private var editingText: NSAttributedString = NSAttributedString()
```

## [2] UITextViewとUndo/Redoボタン
Undo/Redo用ボタン2つとUITextViewを用意。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272677/3f1d5f9b-7ead-ba5a-ead0-954b5823af17.png)
今回はStoryboardを使うのでOutlet。
ついでにTextViewのdelegateをセットし、ボタンの呼び出し`undo()` / `redo()`も仮で書いておきましょう。

```swift
    @IBOutlet weak var textArea: UITextView! {
        didSet { textArea.delegate = self }
    }
    @IBAction func tapUndoButton(_ sender: Any) {
        undo()
    }
    @IBAction func tapRedoButton(_ sender: Any) {
        redo()
    }
```

## [3] 直前の内容をUndo登録し、現在の内容をRedo登録する
UndoManagerに、「直前の内容」に戻すというUndoを登録します。
さらにUndo時にそのUndo（つまりRedo）を登録します。ここでは「現在の内容」に戻すという操作です。
https://developer.apple.com/documentation/foundation/undomanager

```swift
    // 引数には「直前の内容」を渡す
    func registerUndo(text: NSAttributedString) {
        if textUndoManager.isUndoRegistrationEnabled {
             // Undo時にやることを登録
            textUndoManager.registerUndo(withTarget: self, handler: { _ in
                 // Undo中のUndo登録はRedo登録となる（「現在の内容」に戻すという操作を登録）
                if let currentText = self.textArea.attributedText { self.registerUndo(text: currentText) }
                 // 「直前の内容」に戻す
                self.textArea.attributedText = text
            })
        }
    }
```

## [4] 条件を満たした時にUndoを登録
Undoを登録する条件を決めます。
この例は[UITextViewDelegate](https://developer.apple.com/documentation/uikit/uitextviewdelegate/1618599-textviewdidchange)を使い、内容が変更されたら登録するというオーソドックスな方法です。
保存したら「直前の内容」も更新します。

```swift
    func textViewDidChange(_ textView: UITextView) {
        if (textView.markedTextRange == nil) {
            registerUndo(text: editingText)
            editingText = textArea.attributedText
        }
    }
```
`markedTextRange == nil`は「テキストが選択状態でない」ですが、ここでは「日本語などの変換中ではない」という意味合いで使っています。
これで変換中はUndoを保存しません。

`registerUndo()`を他の方法で呼び出せば自由なUndo登録が可能です。
ボタンを設置したり、10秒に1回にしたり、変更カウンターを作って10溜まったらなど。

## [5] Undo/Redoの呼び出し
Undo/Redoボタンを完成させます。
UndoManagerの`undo()` / `redo()`を呼ぶだけ。このとき「直前の内容」も更新。

```swift
    private func undo() {
        textUndoManager.undo()
        editingText = textArea.attributedText
    }
    private func redo() {
        textUndoManager.redo()
        editingText = textArea.attributedText
    }
```
UndoManagerが動作そのものを記録/呼出する仕様であったお陰で、呼び出しはえらく簡単に終わりました。

# おわりに
他にも[canUndo](https://developer.apple.com/documentation/foundation/undomanager/1412394-canundo)などを使ってボタン有効性の表示を切り替えたり、長押しで早送り可能にするなど色々やりこみ要素があります。
この記事が導入の助けになれば幸いです。