[SwiftUI][Realm] SwiftUI と Realm で TODOアプリを作る(3: UNDO/REDO)

     
⌛️ 5 min.
前回導入した Command を使って、UNDO 機能を実装していきます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.2 Beta
  • Xcode 13.2.1
  • iOS 15.2
  • Realm 10.21.0

今回の記事でやること・わかること

前回までで、プロジェクトセットアップと新規要素作成ボタンを実装し、それらを Command パターンを使うように修正しました。

シリーズ記事

[SwiftUI][Realm] SwiftUI と Realm で TODOアプリを作る
[SwiftUI][Realm] SwiftUI と Realm で TODOAppを作る(2: Command パターン)
[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(4: Generics と KeyPath を使った command)
[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(5: 複数 command の実行)

今回は、作成と削除に対しての UNDO を実装していきます。

以下がわかるようになる気がします
・アプリへの UNDO/REDO の実装
・Command パターンを使った UNDO/REDO の実装

TODOアプリアーキテクチャノート

UNDO 機能を実装するときは、以下のような項目が検討項目になります。

・何を UNDO/REDO 対象とするか
・何をしたら UNDO/REDO バッファがクリアされるか
・どういう単位で UNDO/REDO できるか
・だれが UNDO を管理するか

今回のアプリでは、現時点では以下のようになる予定です。
・何を UNDO/REDO 対象とするか
→    TODOItem の作成・削除を対象とする。
・何をしたら UNDO/REDO バッファがクリアされるか
→   操作により作成/削除 すると REDO バッファはクリアされる。UNDO バッファのクリアはアプリがメモリ上から消された時(積極的に削除しないが、積極的に保存もしない)
・どういう単位で UNDO/REDO できるか
→    現時点では、作成のみ、削除のみ なので、そのままの単位
・だれが UNDO を管理するか
→    TODOItem 対象操作が UNDO 対象なので、TODOModel で管理する

前回 Model 操作に Command パターンを導入しましたので、実装した Command 毎にUNDO する機能を実装して、Model として UNDO/REDO できるようにします。

なお、REDO は、通常の execute と同じになるはずです。(そうでないとすると、UNDO が以前の状態に戻せていないということになります)

Command Protocol の UNDO 対応

前回作成した Command 向けプロトコル TODOModelCommand を UNDO にも対応させます。

UNDO 対応した TODOModelCommand protocol

protocol TODOModelCommand: AnyObject {
    func execute(_ model: TODOModel)
    func undo(_ model: TODOModel)
}

UNDO 時に実行される予定の undo メソッドを追加しました。

なお、REDO 時には、execute が再度呼ぶことで対応できるため、redo メソッドの定義は不要です。

TODOModel の拡張:UNDO/REDO バッファ追加

実行した command を記録しておくためのバッファを TODOModel に追加します。

・バッファは、UNDO バッファ・REDO バッファの2つを追加します。
・どちらのバッファも、First-In/First-Out で使用します。(Stack的に使用するということです)
・command の execute 時に、実行した command を UNDO バッファに記録します
・UNDO 時には、UNDO バッファの一番上の command を undo し、REDO バッファに移します
・REDO 時には、REDO バッファの一番上の command を execute し、UNDO バッファに移します

UNDO/REDO 可能かどうかで UNDO/REDO ボタンの enable/disable を制御したいため、UNDO/REDO が可能かどうかを確認する undoable/redoable というメソッドも追加し、外部から状態を取得できるようにします。

undo/redo バッファを持つ TODOModel

class TODOModel: ObservableObject{
    var config: Realm.Configuration
    var undoStack: [TODOModelCommand] = []
    var redoStack: [TODOModelCommand] = []
    
    init() {
        config = Realm.Configuration()
    }
    
    var realm: Realm {
        return try! Realm(configuration: config)
    }
    
    var items: Results {
        realm.objects(TODOItem.self)
    }

    func itemFromID(_ id: TODOItem.ID) -> TODOItem? {
        items.first(where: {$0.id == id})
    }
    
    func executeCommand(_ command: TODOModelCommand) {
        redoStack = []
        command.execute(self)
        undoStack.append(command)
    }
    
    var undoable: Bool {
        return !undoStack.isEmpty
    }
    var redoable: Bool {
        return !redoStack.isEmpty
    }
    
    func undo() {
        guard let undoCommand = undoStack.popLast() else { return }
        undoCommand.undo(self)
        redoStack.append(undoCommand)
    }
    
    func redo() {
        guard let redoCommand = redoStack.popLast() else { return }
        redoCommand.execute(self)
        undoStack.append(redoCommand)
    }
}

CreateTODOItemCommand の UNDO対応

TODOItem を作成する操作の UNDO は、作成した要素を無かったことにすることです。つまり、作成した要素の削除です

このコマンドの undo は、execute で作成した要素を削除する という実装になります。そのために、execute 実行時に作成した要素を id を保持しておくようにしておき、undo 時に、削除するようにします。

また、redo 時を考慮して、ID 指定が必要であれば、指定 ID を持つ要素として作成します。

UNDO 対応 CreateTODOItemCommand

    class CreateTODOItemCommand: TODOModelCommand {
        var id: TODOItem.ID? = nil
        var title: String = ""
        var detail: String = ""
        
        init(_ title: String, detail: String = "") {
            self.title = title
            self.detail = detail
        }
        
        func execute(_ model: TODOModel) {
            let newItem = TODOItem()
            newItem.id = self.id ?? UUID()
            newItem.title = title
            newItem.detail = detail
            
            try! model.realm.write {
                model.realm.add(newItem)
            }
            id = newItem.id
        }
        func undo(_ model: TODOModel) {
            guard let id = self.id,
                  let item = model.itemFromID(id) else { return }
            try! model.realm.write {
                model.realm.delete(item)
            }
        }   
    }

RemoveTODOItemCommand の UNDO対応

TODOItem を削除する操作の UNDO は、削除した要素を再作成することです。

ここでは、削除した要素のプロパティを記録しておき、UNDO 時に、そのプロパティを使って、再作成することにします。

アプリ側で振った ID も記憶しておいて、同じ ID になるように作成しています。

なお、title, detail は、そのコマンドがまだ execute されていない時には、nil を保持するようにして、コマンドがすでに実行されたかをチェックできるようにしています。

UNDO 対応 CreateTODOItemCommand

    class RemoveTODOItemCommand: TODOModelCommand {
        var id: UUID
        var title: String? = nil
        var detail: String? = nil
        
        init(_ id: TODOItem.ID) {
            self.id = id
        }
        
        func execute(_ model: TODOModel) {
            // save item info
            guard let itemToBeRemoved = model.itemFromID(self.id) else { return } // no item
            self.title = itemToBeRemoved.title
            self.detail = itemToBeRemoved.detail
            try! model.realm.write {
                model.realm.delete(itemToBeRemoved)
            }
        }
        func undo(_ model: TODOModel) {
            guard let title = self.title,
                  let detail = self.detail else { return }
            let item = TODOItem()
            item.id = self.id
            item.title = title
            item.detail = detail
            try! model.realm.write {
                model.realm.add(item)
                self.title = nil
                self.detail = nil
            }
        }
    }

View の UNDO 対応

iPhone では、デバイスをシェイクすると UNDO するのがよくある実装ですが、ここでは、ボタンを配置して、UNDO/REDO できるようにします。

画面下の位置に、UNDO ボタン, REDO ボタンとして配置します。それぞれ 実行可能でない時には、disable になるようにします。ViewModel の undo, redo, undoable, redoable はすぐに実装予定です。

View への UNDO/REDO ボタン配置

                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: {
                        viewModel.undo()
                    }, label: {
                        Text("UNDO")
                    })
                        .disabled(!viewModel.undoable)
                    Button(action: {
                        viewModel.redo()
                    }, label: {
                        Text("REDO")
                    })
                        .disabled(!viewModel.redoable)
                    Spacer()
                }

