{
  "$type": "site.standard.document",
  "content": {
    "$type": "app.wtr.content.markdown",
    "markdown": "最近 note iOSアプリにUndoを実装しました。まことに申し訳ないのですが、その動作はまだ全然安定していません。\n\nだいぶカイゼンしたのですが、かなり不安定なRedoについては一旦取り下げることにしました。  \nさて、なんでUndo/Redoがややこしくて、実装がうまくいっていないのかを言い訳していきたいと思います。\n\n## iOSDC 2024 LT\n\niOSDCで発表してきました。LTなのでざっくりとこの記事をまとめたものになります。\n\n[**ノートアプリにUndo機能を実装! UndoManager導入ポイント** _UndoManagerは、アプリにUndo・Redo機能を提供してくれる強力なクラスです。 特にnote アプリのようなエ_ _fortee.jp_](https://fortee.jp/iosdc-japan-2024/proposal/af6336d6-07f9-4738-8205-ad261f8b0d78)[](https://fortee.jp/iosdc-japan-2024/proposal/af6336d6-07f9-4738-8205-ad261f8b0d78)\n\n  \n\n## UndoManager\n\n[**UndoManager | Apple Developer Documentation** _A general-purpose recorder of operations that enables undo an_ _developer.apple.com_](https://developer.apple.com/documentation/foundation/undomanager)[](https://developer.apple.com/documentation/foundation/undomanager)\n\nUndoManager は操作を記録して、その操作をもう一度実行するための仕組みです。  \niOS 3.0から使えるスタック構造で、Undo Redoを、どんどん登録して使えるものになります。\n\n登録できる操作には制限はなくかなり自由になんでもできる仕組みです。  \n登録は次のような感じでできます。\n\n```\nundoManager?.registerUndo(withTarget: self) { target in\n    target.undoSomthing()\n}\n```\n\nそして、undo は\n\n```\nundoManager?.undo()\n```\n\nでできます。  \nこれらを各所にいい感じに埋め込んでいって,いい感じに**undo()**を呼び出してあげれば,「あら簡単、Undoができました」となります。\n\nUndoManagerはスタックで管理しているので、最後に追加されたタスクが最初にundo()で取り出されます。このスタック構造を工夫してUndoを便利に呼び出せるようになります。\n\n![](https://assets.st-note.com/img/1716873513510-uZhAX1pBCG.png)\n\nUndoManagerはスタック\n\n### Redoもできるよ\n\nUndoがあればRedoもあります。Undo処理のなかでさらにUndoを登録するとredoができるようになります。\n\n```\nfunc undoTask() {\n    // なんかする\n    undoManager?.registerUndo(withTarget: self) { target in\n         target.undoSomthing()\n    }\n}\n```\n\n個人的には再起処理にしておくのがシンプルになっていいのかなと思っています。もしくは、2つのメソッドに分けて相互に登録しあうとかですかね。\n\n```\nfunc increment() {\n    // 加算処理\n    undoManager?.registerUndo(withTarget: self) { target in\n         target.decrement()\n    }\n}\n\nfunc decrement() {\n    // 減算処理\n    undoManager?.registerUndo(withTarget: self) { target in\n         target.increment()\n    }\n}\n```\n\n### Grouping\n\n複数のUndoを一つのUndoとして扱う機能もあります。たとえば、noteアプリでは改行を2回連続で入れると新しいブロックが作られます。  \n流れとしては以下のような感じになります。\n\n![](https://assets.st-note.com/production/uploads/images/141969262/picture_pc_4469ed9fde83d18a1a0db5080f46c8b7.gif)\n\n1.  改行の入力\n    \n2.  最後の改行を削除\n    \n3.  新しいTextViewの追加\n    \n4.  新しいTextViewにカーソルの後ろにあった文字列の追加\n    \n\nこれら一連の処理をすべて、一回のUndoで処理する必要があります。そういうときにGroupをつかうと、「ここから」「ここまで」という風に一連の処理を一回のUndoで戻せるようになります。\n\nそれだけですめば、Groupingは便利だよね!となるのですが、Groupingにはすこしややこしい点もあります。\n\nGroupingは標準で使われるようになっています。今、「ここから」「ここまで」と書いたのですが、標準の状態では自動でそこが調整されてしまいます。  \n連続したregisterUndoを登録した場合、それらを自動でまとめてくれます。 **Automatically creates undo groups around each pass of the run loop.** とのことで、run loopごとにまとめてくれる機能があるそうですが、どこからどこまでが**1 run loop**なのかがわからないというような問題があったりします。\n\n```\nvc.undoManager?.beginUndoGrouping()\nundoTask(vc: vc, message: \"Undo 1\")\nvc.undoManager?.endUndoGrouping()\n\nvc.undoManager?.beginUndoGrouping()\nundoTask(vc: vc, message: \"Undo 2\")\nvc.undoManager?.endUndoGrouping()\n\nvc.undoManager?.beginUndoGrouping()\nundoTask(vc: vc, message: \"Undo 3\")\nvc.undoManager?.endUndoGrouping()\n\nvc.undoManager?.undo()\n```\n\nundoTaskの中でundoRegistrationを呼び出しています。それぞれのタスクをbeginUndoGrouping/endUndoGroupingでかこっています。  \nぱっとみたかんじだと、それぞれのundoGroupが独立しているように見えます。しかし、実行してみると3つundoが同時に実行されます。1つのrun loopに含まれてしまっているためだと思われます。  \n無理やりrun loopを分割するには、\n\n```\nawait Task<Void, Never>.detached(priority: .background) {\n    await Task.yield()\n}.value\n```\n\nのような処理をいれると分割されます。\n\n```\nundoTask(vc: vc, message: \"Undo 1\")\nawait Task<Void, Never>.detached(priority: .background) {\n    await Task.yield()\n}.value\nundoTask(vc: vc, message: \"Undo 2\")\nundoTask(vc: vc, message: \"Undo 3\")\n\nvc.undoManager?.undo()\n```\n\nこのコードだと、下2つが1回のundo()でundoされるようになります。  \nまあ、テストコードでかいたものなので実際にこういう使い方をするかどうかは別だとは思いますが。\n\n自動でGroupが作られるのを防ぎ、自分で管理するという方法もあります。\n\n```\nundoManager?.groupsByEvent = false\n```\n\n**groupsByEvent**をfalseにすると自動でグループが作られなくなります。最初にあげた、beginGroupingでかこったコードを実行するとundo 1つ分のみundoされるようになります。  \nもちろん、こちらにも問題があってbeginとendの数があっていないとクラッシュします。undoには今beginされているgroupを自動でendする機能があるので閉じ忘れはそれほど問題ないです。が、beginしわすれた状態でregisterUndoするとクラッシュします。\n\nというわけでGroupややこしいねん。という問題があるのですが、だいたいこれくらいがUndoManagerの基本となります。\n\nnoteのエディタでUndoを実装する上でいくつか問題に遭遇しました。\n\n## note アプリのテキストエディタ構造\n\nエディタの基本的にはこの記事のときから変わっていません。UndoManagerまわりで理解が必要なところは、いろいろなViewがStackViewに入っているというところです。\n\n  \n\n![](https://assets.st-note.com/img/1724728575170-3nzxUmgkSP.png)\n\nざっくり\n\n## 問題点\n\n### UITextView専用UndoManager\n\nUITextViewは**\\_UITextUndoManager**という独自のUndoManager実装を持っているようでした。このUndoManagerは入力した文字列を**いい感じ**にUndoする機能を持っています。逆にこのUndoManagerを使わないとiOS標準の文字入力に関するUndoとは違う動きになってしまいます。\n\n一応UndoManagerはoverrideできるのですが、overrideするとこの独自UndoManagerの機能が失われます。UndoManagerを継承して、ゴニョゴニョできるようにしたMyUndoManagerを使いたくても使えないということです。\n\nしたがって、UITextViewと類似した実装が出来ないならば,UITextViewのUndoManagerを使う必要があります。\n\n### View構造\n\nUndoManager を呼び出すと、Responder Chainを辿って呼び出しが発生します。  \n上記の図ですと,TextView→StackView→ScrollViewという感じになります。  \n「いい感じに呼び出してくれるんだね!」という感じがしますが、noteアプリの実装ではいい感じではありません。  \nnoteアプリの場合、TextViewが兄弟関係として並んだ構造となっています。そのため、親子関係を辿っていくResponder Chainの場合となりのTextViewがもっているUndoManagerの存在を知りません。兄弟関係のUndoManagerの存在をしらないため、UndoManagerが呼び出されるべき真の順序がわからなくなります。\n\nTextView1→TextView2→TextView1という順で編集したときに、TextView1でUndoManagerを呼び出すと、TextView2で発生した編集はスキップされてTextView1 に関するものだけがUndo出来る状態になります。  \n逆にTextView2で呼び出した場合は、TextView2のみの処理を扱い、TextView 1の変更がスキップされます。\n\n![](https://assets.st-note.com/img/1716770181837-679Jt1oNMW.png)\n\nとなりのUndoManagerはしらない子\n\nここで、共通のUndoManager を使えばいいじゃない!となるのですが、残念ながら先述した通りUITextViewのUndoManagerは置き換えできません。\n\n### エディタの不具合と特性\n\nそして、 問題点3つ目がエディタの未定義動作と不具合です。iOS note アプリで記事を書いていたら,たまに首をかしげたくなるような動きをする時があります。ブロックの追加や並べ替え、リストの操作などにいくつかありました。Undoの実装がだめなのか、もとのコードがだめだったのかというのを調査しながら進める必要が発生したため,かなり実装が大変なことになっています。  \nまた、カーソルの位置,文字選択状態といった考慮すべき**状態**があります。選択した文字に対して,ボールドにしたり、リンクを追加したりという編集を加えるといった編集をUndo/Redoするときに選択状態を変更する必要があります。Undoでカーソルも移動するかどうかなど、いろいろ確認する必要があります。\n\nまあ、このあたりはせっせと未定義動作や不具合を修正し、**状態**についても復元できるようにUndoを実装していくしか対応方法はないので、がんばっています。\n\n## 実装\n\n### 実装方針\n\n1.  UndoManagerをためるStackをつくる\n    \n2.  Stackから順番にundoする\n    \n\n![](https://assets.st-note.com/img/1717035943359-8CvWJIwTdB.png)\n\n雰囲気\n\n### 実装\n\nそれでは、実装についてです。  \nUndoが登録されるさいNSNotificationがいくつか飛びます。\n\n*   NSUndoManagerCheckpoint (Undoが追加削除変更された)\n    \n*   NSUndoManagerDidOpenUndoGroup(グループが開始した)\n    \n*   NSUndoManagerDidCloseUndoGroup(グループが終了した)\n    \n\nこれ以外にも、いろいろ飛びまくるのですがグループが終了したときに飛んでくる**NSUndoManagerDidCloseUndoGroup**を使います。\n\n[**NSUndoManagerDidCloseUndoGroup | Apple Developer Documentation** _Posted after an object closes an undo group, which occurs in_ _developer.apple.com_](https://developer.apple.com/documentation/foundation/nsnotification/name/1408817-nsundomanagerdidcloseundogroup)[](https://developer.apple.com/documentation/foundation/nsnotification/name/1408817-nsundomanagerdidcloseundogroup)\n\nこのNotificationが飛んできたタイミング、かつ、groupLevelが0のときにManagerStack(ただの配列)に追加するというふうにしてみました。\n\n```\nNotificationCenter.default.publisher(for: .NSUndoManagerDidCloseUndoGroup, object: nil).sink { [weak self] notification in\n    guard let manager = notification.object as? UndoManager, manager.groupingLevel == 0 else { return }\n        self?.undoManagers.append(manager)\n}.store(in: &cancellables)\n```\n\nそして、**undo()**したいときは、**undoManagers.popLast()**し、undoします。これで一応、親子関係ではなく兄弟関係にあるUndoManagerを使った順序で呼び出せるようになりました。\n\n↑の絵にあるようにStackView自体にもUndoManagerを持たせて、並べ替えや追加といった処理も同じようにあつかえるようにしています。  \nこれのためにundoManagersという選択肢はすごくありだったんじゃないかと思います。\n\n### 実際使っているところ\n\n一番レベルでシンプルな使っている箇所のコードです。画面下部のツールバーの文字をBoldにするボタンでつかっている実装です。  \nupdateBold は文字をPlainテキストだったらBoldにし、BoldだったらPlainにする関数です。戻り値はUndoが必要だったらtrue,不要だったらfalseになるようにしています。\n\n```\nprivate func apply(inputView: TextEditorInputView, textView: UITextView) {\n    let selectedRange = textView.selectedRange\n    if updateBold(for: textView, inputView: inputView) {\n        inputView.undoManager?.registerUndo(withTarget: textView) { [weak self, selectedRange] target in\n            target.becomeFirstResponder()\n            target.selectedRange = selectedRange\n            self?.apply(inputView: inputView, textView: target)\n        }\n    }\n\n    delegate?.updateToolbar(for: inputView)\n}\n```\n\nというかんじで、シンプルにUndoを実装できます。連打して、Bold→Plain→Boldとしたときの動作や、先述した未定義動作や不具合のせいで、迷宮にはまっていき、このシンプルなコードの何がわるいんだ。。。というのをUndo実装一つ一つに繰り返していくみたいなことをやり続けています。\n\n## まとめ\n\nUndoManagerはテキスト操作に限らず,任意の処理をクロージャで定義するればUndoとして実行できます。今回は再帰的に呼び出すだけのシンプルなコードとしましたが、再帰ではなく追加⇔削除、並び替え、パラメータの変更などだいたい何でもできます。  \nUndo・Redoができることがプラスとなるアプリであればぜひ導入することをオススメします。\n\nそのさい、問題となってくるのは**\\_UITextUndoManager**のようなプライベートクラスが邪魔ってところや、Responder Chain に乗らないような使い方だと厳しさが増すということです。\n\nそして、最重要なのが開発のなるべく早いうちから,Undo・Redoすることを前提にコードを考えていくことです。\n\nUndo・Redoできるということは、その処理によって発生する副作用がコントロールできているといえます。どこかで副作用が発生した結果,もとの状態にもどらないということがおこっていないといえるためです。\n\nnoteアプリでは最近になってundoいれたいなとなった結果\n\n*   UndoManagerの複雑さ\n    \n*   テキスト処理の副作用の多さ\n    \n\nからかなり追加に苦労しています。時間はかかるかも知れないですが、エディタの完成度を上げていくためにこれからも開発していきます。"
  },
  "path": "/blog/nb66301b0c040",
  "publishedAt": "2024-08-27T00:00:00.000Z",
  "site": "at://did:plc:c656e4nvjol7kh35tfmzkgpu/site.standard.publication/3mmnvl6ln3226",
  "title": "note iOSアプリにUndo機能を実装! #iOSDC #LT",
  "updatedAt": "2026-05-25T07:15:04Z"
}