今回は、追加・削除にUNDO/REDOを実装してみます。
Realm シリーズ第3回です。関係回は、以下です。



Sponsor Link
目次
Realm は、UNDO/REDO が苦手
この UNDO/REDO 対応が Realm を使って実装するアプリの課題の1つです。
CoreData であれば、ほぼ全自動で UNDO/REDO 対応のアプリになりますが、Realm では、UNDO/REDO の仕組みから実装しなければなりません。
UNDO/REDO 実装方針
今回は、追加機能と削除機能を UNDO/REDO 対応させようと思います。
追加機能は、追加した要素を記憶しておき、UNDO 時に追加要素を削除することで良いのですが、削除機能の UNDO は簡単ではありません。
削除機能で本当に要素を削除してしまうと UNDO された時に、元に戻せません・・・・
ということで、要素に、”askedForDeletion” フラグを導入して、このフラグが TRUE であれば、削除された要素であると扱うことにします。
別方針案1:アプリケーションモデルが、処理対象の要素リストを保持し、そのリストから外すことで削除とする。
別方針案2:削除時に実際に削除してしまう。ただし、再作成できる情報をすべて保存し、UNDO 時には、同じ情報を持つように要素を再作成する。
いくつか方針は考えられますが、今回は、フラグをセットするようにして進めることにします。
コマンドパターン
GoF のデザインパターンの1つに “Command Pattern” というものがあります。
Wikipedia の説明は、こちら。
今回はこのパターンを採用して実装していきます。
Command が準拠すべき Protocol は、以下のように定義しました。
1 2 3 4 5 6 7 8 |
protocol Command { // target model var appModel: AppModel { get } // 1 func execute() // 2 func flippedCommand() -> Command // 3 } |
1: コマンドの操作対象のモデルを持つようにします。
2: 実行するための関数名を定義します。
3: UNDO/REDO のために、自コマンドの逆操作となるようなコマンドを返します。
作成コマンド、削除コマンド のどちらも、上記 Protocol に準拠して、実装していきます。
AppModel 修正
Command Protocol に準拠するコマンドを実行できるようにするのと、UNDO, REDO 用に、Command のリストを保持しておくようにします。
Command のリストを利用して、アプリケーションとして、UNDO 可能か?、REDO 可能か? の情報を提供できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
class AppModel: NSObject, ObservableObject { @Published var textLineList:List<TextLine> @Published var undoStack:[Command] = [] // 1 @Published var redoStack:[Command] = [] // 2 // 3 var canUndo:Bool { return undoStack.count > 0 } // 4 var canRedo: Bool { return redoStack.count > 0 } override init() { let realm = try! Realm() var textLines = realm.object(ofType: TextLines.self, forPrimaryKey: 0) if textLines == nil { textLines = try! realm.write{ realm.create(TextLines.self, value: [])} } self.textLineList = textLines!.textLines super.init() } // MARK: command execution // 5 func executeCommand(_ command: Command) { command.execute() let flippedCommand = command.flippedCommand() self.undoStack.append(flippedCommand) // clear redo stack self.redoStack = [] } // 6 func undo() { guard let command = undoStack.popLast() else { return } command.execute() let redoCommand = command.flippedCommand() self.redoStack.append(redoCommand) } // 7 func redo() { guard let command = redoStack.popLast() else { return } command.execute() let undoCommand = command.flippedCommand() self.undoStack.append(undoCommand) } } |
1,2: UNDO/REDO 用のコマンドスタック
3,4: UNDO/REDO 可能フラグ
5: コマンド実行関数。実行後、UNDO 用のコマンドを UNDO スタックに記録します。同時に、REDO スタックを空にします。
6, 7: UNDO, REDO 実行関数
コマンド作成
AppModel 側の準備はできたので、作成・削除のコマンドを作っていきます。
気をつけるべき点は、Realm のオブジェクトは、スレッドを超えて渡すことができないので、別スレッドからオブジェクトを操作する時には、primaryKey を使って、Realm から要素を改めて取得する必要があります。
CreateTextLineCommand
以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
class CreateTextLineCommand: Command { var appModel: AppModel var newTextLineText: String var createdPrimaryKey: String init(appModel: AppModel, newTextLineText: String) { self.appModel = appModel self.newTextLineText = newTextLineText // when created, this will be updated self.createdPrimaryKey = "" } func execute() { appModel.textLineList.realm?.beginWrite() let newTextLine = TextLine() newTextLine.text = self.newTextLineText newTextLine.askedForDelete = false appModel.textLineList.realm?.add(newTextLine) appModel.textLineList.append(newTextLine) try! appModel.textLineList.realm?.commitWrite() self.createdPrimaryKey = newTextLine.id } func flippedCommand() -> Command { let flippedCommand = DeleteTextLineCommand(appModel: self.appModel, targetId: self.createdPrimaryKey, newValue: true) return flippedCommand } } |
気をつける点は、flippedCommand(UNDO用のコマンド)は、削除コマンドになるという点です。
DeleteTextLineCommand
以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DeleteTextLineCommand: Command { var appModel: AppModel var primaryKey: String var newValue: Bool var oldValue: Bool init(appModel: AppModel, targetId: String, newValue: Bool) { self.appModel = appModel self.primaryKey = targetId self.newValue = newValue self.oldValue = !newValue } func execute() { appModel.textLineList.realm?.beginWrite() let targetElement = appModel.textLineList.realm?.object(ofType: TextLine.self, forPrimaryKey: self.primaryKey) targetElement?.askedForDelete = self.newValue try! appModel.textLineList.realm?.commitWrite() } func flippedCommand() -> Command { let flippedCommand = DeleteTextLineCommand(appModel: self.appModel, targetId: self.primaryKey, newValue: self.oldValue) return flippedCommand } } |
気をつける点は、DeleteTextLineCommand の flippedCommand は、DeleteTextLineCommand であり、CreateTextLineCommand ではありません。
方針のところでも説明しましたが、削除という行為は、削除フラグをセットしているだけなので、そのフラグ操作を行うためです。
注意点
実装してきたように、削除コマンドは実際に削除せずに削除フラグを設定するだけです。
ですので、アプリケーション終了時のようなタイミングで、削除フラグを設定された要素を削除することが必要です。
削除しなくとも UI 的には動きますが、データ量が増え続けてしまいます。
iPhone アプリには、アプリケーション終了の明確なタイミングがありません。別アプリに切り替えた時には、バックグラウンドに遷移した状態になっています。
この辺りは、UNDO/REDO というよりもアプリケーション設計の課題だと思いますので、検討項目のまま残しておくことにします。
考えていくとすると、以下の項目から検討していくことになるかと思います。
- ユーザーが、UNDO/REDO できなくなっても良いと思えるタイミングは何か?
- そのタイミングを、iOS で検知することができるか?
これが、Xcode12 から導入されるドキュメントを対象とするアプリであれば、セーブ等のタイミングがそのタイミングになるかと思います。(詳細未検討です)
UNDO/REDO UI
UNDO/REDO の UI についての Apple のドキュメントは、こちら。
ざっくりいうと、「UNDO/REDO の対象をきちんと説明しなさい。」「シェイクジェスチャーを使うなら、UNDO/REDO 以外に使ってはいけない」「念の為、UNDO/REDOボタンも用意して」「現在のContextに対してのみUNDO/REDOしなさい」と言ってます。
今回は、UNDO/REDO のためのボタンをつけたいと思います。
すこしタイトルバー周りがごちゃついてしまいましたが、前回のUIから少し変更して以下のようにしました。
コードとしては、以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
struct ContentView: View { @ObservedObject var appModel: AppModel @ObservedObject var textLines: RealmSwift.List<TextLine> var body: some View { VStack { NavigationView { List { // 0 ForEach( textLines.filter("askedForDelete == false").freeze() ) { textLine in Text("\(textLine.askedForDelete.description) \(textLine.id) \(textLine.text)") .font(.footnote) } .onDelete(perform: delete) .onMove(perform: move) } .navigationBarItems(leading: HStack { // 1 Button(action: { self.appModel.undo() }, label: { Image(systemName: "arrow.uturn.left.circle") }) // 2 .disabled(self.appModel.canUndo != true) // 3 Button(action: { self.appModel.redo() }, label: { Image(systemName: "arrow.uturn.right.circle") }) // 4 .disabled(self.appModel.canRedo != true) }, trailing: HStack { // 5 EditButton() // 6 Button("Add", action: { let newName = "name" + String(Int.random(in: 0...2000)) let createCommand = CreateTextLineCommand(appModel: self.appModel, newTextLineText: newName) self.appModel.executeCommand(createCommand) }) } ) .navigationBarTitle("Realm Doc") } HStack { Text("# of item \(textLines.filter("askedForDelete == false").count)") } } } func delete(at offsets:IndexSet) { let target = textLines.filter("askedForDelete == false")[offsets.first!] // 7 let deleteCommand = DeleteTextLineCommand(appModel: self.appModel, targetId: target.id, newValue: true) // 8 self.appModel.executeCommand(deleteCommand) } func move(fromOffsets offsets: IndexSet, toOffset to: Int) { textLines.realm?.beginWrite() textLines.move(fromOffsets: offsets, toOffset: to) try! textLines.realm?.commitWrite() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView(appModel: .init(), textLines: .init()) } } |
0: 削除フラグが設定されている要素をフィルターして処理しています。(他の箇所も同様です)
1: UNDO ボタンを SFSymbolを使って表示しています。クリックされた時には、AppModel から UNDO を実行します。
2: UNDO 可能かどうかは AppModel の判定をベースに判断します。
3, 4: REDO ボタンも同様です。
5: EditButton は、右側に移動しました。
6: 追加機能のボタンです。Closure の中でコマンドを作成して、AppModel に実行させています。
7, 8: 削除機能も同様に、コマンドを作成し、AppModel にて実行します。
まとめ:Realm を使った UNDO/REDO は、自分で実装が必要
今回は、Command Pattern を Realm と組み合わせて UNDO/REDO 機能を作りました。
デザインパターンは、いつまでも地味に役立つものだと改めて感じました。
説明は以上です。
Sponsor Link