ViewMode の UNDO 対応

ViewModel は、Model と View を接続します。具体的には、以下の情報です。
・UNDO 実施
・REDO 実施
・UNDO 可能か確認
・REDO 可能か確認

すでに、Model 側には用意してありますので、接続するだけになります。

UNDO 対応 ViewModel

//
//  ViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2021/12/30
//  © 2021  SmallDeskSoftware
//

import Foundation
import SwiftUI
import RealmSwift

class ViewModel: ObservableObject {
    @Published var model: TODOModel = TODOModel()
    
    var todoItems: Results {
        model.items
    }
    
    func undo() {
        guard undoable else { return }
        objectWillChange.send()
        model.undo()
    }
    
    func redo() {
        guard redoable else { return }
        objectWillChange.send()
        model.redo()
    }
    
    var undoable: Bool {
        return model.undoable
    }
    var redoable: Bool {
        return model.redoable
    }
    
    func addTODOItem(_ title: String, detail: String = "") {
        let command = TODOModel.CreateTODOItemCommand(title, detail: detail)
        objectWillChange.send()
        model.executeCommand(command)
    }
    
    func removeTODOItem(_ id: TODOItem.ID) {
        let command = TODOModel.RemoveTODOItemCommand(id)
        objectWillChange.send()
        model.executeCommand(command)
    }
}

