マルチカラム環境でのUndo標準挙動を探る


UISplitViewControllerでは、`.primary`・`.supplementary`・`.detail`の3つのViewControllerを表示することが可能です。MacAppは専門ではありませんが、同様にMacOS HIGの[Split Views](https://developer.apple.com/design/human-interface-guidelines/macos/windows-and-views/split-views/)でも`vertical`, `horizontal`, `multiple`というSplitの種類が紹介され、それが`NSSplitViewController`で実現可能だと説明されています。

![スクリーンショット 2020-12-14 12.10.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/c7d93116-5df6-c8a2-32ab-91384eb83e65.png)
この記事では、このようなマルチカラム環境での**「アプリケーションでのネイティブらしいUndo体験」を探る**ということを目標にして探索を行います。

https://twitter.com/yosshi_4486/status/1333553196745191424?s=21

# 1. 進行説明
最初に断りを入れておきますが、[HIG](https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction/undo-and-redo/)でのUndo/Redoへの言及は非常に薄く、WWDCでUndoのインターフェースについて特集された回も見当たらないため、他のしっかりと情報が提供される項目([Drag&Drop](https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction/drag-and-drop/)など)のように、**「HIGをしっかりと読んで、WWDCをちゃんと見て、公式リソースからネイティブらしい挙動を掴み学び実装する」といった進め方をするのは困難です。**

そのため、今回の記事では「Apple純正アプリケーション」の挙動をいくつかトレース/テストして、リバースエンジニアリング的にUndo標準挙動を得ようと試みていきます。

# 2.Note(メモ）アプリの挙動テスト
Noteアプリで複数のデバイスで挙動テストを行いました。いくつかのケースを見ていきましょう。

探索要素としては以下のようなものになります。この全てを組み合わせ調査すると7!で5040通りになってしまうので、勘でいくつか有効そうなものを絞り込んで試してみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/fbe3d5bd-f1bf-b97a-7f83-d9310f2db3a5.png)

## macOS
以下のような時系列に依存するテストケースを実行していきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/99e0a110-bb26-eb98-29ce-9a6084db0920.png)

### ロジックの推察
- テキスト入力(NSTextView or UITextView)に操作が移ると、それまでのundoStackはクリアされている。
- 画面上で表示されている場合は、フォルダ/ノートでundoStackを共有。フォルダ一覧を隠してもフォルダ作成へのundoは実行される。
- ノートは作成→削除と続けると、操作自体がなかったものとして扱われる。
- フォルダは作成→削除と続けると、どちらもundoすることが可能である。


#### ノートオブジェクトの特殊な削除
上記のようにノートとフォルダでundo挙動が変わるのは、オブジェクトが別で削除についての考え方が異なるからだと考えています。

ノートオブジェクトは、削除しても「最近削除した項目」に移動するという特殊な削除挙動をしており、仮に削除が意図するものでなかった場合や、二週間後に必要になった場合でも、その後また戻せるUIを提供することで、通常の削除をラフに提供できます。

対してフォルダオブジェクトは、そのような特殊な削除操作がないので、誤って削除してしまった場合は急いでundoで取り消さなければなりません。

このような削除に対するインターフェースやオブジェクト性質の違いから実装が異なり、結果として「作成→削除」の場合のundoの挙動の違いに至っているのではないかと想像します。

## iOS(iPad&iPhone)
テキスト入力への変更はUndo可能ですが、ノートオブジェクト、フォルダオブジェクトへの作成・削除はUndoが実行できません。

# 3.Reminderアプリでの挙動テスト
続いて、Reminderアプリでも同じように挙動を見ていきましょう。

今回の操作要素は以下になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/0fc76632-3e15-24b3-5f16-efe6d36134f6.png)

加えて、先程のNoteアプリでUndoに対する観点を得たので利用し、以下のような観点表を作成しました。次のテストケースはこれを考慮しながら作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/b52c1634-3b64-4041-3086-b6858a54195d.png)

## macOS
観点を考慮して下記のようなテストを実行していきました。リマインダーオブジェクトは、入力をしないと保存されない仕様だったので、進行上入力が必要だった箇所は括弧で囲んでいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/51c50ca4-9036-22d1-dd80-5a6ca909abc8.png)

### ロジックの推察
- オブジェクト種類も可視性も関係なく、全てを共通のUndoStackでハンドリングしている。

おそらく、共有されたview用の`managedObjectContext.undoManager`を使い回しているのではないでしょうか。アプリケーションが終了してメモリ上の`undoManager`が破棄されるまで共有して使いまわされているような感じがします。

## iOS
### iPad
iPadではサイドバーの開閉が提供されていないため、一部のテストケースを削っています。macOS版との挙動の違いとしては、リストのundoの際にそのオブジェクト自体のundoはされず、テキストが見入力にもどる。という挙動をする点です。ですが、それ以外の操作に関してはmacOS版と同じ挙動をしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114709/1ad629a0-8856-4bd0-e4f2-49042b6e7520.png)

#### ロジックの推察
iPadは少しBuggyな挙動をします。一度リマインダーへの入力を行うとグループやリストのundoが適応されますが、それまではundoが適応されません。

# 4. 標準挙動を考える
いくつかのアプリケーションを見ていった結果、あまり原理原則が多く存在する分野ではないなぁと感じました。

HIGに以下のような項目があります。直訳すると「現在のコンテキストでのみUndoとRedoを実行してください」となりますが、iPadやmacのマルチカラム環境で可視性でないオブジェクトに対してもundo出来てしまっているのでどういうことなのか...。

> Perform undo and redo operations in the current context only
https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction/undo-and-redo/

# 5. 対応方針
ここまで見ていきましたが、残念ながらベストプラクティスというものは得られませんでした。

なので幾つかの対応方針を考えていきたいと思います

## 方針1. 共有方針
共通の`undoManager`を利用し、undo挙動を共有します。良い点はPrimary-Detailで共有された続きのundoが実現できること。

## 方針2. 非共有方針
別々の`undoManager`を利用し、レスポンダーチェーンで`canUndo`もしくは`canRedo`が対応できなくなるまで反応し、反応できない場合は次のオブジェクト(Primary)に回します。良い点は、それぞれがundoを管理しているので、自分以外のundoを気にしなくて済むところ、悪い点は、続きのundoが実現できないところです。

## 方針3. コンテキストベース
「共有するところ、共有を止めるところを、そのコンテキストによって判断して実装する」という一番難しいもの

# 6. 実装サンプル
この実装では、上記の1の方針に従ってサンプルを実装しました。最も簡潔な実装ではありますが、undo関連の実装サンプル自体が多くないので、ある程度参考にはなるかと思います。

https://github.com/yosshi4486/MultiColumnUndoSample

# 7. まとめ
この記事では以下に章に分けてUndoの標準挙動を探りました。

- 純正Noteアプリケーションの挙動をテストし、ロジックを推察
- 純正Reminderアプリケーションの挙動をテスト、ロジックを推察
- HIGを参考にしながらUndoの標準挙動とプラクティスを推論
- UISplitViewControllerとNSUndoManager利用した、UIKitでの実装サンプルを解説

(最初はベストプラクティスを探ってやると意気込んで始めたのですが、純正アプリケーションでも実装が微妙だったので、尻すぼみの記事になってしまいました...)

# 補足
- この記事のロジック推察では、Apple純正Appのため[CoreData](https://developer.apple.com/documentation/coredata)が内部的に利用されているだろう。という前提で話を進めています。
- テストケースを用意して進めてはみましたが、テストのプロではないため進め方は適当です。