[SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その3 : UNDO/REDO 実装)

Realm を使ったアプリで要素の追加・削除をできるように実装してきました。
今回は、追加・削除にUNDO/REDOを実装してみます。

Realm シリーズ第3回です。関係回は、以下です。

[SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その1 : 要素作成) [SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その2 : 要素編集) [SwiftUI][Realm][Xcode12] Realm を使ったアプリの開発方法(その4: List 表示している要素を修正する)

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 は、以下のように定義しました。

Command Protocol

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 可能か? の情報を提供できるようにします。

UNDO/REDO 対応 AppModel

class AppModel: NSObject, ObservableObject {
    @Published var textLineList:List
    @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

以下のようになります。

CreateTextLineCommand

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

以下のようになります。

DeleteTextLineCommand

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から少し変更して以下のようにしました。

UI for UNDO/REDO

コードとしては、以下のようになります。

UNDO/REDO を追加したUI

struct ContentView: View {
    @ObservedObject var appModel: AppModel
    @ObservedObject var textLines: RealmSwift.List
    
    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 機能を作りました。

デザインパターンは、いつまでも地味に役立つものだと改めて感じました。

おすすめの Realm 本

Web で API 等を Reference で確認することができますが、じっくりと腰を据えて学習したいときには、学習の順番が考慮されている本を使うのがおすすめです。

Realm については、本がほとんど出版されていませんが、以下の本は おすすめです。

説明は以上です。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です