まとめ

Command パターンを使って、UNDO/REDO 処理を実装しました。

ここまでで 以下のように動作するアプリになっています。

# 要素取得結果をソートしていないので、表示順序が UNDO 等によって変更されてしまっています。

ここまでに実装した機能概要
  • Command パターンを使った UNDO/REDO 処理を実装した

説明は以上です。
不明な点やおかしな点ありましたら、コメントもしくはこちらまで。

ここまでのコード

参考までにここまでに実装したコードを転記しておきます。

Model.swift

//
//  Model.swift
//
//  Created by : Tomoaki Yagishita on 2021/12/30
//  © 2021  SmallDeskSoftware
//

import Foundation
import RealmSwift

class TODOModel: ObservableObject{
    var config: Realm.Configuration
    var undoStack: [TODOModelCommand] = []
    var redoStack: [TODOModelCommand] = []
    
    init() {
        config = Realm.Configuration()
    }
    
    var realm: Realm {
        return try! Realm(configuration: config)
    }
    
    var items: Results {
        realm.objects(TODOItem.self)
    }

    func itemFromID(_ id: TODOItem.ID) -> TODOItem? {
        items.first(where: {$0.id == id})
    }
    
    func executeCommand(_ command: TODOModelCommand) {
        redoStack = []
        command.execute(self)
        undoStack.append(command)
    }
    
    var undoable: Bool {
        return !undoStack.isEmpty
    }
    var redoable: Bool {
        return !redoStack.isEmpty
    }
    
    func undo() {
        guard let undoCommand = undoStack.popLast() else { return }
        undoCommand.undo(self)
        redoStack.append(undoCommand)
    }
    
    func redo() {
        guard let redoCommand = redoStack.popLast() else { return }
        redoCommand.execute(self)
        undoStack.append(redoCommand)
    }
}

class TODOItem: Object, Identifiable {
    @Persisted(primaryKey: true) var id: UUID = UUID()
    @Persisted var title: String
    @Persisted var detail: String
}

Model+Commands.swift

//
//  Model+Command.swift
//
//  Created by : Tomoaki Yagishita on 2022/01/12
//  © 2022  SmallDeskSoftware
//

import Foundation

protocol TODOModelCommand: AnyObject {
    func execute(_ model: TODOModel)
    func undo(_ model: TODOModel)
}

extension TODOModel {
    class CreateTODOItemCommand: TODOModelCommand {
        var id: TODOItem.ID? = nil
        var title: String = ""
        var detail: String = ""
        
        init(_ title: String, detail: String = "") {
            self.title = title
            self.detail = detail
        }
        
