[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(5: 複数 command の実行)

     
⌛️ 6 min.
複数の Command をまとめて実行(1回の 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 で TODOアプリを作る(3: UNDO/REDO)
[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(4: Generics と KeyPath を使った command)

たいていの場合は、1つのプロパティ変更は、1回の操作として扱われ、UNDO によって 1つづつ 元に戻されることになりますが、場合によっては、複数のプロパティ変更を 1回の操作として扱いたい時があります。

今回は、そのようなケースへの対応方法を説明します。

以下がわかるようになる気がします
・Command パターンの応用
・Composite パターン
・UNDO の単位の設定方法

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

TODOModel は、渡された Command を1つの単位として UNDO するようになっています。

この前提を変更してしまうと全体の見直しが必要になるので、現在の前提を変更せずに実装する方法を考えます。

以下が方針です。
・TODOModel に渡された Command が 1つの UNDO 単位であることは変わらない
・複数の Command をまとめる Command を作れるようにする

Composite パターンを使って、Command をまとめる Command を考えていきます。

Composite パターン

Composite パターンは、Tree 構造の再帰的な構造を表す時に使用されるパターンです。

Wikipedia は、こちら

今回の例で言うと、TODOModelCommand に準拠する Command として、複数の Command を内包する Command を作成すると言うことです。

複数の Command を実行する Command 実装

複数の Command(TODOModelCommand) を実行する Command を作成していきます。

MultipleCommand の実装

作ろうとしている MultipleCommand は、TODOModelCommand に準拠しつつ、内部に複数の TODOModelCommand を持ち、順番に実行するという Command になります。

実行順序を考える時には、Execute の際には、保持している順番に実行し、UNDO の際には、逆順に実行するようにしておきます。(UNDO 時の逆順実行は、オーバースペックな気もしますが念の為)

なお、1つめの Command が作成した要素を 2つめの Command で操作することは、この MultipleCommand では想定していません。

MultipleCommand

extension TODOModel {
    class MultipleCommand: TODOModelCommand {
        var commands: [TODOModelCommand] = []
        func add(_ command: TODOModelCommand) {
            commands.append(command)
        }
        func execute(_ model: TODOModel) {
            for command in commands {
                command.execute(model)
            }
        }
        
        func undo(_ model: TODOModel) {
            for command in commands.reversed() {
                command.undo(model)
            }
        }
    }
}

MultipleCommand を使った操作

前回、詳細ビューで title と detail を変更すると、Command が個別に実行され UNDO が2つ増える状態になっていましたが、MultipleCommand を使用し、1回の操作として扱われるように修正します。
ViewModel から Model への修正時のコードを、MultipleCommand を使用するように変更します。

呼び出す側で、title, detail の変更の必要性をチェックし、変更が不要なパラメータには nil を渡すことを想定しています。

ViewModel での変更(抜粋)

    func updateTODOItemTitleAndDetail(_ id: TODOItem.ID, newTitle: String?, newDetail: String?) {
        let command = TODOModel.MultipleCommand()
        if let newTitle = newTitle {
            command.add(TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.title, newValue: newTitle))
        }
        if let newDetail = newDetail {
            command.add(TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.detail, newValue: newDetail))
        }
        objectWillChange.send()
        model.executeCommand(command)
    }
}

DetailView での変更(抜粋)

  Button(action: {
    viewModel.updateTODOItemTitleAndDetail(item.id,
                                           newTitle: title == item.title ? nil : title,
                                           newDetail: detail == item.detail ? nil: detail)
    dismiss()
  }, label: {
    Text("update").font(.title)
  })

MainView の更新

操作結果が分かりにくいので、title だけでなく、detail も MainView で表示するようにしました。

MainView での変更(抜粋)

    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.todoItems.freeze()) { item in
                    NavigationLink(destination: { coordinator.nextView(item) },
                                   label: {
                        VStack(alignment: .leading) {
                            Text("Title: \(item.title)")
                            Text("Detail: \(item.detail == "" ? "-" : item.detail)").font(.caption)
                        }
                    }).frame(maxWidth: .infinity)
                }
                .onDelete { indexSet in
                    if let index = indexSet.first {
                        viewModel.removeTODOItem(viewModel.todoItems[index].id)
                    }
                }
            }
            .navigationTitle("RealmTODO")
            .navigationBarTitleDisplayMode(.inline)
            .... (省略)

まとめ

Command パターンで 複数の Command を 1つの Command として実行する方法を説明しました。

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

今回の実装概要
  • MultipleCommand を作成し、複数 Command を 1つの Command として実行できる
  • Composite パターンを使用して、MultipleCommand を実装した

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

ここまでのコード

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

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<TODOItem> {
        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
}

