見出し画像

note iOSアプリにUndo機能を実装! #iOSDC #LT

最近 note iOSアプリにUndoを実装しました。まことに申し訳ないのですが、その動作はまだ全然安定していません。

だいぶカイゼンしたのですが、かなり不安定なRedoについては一旦取り下げることにしました。
さて、なんでUndo/Redoがややこしくて、実装がうまくいっていないのかを言い訳していきたいと思います。

iOSDC 2024 LT

iOSDCで発表してきました。LTなのでざっくりとこの記事をまとめたものになります。


UndoManager

UndoManager は操作を記録して、その操作をもう一度実行するための仕組みです。
iOS 3.0から使えるスタック構造で、Undo Redoを、どんどん登録して使えるものになります。

登録できる操作には制限はなくかなり自由になんでもできる仕組みです。
登録は次のような感じでできます。

undoManager?.registerUndo(withTarget: self) { target in
    target.undoSomthing()
}

そして、undo は

undoManager?.undo()

でできます。
これらを各所にいい感じに埋め込んでいって,いい感じにundo()を呼び出してあげれば,「あら簡単、Undoができました」となります。

UndoManagerはスタックで管理しているので、最後に追加されたタスクが最初にundo()で取り出されます。このスタック構造を工夫してUndoを便利に呼び出せるようになります。

UndoManagerはスタック

Redoもできるよ

UndoがあればRedoもあります。Undo処理のなかでさらにUndoを登録するとredoができるようになります。

func undoTask() {
    // なんかする
    undoManager?.registerUndo(withTarget: self) { target in
         target.undoSomthing()
    }
}

個人的には再起処理にしておくのがシンプルになっていいのかなと思っています。もしくは、2つのメソッドに分けて相互に登録しあうとかですかね。

func increment() {
    // 加算処理
    undoManager?.registerUndo(withTarget: self) { target in
         target.decrement()
    }
}

func decrement() {
    // 減算処理
    undoManager?.registerUndo(withTarget: self) { target in
         target.increment()
    }
}

Grouping

複数のUndoを一つのUndoとして扱う機能もあります。たとえば、noteアプリでは改行を2回連続で入れると新しいブロックが作られます。
流れとしては以下のような感じになります。

  1. 改行の入力

  2. 最後の改行を削除

  3. 新しいTextViewの追加

  4. 新しいTextViewにカーソルの後ろにあった文字列の追加

これら一連の処理をすべて、一回のUndoで処理する必要があります。そういうときにGroupをつかうと、「ここから」「ここまで」という風に一連の処理を一回のUndoで戻せるようになります。

それだけですめば、Groupingは便利だよね!となるのですが、Groupingにはすこしややこしい点もあります。

Groupingは標準で使われるようになっています。今、「ここから」「ここまで」と書いたのですが、標準の状態では自動でそこが調整されてしまいます。
連続したregisterUndoを登録した場合、それらを自動でまとめてくれます。 Automatically creates undo groups around each pass of the run loop. とのことで、run loopごとにまとめてくれる機能があるそうですが、どこからどこまでが1 run loopなのかがわからないというような問題があったりします。

vc.undoManager?.beginUndoGrouping()
undoTask(vc: vc, message: "Undo 1")
vc.undoManager?.endUndoGrouping()

vc.undoManager?.beginUndoGrouping()
undoTask(vc: vc, message: "Undo 2")
vc.undoManager?.endUndoGrouping()

vc.undoManager?.beginUndoGrouping()
undoTask(vc: vc, message: "Undo 3")
vc.undoManager?.endUndoGrouping()

vc.undoManager?.undo()

undoTaskの中でundoRegistrationを呼び出しています。それぞれのタスクをbeginUndoGrouping/endUndoGroupingでかこっています。
ぱっとみたかんじだと、それぞれのundoGroupが独立しているように見えます。しかし、実行してみると3つundoが同時に実行されます。1つのrun loopに含まれてしまっているためだと思われます。
無理やりrun loopを分割するには、