        func execute(_ model: TODOModel) {
            let newItem = TODOItem()
            newItem.id = self.id ?? UUID()
            newItem.title = title
            newItem.detail = detail
            
            try! model.realm.write {
                model.realm.add(newItem)
            }
            id = newItem.id
        }
        func undo(_ model: TODOModel) {
            guard let id = self.id,
                  let item = model.itemFromID(id) else { return }
            try! model.realm.write {
                model.realm.delete(item)
            }
        }
        
    }
    class RemoveTODOItemCommand: TODOModelCommand {
        var id: UUID
        var title: String? = nil
        var detail: String? = nil
        
        init(_ id: TODOItem.ID) {
            self.id = id
        }
        
        func execute(_ model: TODOModel) {
            // save item info
            guard let itemToBeRemoved = model.itemFromID(self.id) else {
                return } // no item
            self.title = itemToBeRemoved.title
            self.detail = itemToBeRemoved.detail
            try! model.realm.write {
                model.realm.delete(itemToBeRemoved)
            }
        }
        func undo(_ model: TODOModel) {
            guard let title = self.title,
                  let detail = self.detail else { return }
            let item = TODOItem()
            item.id = self.id
            item.title = title
            item.detail = detail
            try! model.realm.write {
                model.realm.add(item)
                self.title = nil
                self.detail = nil
            }
        }
    }
}

ViewModel.swift

//
//  ViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2021/12/30
//  © 2021  SmallDeskSoftware
//

import Foundation
import SwiftUI
import RealmSwift

class ViewModel: ObservableObject {
    @Published var model: TODOModel = TODOModel()
    
    var todoItems: Results {
        model.items
    }
    
    func undo() {
        guard undoable else { return }
        objectWillChange.send()
        model.undo()
    }
    
    func redo() {
        guard redoable else { return }
        objectWillChange.send()
        model.redo()
    }
    
    var undoable: Bool {
        return model.undoable
    }
    var redoable: Bool {
        return model.redoable
    }
    
    func addTODOItem(_ title: String, detail: String = "") {
        let command = TODOModel.CreateTODOItemCommand(title, detail: detail)
        objectWillChange.send()
        model.executeCommand(command)
    }
    
    func removeTODOItem(_ id: TODOItem.ID) {
        let command = TODOModel.RemoveTODOItemCommand(id)
        objectWillChange.send()
        model.executeCommand(command)
    }
}

MainView.swift

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/01/10
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct MainView: View {
    @EnvironmentObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.todoItems.freeze()) { item in
                    Text("\(item.title)")
                }
                .onDelete { indexSet in
                    if let index = indexSet.first {
                        viewModel.removeTODOItem(viewModel.todoItems[index].id)
                    }
                }
            }
            .navigationTitle("RealmTODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    HStack {
                    #if os(iOS)
                    EditButton()
                    #endif
                    Text("#: \(viewModel.todoItems.count)")
                    }
                }
                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    Button(action: {
                        let dateFormatter = DateFormatter()
                        dateFormatter.dateStyle = .short
                        dateFormatter.timeStyle = .long
                        let itemName = dateFormatter.string(from: Date())
                        viewModel.addTODOItem(itemName)
                    }, label: {
                        Image(systemName: "plus")
                    })
                }
                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: {
                        viewModel.undo()
                    }, label: {
                        Text("UNDO")
                    })
                        .disabled(!viewModel.undoable)
                    Button(action: {
                        viewModel.redo()
                    }, label: {
                        Text("REDO")
                    })
                        .disabled(!viewModel.redoable)
                    Spacer()
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MainView()
            .environmentObject(ViewModel())
    }
}

RealmTODOApp.swift

//
//  RealmTODOApp.swift
//
//  Created by : Tomoaki Yagishita on 2022/01/10
//  © 2022  SmallDeskSoftware
//

import SwiftUI

@main
struct RealmTODOApp: App {
    @StateObject var viewModel = ViewModel()
    var body: some Scene {
        WindowGroup {
            MainView()
                .environmentObject(viewModel)
        }
    }
}

コメントを残す

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