extension TODOItem {
    static func previewItem() -> TODOItem {
        let item = TODOItem()
        item.id = UUID()
        item.title = "Title"
        item.detail = "Detail"
        return item
    }
}

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 MultipleCommand: TODOModelCommand {
        var commands: [TODOModelCommand] = []
        func add(_ command: TODOModelCommand) {
            commands.append(command)
        }
        func execute(_ model: TODOModel) {
            for command in commands {
                command.execute(model)
            }
        }
        
        func undo(_ model: TODOModel) {
            for command in commands.reversed() {
                command.undo(model)
            }
        }
    }
}

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
            }
        }
    }
    
    class UpdateTODOItemProperty<T>: TODOModelCommand {
        let id: TODOItem.ID
        let keyPath: ReferenceWritableKeyPath<TODOItem, T>
        let newValue: T
        var oldValue: T?
        init(_ id: TODOItem.ID, keyPath: ReferenceWritableKeyPath<TODOItem, T>,  newValue: T) {
            self.id = id
            self.keyPath = keyPath
            self.newValue = newValue
            self.oldValue = nil
        }
        
        func execute(_ model: TODOModel) {
            guard let item = model.itemFromID(id) else { return }
            try! model.realm.write {
                self.oldValue = item[keyPath: keyPath]
                item[keyPath: keyPath] = newValue
            }
        }
        
        func undo(_ model: TODOModel) {
            guard let item = model.itemFromID(id) else { return }
            guard let oldValue = oldValue else { return } // not executed yet?
            try! model.realm.write {
                item[keyPath: keyPath] = oldValue
            }
        }
    }
    
    class UpdateTODOItemString: TODOModelCommand {
        let id: TODOItem.ID
        let keyPath: ReferenceWritableKeyPath<TODOItem, String>
        let newValue: String
        var oldValue: String?
        init(_ id: TODOItem.ID, keyPath: ReferenceWritableKeyPath<TODOItem, String>,  newValue: String) {
            self.id = id
            self.keyPath = keyPath
            self.newValue = newValue
            self.oldValue = nil
        }
        
        func execute(_ model: TODOModel) {
            guard let item = model.itemFromID(id) else { return }
            try! model.realm.write {
                self.oldValue = item[keyPath: keyPath]
                item[keyPath: keyPath] = newValue
            }
        }
        
        func undo(_ model: TODOModel) {
            guard let item = model.itemFromID(id) else { return }
            guard let oldValue = oldValue else { return } // not executed yet?
            try! model.realm.write {
                item[keyPath: keyPath] = oldValue
            }
        }
    }
    
    class UpdateTODOItemTitle: TODOModelCommand {
        let id: TODOItem.ID
        let newTitle: String
        var oldTitle: String?
        
        init(_ id: TODOItem.ID, newTitle: String) {
            self.id = id
            self.newTitle = newTitle
            self.oldTitle = nil
        }
        
        func execute(_ model: TODOModel) {
            guard let item = model.itemFromID(id) else { return }
            try! model.realm.write {
                self.oldTitle = item.title
                item.title = newTitle
            }
        }
        
        func undo(_ model: TODOModel) {
            guard let item = model.itemFromID(id) else { return }
            guard let oldTitle = oldTitle else { return } // not executed yet?
            try! model.realm.write {
                item.title = oldTitle
            }
        }
    }
}

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<TODOItem> {
        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
    }
    

}

// MARK: TODOItem edit
extension ViewModel {
    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)
    }
    func updateTODOItemTitle(_ id: TODOItem.ID, newTitle: String) {
        let command = TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.title, newValue: newTitle)
        objectWillChange.send()
        model.executeCommand(command)
    }
    func updateTODOItemDetail(_ id: TODOItem.ID, newDetail: String) {
        let command = TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.detail, newValue: newDetail)
        objectWillChange.send()
        model.executeCommand(command)
    }
    func updateTODOItemTitleAndDetail(_ id: TODOItem.ID, newTitle: String?, newDetail: String?) {
        let command = TODOModel.MultipleCommand()
        if let newTitle = newTitle {
            command.add(TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.title, newValue: newTitle))
        }
        if let newDetail = newDetail {
            command.add(TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.detail, newValue: newDetail))
        }
        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
    let coordinator = Coordinator()
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.todoItems.freeze()) { item in
                    NavigationLink(destination: { coordinator.nextView(item) },
                                   label: {
                        VStack(alignment: .leading) {
                            Text("Title: \(item.title)")
                            Text("Detail: \(item.detail == "" ? "-" : item.detail)").font(.caption)
                        }
                    }).frame(maxWidth: .infinity)
                }
                .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())
    }
}

DetailView.swift

//
//  DetailView.swift
//
//  Created by : Tomoaki Yagishita on 2022/01/12
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct DetailView: View {
    @EnvironmentObject var viewModel: ViewModel
    @Environment(\.dismiss) var dismiss

    let item: TODOItem
    @State private var title = ""
    @State private var detail = ""

    var body: some View {
        List {
            HStack {
                Text("Title :").font(.caption).frame(width: 40)
                TextField("Title", text: $title)
                    .onAppear {
                        self.title = item.title
                    }
                    .textFieldStyle(.roundedBorder)
            }
            HStack {
                Text("Detail :").font(.caption).frame(width: 40)
                TextField("Detail", text: $detail)
                    .onAppear {
                        self.detail = item.detail
                    }
                    .textFieldStyle(.roundedBorder)
            }
            HStack {
                Spacer()
                Button(action: {
                    dismiss()
                }, label: {
                    Text("cancel").font(.title)
                })
                    .buttonStyle(.borderless)
                Spacer()
                Button(action: {
                    viewModel.updateTODOItemTitleAndDetail(item.id,
                                                           newTitle: title == item.title ? nil : title,
                                                           newDetail: detail == item.detail ? nil: detail)
                    dismiss()
                }, label: {
                    Text("update").font(.title)
                })
                    .buttonStyle(.borderless)
                Spacer()
            }
        }
        .navigationBarBackButtonHidden(true)
    }
}
struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
        DetailView(item: TODOItem.previewItem())
            .environmentObject(ViewModel())
    }
}

Coordinator.swift

//
//  Router.swift
//
//  Created by : Tomoaki Yagishita on 2021/12/31
//  © 2021  SmallDeskSoftware
//

import Foundation
import SwiftUI

class Coordinator: ObservableObject {
    @ViewBuilder
    func nextView(_ item: TODOItem) -> some View {
        DetailView(item: item)
    }
}

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)
        }
    }
}

コメントを残す

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