await Task<Void, Never>.detached(priority: .background) {
    await Task.yield()
}.value

のような処理をいれると分割されます。

undoTask(vc: vc, message: "Undo 1")
await Task<Void, Never>.detached(priority: .background) {
    await Task.yield()
}.value
undoTask(vc: vc, message: "Undo 2")
undoTask(vc: vc, message: "Undo 3")

vc.undoManager?.undo()

このコードだと、下2つが1回のundo()でundoされるようになります。
まあ、テストコードでかいたものなので実際にこういう使い方をするかどうかは別だとは思いますが。

自動でGroupが作られるのを防ぎ、自分で管理するという方法もあります。

undoManager?.groupsByEvent = false

groupsByEventをfalseにすると自動でグループが作られなくなります。最初にあげた、beginGroupingでかこったコードを実行するとundo 1つ分のみundoされるようになります。
もちろん、こちらにも問題があってbeginとendの数があっていないとクラッシュします。undoには今beginされているgroupを自動でendする機能があるので閉じ忘れはそれほど問題ないです。が、beginしわすれた状態でregisterUndoするとクラッシュします。

というわけでGroupややこしいねん。という問題があるのですが、だいたいこれくらいがUndoManagerの基本となります。

noteのエディタでUndoを実装する上でいくつか問題に遭遇しました。

note アプリのテキストエディタ構造

エディタの基本的にはこの記事のときから変わっていません。UndoManagerまわりで理解が必要なところは、いろいろなViewがStackViewに入っているというところです。


ざっくり

問題点

UITextView専用UndoManager

UITextViewは_UITextUndoManagerという独自のUndoManager実装を持っているようでした。このUndoManagerは入力した文字列をいい感じにUndoする機能を持っています。逆にこのUndoManagerを使わないとiOS標準の文字入力に関するUndoとは違う動きになってしまいます。

一応UndoManagerはoverrideできるのですが、overrideするとこの独自UndoManagerの機能が失われます。UndoManagerを継承して、ゴニョゴニョできるようにしたMyUndoManagerを使いたくても使えないということです。

したがって、UITextViewと類似した実装が出来ないならば,UITextViewのUndoManagerを使う必要があります。

View構造

UndoManager を呼び出すと、Responder Chainを辿って呼び出しが発生します。
上記の図ですと,TextView→StackView→ScrollViewという感じになります。
「いい感じに呼び出してくれるんだね!」という感じがしますが、noteアプリの実装ではいい感じではありません。
noteアプリの場合、TextViewが兄弟関係として並んだ構造となっています。そのため、親子関係を辿っていくResponder Chainの場合となりのTextViewがもっているUndoManagerの存在を知りません。兄弟関係のUndoManagerの存在をしらないため、UndoManagerが呼び出されるべき真の順序がわからなくなります。

TextView1→TextView2→TextView1という順で編集したときに、TextView1でUndoManagerを呼び出すと、TextView2で発生した編集はスキップされてTextView1 に関するものだけがUndo出来る状態になります。
逆にTextView2で呼び出した場合は、TextView2のみの処理を扱い、TextView 1の変更がスキップされます。

となりのUndoManagerはしらない子

ここで、共通のUndoManager を使えばいいじゃない!となるのですが、残念ながら先述した通りUITextViewのUndoManagerは置き換えできません。

エディタの不具合と特性

そして、 問題点3つ目がエディタの未定義動作と不具合です。iOS note アプリで記事を書いていたら,たまに首をかしげたくなるような動きをする時があります。ブロックの追加や並べ替え、リストの操作などにいくつかありました。Undoの実装がだめなのか、もとのコードがだめだったのかというのを調査しながら進める必要が発生したため,かなり実装が大変なことになっています。
また、カーソルの位置,文字選択状態といった考慮すべき状態があります。選択した文字に対して,ボールドにしたり、リンクを追加したりという編集を加えるといった編集をUndo/Redoするときに選択状態を変更する必要があります。Undoでカーソルも移動するかどうかなど、いろいろ確認する必要があります。

