【Android】テキストエディタ（EditText）にアンドゥ・リドゥ機能を付ける

# 概要
Androidアプリの`EditText`で、アンドゥ（元に戻す）・リドゥ（やり直し）ができるようにしたかった。しかし、標準ライブラリではアンドゥ・リドゥ機能は用意されていないらしい（多分）。
アンドゥ・リドゥを実装する方法を調べた。

# アンドゥ・リドゥを実現する方法
`android.text`パッケージに[`TextWatcher`](https://developer.android.com/reference/kotlin/android/text/TextWatcher.html)というインターフェースが用意されているため、それを利用して実装する。
## `TextWatcher`とは
> When an object of this type is attached to an Editable, its methods will be called when the text is changed.

[`Editable`](https://developer.android.com/reference/kotlin/android/text/Editable)にこの型のオブジェクトが付いているとき、テキストが変更されたときに`TextWatcher`のメソッドが呼ばれる。
呼ばれるメソッドは以下の3つである。`TextWatcher`はインターフェースであるため、3つともオーバーライドする必要がある。

- `beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int)`  
`s`中の`start`番目から始まる`count`個の文字が、長さ`after`の新しいテキストに置き換えられようとしているときに呼ばれるメソッド
- `onTextChanged(s: CharSequence, start: Int, before: Int, count: Int)`  
`s`中の`start`番目から始まる`count`個の文字が、長さ`before`の古いテキストを置き換えたときに呼ばれるメソッド
- `afterTextChanged(s: Editable)`  
`s`中のどこかで、テキストが変更されたときに呼ばれるメソッド

```UndoTextWatcher.kt
class UndoTextWatcher : TextWatcher {
    override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
        // テキスト変更が起こる直前の処理（undo用の文字列保持など）
    }

    override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
        // テキスト変更が起きた直後の処理（undoしたときに置き換える箇所の取得・保持など）
    }

    override fun afterTextChanged(s: Editable) {
        // テキストが変更された後の処理（undo・redoの可否判定など）
    }
}
```

例えば「あいうえお」→「あいうえおかきくけこ」の場合は以下のようになる。

```text:log
System.out: beforeTextChanged // （「お」の直後の）0文字が5文字のafterに置き換えられようとしている
System.out: s: あいうえお, start: 5, count: 0, after: 5

System.out: onTextChanged // 「か」から5文字が0文字のbeforeを置き換えた
System.out: s: あいうえおかきくけこ, start: 5, before: 0, count: 5

System.out: afterTextChanged // 「あいうえおかきくけこ」になった
System.out: s: あいうえおかきくけこ
```

## 挙動を実装する
まず、`undoing`（アンドゥ・リドゥ実行中かどうか）・`undos`（アンドゥのリスト）・`redos`（リドゥのリスト）と、`before`や`after`を保持するクラスを用意しておく。

```UndoTextWatcher.kt
class EditEvent {
    private val beforePosition: Int // 置き換えられる文字列の開始位置
    private val before: CharSequence // 置き換えられる文字列
    private var afterPosition: Int = 0 // 置き換えた文字列の開始位置
    private var after: CharSequence = "" // 置き換えた文字列

    constructor(beforePosition: Int, before: CharSequence) {
        this.beforePosition = beforePosition
        this.before = before
    }

    fun setAfter(afterPosition: Int, after: CharSequence) {
        this.afterPosition = afterPosition
        this.after = after
    }

    fun undo(editable: Editable) {
        editable.replace(afterPosition, afterPosition + after.length, before)
    }

    fun redo(editable: Editable) {
        editable.replace(beforePosition, beforePosition + before.length, after)
    }
}

class UndoTextWatcher : TextWatcher {
    var undoing = false // undo・redo実行中かどうか
    val undos = LinkedList<EditEvent>() // undoリスト
    val redos = LinkedList<EditEvent>() // redoリスト

    override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
        // テキスト変更が起こる直前の処理（undo用の文字列保持など）
    }

    override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
        // テキスト変更が起きた直後の処理（undoしたときに置き換える箇所の取得・保持など）
    }

    override fun afterTextChanged(s: Editable) {
        // テキストが変更された後の処理（undo・redoの可否判定など）
    }
}
```

### `beforeTextChanged`でやること
[0] （`undoing`が`true`のとき、何もしない。）
[1] `start`と、元文字列`s`の`start`から`count`文字（==置き換えられる文字列）を`undos`に追加する。
[2] `redos`が空でない場合、`redos`を空にする。

```UndoTextWatcher.kt
class UndoTextWatcher : TextWatcher {
    override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
        // テキスト変更が起こる直前の処理（undo用の文字列保持など）
        if (undoing) return // [0]
        val event = EditEvent(start, s.subSequence(start, start + count)) // undoする文字列と位置を保持する
        undos.addLast(event) // [1]
        clearRedos() // [2]
    }

    private fun clearRedos() {
        while (!redos.isEmpty()) {
            redos.removeFirst()
        }
    }
    ...(略)...
}
```

### `onTextChanged`でやること
[0] （`undoing`が`true`のとき、何もしない。）
[1] `beforeTextChanged`で`undos`に追加した要素に、`start`と、新文字列`s`の`start`から`count`文字（==置き換えた文字列）の情報を追加する。

```UndoTextWatcher.kt
class UndoTextWatcher : TextWatcher {
    ...(略)...
    override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
        // テキスト変更が起きた直後の処理（undoしたときに置き換える箇所の取得・保持など）
        if (undoing) return // [0]
        val event = undos.getLast() // undoリストに最後に追加した要素
        event.setAfter(start, s.subSequence(start, start + count)) // [1]
    }
    ...(略)...
}
```

### アンドゥボタンが押されたときにやること
[0] （`undos`が空の場合何もしない。）
[1] `undos`から最新の要素を取り出して、`redos`に追加する。
[2] `undoing`を`true`にする。
[3] 文字列全体の`afterPosition`から`after`の長さ分の文字列（つまり`after`）を、`before`に置き換える。
[4] `undoing`を`false`にする。

```UndoTextWatcher.kt
class UndoTextWatcher : TextWatcher {
    ...(略)...
    fun undo(editable: Editable) {
        if (undos.isEmpty()) return // [0]
        val event = undos.removeLast() // [1]
        redos.addLast(event) // [1]
        undoing = true // [2]
        try {
            event.undo(editable) // [3]
        } finally {
            undoing = false // [4]
        }
    }
}
```

### リドゥボタンが押されたときにやること
[0] （`redos`が空の場合何もしない。）
[1] `redos`から最新の要素を取り出して、`undos`に追加する。
[2] `undoing`を`true`にする。
[3] 文字列全体の`beforePosition`から`before`の長さ分の文字列（つまり`before`）を、`after`に置き換える。
[4] `undoing`を`false`にする。

```UndoTextWatcher.kt
class UndoTextWatcher : TextWatcher {
    ...(略)...
    fun redo(editable: Editable) {
        if (redos.isEmpty()) return // [0]
        val event = redos.removeLast() // [1]
        undos.addLast(event) // [1]
        undoing = true // [2]
        try {
            event.redo(editable) // [3]
        } finally {
            undoing = false // [4]
        }
    }
}
```
これでアンドゥ・リドゥ機能の挙動の実装は完成した。

## `EditText`で使えるようにする
実際にアプリの画面でアンドゥ・リドゥできるようにする。
`EditText`・undoボタン・redoボタンは適当に用意しておく。

```Fragment.kt
val textWatcher = UndoTextWatcher()
editText.addTextChangedListener(textWatcher) // undo・redo機能を付けたTextWatcherをEditTextにセットする
undoButton.setOnClickListener {
    textWatcher.undo(editText.text) // undoボタンを押してundoする
}
redoButton.setOnClickListener {
    textWatcher.redo(editText.text) // redoボタンを押してredoする
}
```

これで、アプリ上でアンドゥ・リドゥを実行できるようになる。

## その他
### アンドゥ・リドゥできるか判定する
これ以上アンドゥできない時にボタンを無効にしたいなどの場合に、アンドゥできるかどうかを判定する。

![スクリーンショット 2020-02-14 18.38.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124752/a6dd6758-565a-304d-544e-c628b6b5b36d.png)
↑アンドゥボタンが無効になるようにした

```Fragment.kt
class UndoTextWatcher : TextWatcher {
    ...(略)...
    override fun afterTextChanged(s: Editable) {
        hasUndos = !undos.isEmpty() // undoできるか（undoリストが空でないか）
        hasRedos = !redos.isEmpty() // redoできるか（redoリストが空でないか）
    }
}

if (!hasUndos) {
    // ボタン無効などの処理
}
if (!hasRedos) {
    // ボタン無効などの処理
}
```

# まとめ
- `TextWatcher`を利用して置き換える文字列や位置を取得し、アンドゥ・リドゥ機能を実装した。
- `EditText.addTextChangedListener()`で`TextWatcher`をセットすると、アプリ上でアンドゥ・リドゥが使えるようになる。

# 参考
https://github.com/tkojitu/UndoDemo