まあ、このあたりはせっせと未定義動作や不具合を修正し、状態についても復元できるようにUndoを実装していくしか対応方法はないので、がんばっています。

実装

実装方針

  1. UndoManagerをためるStackをつくる

  2. Stackから順番にundoする

雰囲気

実装

それでは、実装についてです。
Undoが登録されるさいNSNotificationがいくつか飛びます。

  • NSUndoManagerCheckpoint (Undoが追加削除変更された)

  • NSUndoManagerDidOpenUndoGroup(グループが開始した)

  • NSUndoManagerDidCloseUndoGroup(グループが終了した)

これ以外にも、いろいろ飛びまくるのですがグループが終了したときに飛んでくるNSUndoManagerDidCloseUndoGroupを使います。

このNotificationが飛んできたタイミング、かつ、groupLevelが0のときにManagerStack(ただの配列)に追加するというふうにしてみました。

NotificationCenter.default.publisher(for: .NSUndoManagerDidCloseUndoGroup, object: nil).sink { [weak self] notification in
    guard let manager = notification.object as? UndoManager, manager.groupingLevel == 0 else { return }
        self?.undoManagers.append(manager)
}.store(in: &cancellables)

そして、undo()したいときは、undoManagers.popLast()し、undoします。これで一応、親子関係ではなく兄弟関係にあるUndoManagerを使った順序で呼び出せるようになりました。

↑の絵にあるようにStackView自体にもUndoManagerを持たせて、並べ替えや追加といった処理も同じようにあつかえるようにしています。
これのためにundoManagersという選択肢はすごくありだったんじゃないかと思います。

実際使っているところ

一番レベルでシンプルな使っている箇所のコードです。画面下部のツールバーの文字をBoldにするボタンでつかっている実装です。
updateBold は文字をPlainテキストだったらBoldにし、BoldだったらPlainにする関数です。戻り値はUndoが必要だったらtrue,不要だったらfalseになるようにしています。

private func apply(inputView: TextEditorInputView, textView: UITextView) {
    let selectedRange = textView.selectedRange
    if updateBold(for: textView, inputView: inputView) {
        inputView.undoManager?.registerUndo(withTarget: textView) { [weak self, selectedRange] target in
            target.becomeFirstResponder()
            target.selectedRange = selectedRange
            self?.apply(inputView: inputView, textView: target)
        }
    }

    delegate?.updateToolbar(for: inputView)
}

というかんじで、シンプルにUndoを実装できます。連打して、Bold→Plain→Boldとしたときの動作や、先述した未定義動作や不具合のせいで、迷宮にはまっていき、このシンプルなコードの何がわるいんだ。。。というのをUndo実装一つ一つに繰り返していくみたいなことをやり続けています。

まとめ

UndoManagerはテキスト操作に限らず,任意の処理をクロージャで定義するればUndoとして実行できます。今回は再帰的に呼び出すだけのシンプルなコードとしましたが、再帰ではなく追加⇔削除、並び替え、パラメータの変更などだいたい何でもできます。
Undo・Redoができることがプラスとなるアプリであればぜひ導入することをオススメします。

そのさい、問題となってくるのは_UITextUndoManagerのようなプライベートクラスが邪魔ってところや、Responder Chain に乗らないような使い方だと厳しさが増すということです。

そして、最重要なのが開発のなるべく早いうちから,Undo・Redoすることを前提にコードを考えていくことです。

Undo・Redoできるということは、その処理によって発生する副作用がコントロールできているといえます。どこかで副作用が発生した結果,もとの状態にもどらないということがおこっていないといえるためです。

noteアプリでは最近になってundoいれたいなとなった結果

  • UndoManagerの複雑さ

  • テキスト処理の副作用の多さ

からかなり追加に苦労しています。時間はかかるかも知れないですが、エディタの完成度を上げていくためにこれからも開発していきます。