[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(4: Generics と KeyPath を使った command)

     
⌛️ 9 min.
KeyPath を使って、Command を作ることで、Command 作成を効率化します。

環境&対象

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

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

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

前回の実装で Command パターンが、UNDO 実装に便利なことが分かりましたが、要素のプロパティ変更の Command を考えると すこし手間がかかりそうに感じます。

Swift の KeyPath をうまく使うことで、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 を作る(5: 複数 command の実行)

今回は、TODOItem のプロパティ title と detail を変更するコマンドを作っていきます。

title や detail を変更するために、詳細ビューを導入していきます。このときに、View 間の関連性を低減させるための Coordinatorを導入し、View に直接遷移先の View を記述しないようにしてみます。

以下がわかるようになる気がします
・SwiftUI の TextField の使い方
・Realm のプロパティの更新方法
・Swift の KeyPath の使い所
・Coordinator を使ったビュー間の依存関係の低減

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

Coordinator と KeyPath について少し解説します

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

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

Coordinator

Coordinator は、ビュー間の依存関係を低減するために考案されたものです。

例えば、次の例を見てください。動物(Cat,Dog,Bird,Cow) が定義されていて、クリックすると詳細ビューが表示されるコードです。

ContentView が次のビューを知っているケース

struct ContentView: View {
    var animals: [String] = ["Cat", "Dog", "Bird", "Cow"]
    var body: some View {
        NavigationView {
            List {
                ForEach(animals, id: \.self) { item in
                    NavigationLink(destination: DetailView(name: item),
                                   label: { Text(item) })
                }
            }
        }
            .padding()
    }
}

struct DetailView: View {
    let name: String
    var body: some View {
        VStack {
            Text("Animal Detail").font(.title)
            Text(name)
        }
    }
}

このコードでは、ContentView は、NavigationLink の destination にどのビューを指定するかを知っている必要があります。ここでは、すべての動物に対して、DetailView を使っています。

ですが、例えば、哺乳類と鳥類で別のビューを使って表示したい時にはどうすればよいでしょうか?
(それぞれに、MammalDetailView と BirdDetailView が用意されたとします。)

いろいろな方法がありますが、以下のように ContentView 内での条件分岐を実装するのも1つです。

ContentView で 遷移先を決めるケース

struct ContentView: View {
    var animals: [String] = ["Cat", "Dog", "Bird", "Cow"]
    var body: some View {
        NavigationView {
            List {
                ForEach(animals, id: \.self) { item in
                    if item != "Bird" {
                        NavigationLink(destination: MammalDetailView(name:item),
                                       label: { Text(item) })
                    } else {
                        NavigationLink(destination: BirdDetailView(name:item),
                                       label: { Text(item) })
                    }
                }
            }
        }
            .padding()
    }
}

struct MammalDetailView: View {
    let name: String
    var body: some View {
        VStack {
            Text("Animal Detail").font(.title)
            Text("Mammal").font(.caption)
            Text(name)
        }
    }
}

struct BirdDetailView: View {
    let name: String
    var body: some View {
        VStack {
            Text("Animal Detail").font(.title)
            Text("Bird").font(.caption)
            Text(name)
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

この分岐方法だと、DetailView の分別が複雑になるにしたがって、ContentView が複雑になっていきます。

本来、要素のリストをユーザーに提示して、その選択を受け取り、”どこか” に遷移する というのが ContentView の当初の責務だと思いますが、上記の実装は、ContentView が選択された要素に応じた適切な遷移先を選択するという責務まで加わったということになります。

クラスに対しての責務分割という視点で、このような実装はあまりよくないです。1つのクラスに複数の責務が割り当てられるとコードが複雑になります。

適切なビューを選択するという責務を、ContentView から切り離し、新たな Coordinator クラスを使って、選択するようにします。

Coordinator クラスと ContentView

class Coordinator: ObservableObject {
    @ViewBuilder
    func nextView(_ item: String) -> some View {
        if item == "Bird" {
            BirdDetailView(name:item)
        }
        MammalDetailView(name:item)
    }
}

struct ContentView: View {
    var coordinator = Coordinator()
    var animals: [String] = ["Cat", "Dog", "Bird", "Cow"]
    var body: some View {
        NavigationView {
            List {
                ForEach(animals, id: \.self) { item in
                    NavigationLink(destination: coordinator.nextView(item),
                                   label: {Text(item)})
                }
            }
        }
            .padding()
    }
}

遷移先の View を Coordinator が返すようにすることで、ContentView 的には、遷移先について知らなくて良いことになります。

上記の実装では、item だけ渡していますが、アプリの状態等も渡すようにすると、選択要素+アプリの状態で次の遷移先を決めるという形にできます。

KeyPath

Swift 4.2 から導入された機能です。

動的なプロパティへのアクセスが可能になります。

KeyPath については、以下の記事で説明してます。
[Swift] KeyPath の理解

DetailView の実装

TODOItem の Title と Detail を編集できるようなビューを作成します。

入力フィールド

SwiftUI で Text を変更させるためのビューは、TextField です。

Apple のドキュメントは、こちら

即時反映したい?

ただし、TextField に直接 TODOItem の title や detail を渡してしまうと、ユーザーが変更したタイミングでその都度 値が設定されてしまいます。

例えば、変更をキャンセルしようとしても、できません。

入力した値をキャンセルできた方が良い気がするので、ビューの下の方に “OK”, “Cancel” ボタンを配置して、”OK” が押下されたら入力された値を反映し、”Cancel” が押下された時には、入力された値を破棄するようにしてみます。

具体的には、一時的に入力値を保存するために @State 定義の変数を用意して、ボタン押下時にTODOItem を更新するようにします。
その他の注意点として、TODOItem の持つ title や detail は、表示直前に取得して それぞれの @State 変数にセットしなければいけません。

SwiftUI の特徴として、メモリ上の View を再利用するかどうかは、SwiftUI が判断し、アプリ側で制御することができません。ですので、今回のケースでは、それぞれの TextField の onAppear で変更対象要素のプロパティを取得してくる必要があります。

TextField 下に、2つのボタンを配置して、それぞれ “OK”, “Cancel” ボタンにしています。対応する action は、Environment を使った ビューの close のみ実装しています。(TODOItem のプロパティ変更は後から実装していきます)

以下のようなビューにしました。(デザインはあまり考慮していません)

DetailView

DetailView 実装

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: {
                    dismiss()
                }, label: {
                    Text("update").font(.title)
                })
                    .buttonStyle(.borderless)
                Spacer()
            }
        }
        .navigationBarBackButtonHidden(true)
    }
}

細かい点としては、
・List を使用してレイアウトしています
・List 内で Button を使う時は、.borderless にしないと、押下イベントがきちんと処理されません
・自前の cancel/update ボタンで閉じるようにしているので、ナビゲーションバーの戻るボタンは非表示にしています
・Button の action は、dismiss だけで、要素変更のコードは実装していません。 @Environment(\.dismiss) については、以下の記事を参照ください。
SwiftUI2021 [SwiftUI] presentationMode は iOS15/macOS12 で deprecated になりました

ビューとしてのおおよそは完成しました。(変更機能を実装していません)

Coordinator の実装

画面遷移を管理する Coordinator を実装していきます。

現時点では、Coordinator は、要素を受け取り、受け取った要素をセットした DetailView を返せば OK です。

Coordinator 実装

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

MainViewのアップデート

Coordinator で遷移するように MainView をアップデートします。
影響のある List 周りを抜粋すると以下のようになります。
NavigationLink の destination に coordinator から返す View を渡すようにしています。

            List {
                ForEach(viewModel.todoItems.freeze()) { item in
                    NavigationLink(destination: { coordinator.nextView(item) },
                                   label: { Text(item.title) })
                }
                .onDelete { indexSet in
                    if let index = indexSet.first {
                        viewModel.removeTODOItem(viewModel.todoItems[index].id)
                    }
                }
            }

このアップデートで、ビュー周りは完成です。続いて、機能面を実装していきます。

Title を変更する Command

TODOItem の title を更新するコマンドを作成します。

前回から、UNDO にも対応していますので、UNDO できることも考慮して実装していきます。

UNDO 対応の UpdateTODOItemTitle コマンド

    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, DetailView について、以下のように変更しています。

ViewModel 変更

extension ViewModel {
    func updateTODOItemTitle(_ id: TODOItem.ID, newTitle: String) {
        let command = TODOModel.UpdateTODOItemTitle(id, newTitle: newTitle)
        objectWillChange.send()
        model.executeCommand(command)
    }
}

DetailView変更

                Button(action: {
                    if title != item.title {
                        viewModel.updateTODOItemTitle(item.id, newTitle: title)
                    }
                    dismiss()
                }, label: {
                    Text("update").font(.title)
                })
                    .buttonStyle(.borderless)

DetailView の “update” ボタンが押下された時に以下のような処理を追加しています。
・入力された title がすでに設定されている title と異なっているか?
・異なっているなら、ViewModel に変更依頼

Note: なぜ、title が異なっているかをチェックするのか?

現在実装している コマンドでは、新しくセットする値が、既存の値と同じであるかをチェックしていません。ですので、同じ値であってもセットすれば新しい UNDO 対象のコマンドとして 記録されます。たいていのケースで、このような実際の変更を起こさないコマンドは UNDO 対象にはしたくないはずです。今回のアプリでは、チェックして コマンドを実装しないというコードにしています。

以下のように、動きました。

このまま UpdateTODOItemDetail コマンドを作っていくのも良いのですが、効率的に command を作る方法を検討していきます。

汎用的な Command に 改良する

title をアップデートするコマンド UpdateTODOItemTitle は、(当たり前ですが) TODOItem の “title” というプロパティしかアップデートできません。

アプリケーションを拡張して、プロパティが増えてきた時に、増えたプロパティ個別に全ての command を作るのは手間がかかりそうです。

Swift の KeyPath と Generics を使用して、プロパティを更新するための汎用的な command を作ってみます。
Swift では、動的にプロパティにアクセスするための方法として KeyPath が用意されています。

この KeyPath を使用して、外部からどのプロパティをアップデートするのかを指定するようにしてみます。

KeyPath は、プロパティと その型をセットで保持しますので、まずは、title の型である String 型を持つ プロパティを指す KeyPath を使用するものを作ってみます。

# 対象の TODOItem は、class なので、ReferenceWritableKeyPath を使用することになります。

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

このコマンドを呼ぶときには、次のようになります。(ViewModel 内のコード)

extension ViewModel {
    func updateTODOItemTitle(_ id: TODOItem.ID, newTitle: String) {
        //let command = TODOModel.UpdateTODOItemTitle(id, newTitle: newTitle)
        let command = TODOModel.UpdateTODOItemString(id, keyPath: \TODOItem.title, newValue: newTitle)
        objectWillChange.send()
        model.executeCommand(command)
    }
}

外部からアップデートするプロパティを指定できるようになったので、プロパティ毎のコマンドは不要になりましたが、まだ String 型であるという性質を使用しているので、このままでは、Int や Double 等 型の異なるプロパティについて、それぞれの 型ごとの command が必要になってしまい、いまいちです。

この型は、class と プロパティが確定すれば、この型も確定できるので、PartialKeyPath という型が用意されています。

同様のアイデアで、この部分を Generics を使って、一般化します。

Generics と KeyPath を使用した command

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

あまり UpdateTODOItemString との差異がわからないかもしれませんが、呼ぶ側でもあまり違いは発生しません。

ViewModel で 呼んでいる箇所

    func updateTODOItemTitle(_ id: TODOItem.ID, newTitle: String) {
        //let command = TODOModel.UpdateTODOItemTitle(id, newTitle: newTitle)
        //let command = TODOModel.UpdateTODOItemString(id, keyPath: \TODOItem.title, newValue: newTitle)
        let command = TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.title, newValue: newTitle)
        objectWillChange.send()
        model.executeCommand(command)
    }

呼び出す側はメソッド名以外まったく同じです。ですが、内部のコードでは、プロパティもその型も直接 記述しないようにできています。つまり、String 以外の型の プロパティでも変更できるようになったということです。

Detail を変更する Command

今作った UpdateTODOItemProperty を使用すれば良いので、ViewModel 側で detail を指定してコマンドを実行するようにします。

detail を対象にアップデートするコマンドを実行するメソッド(in ViewModel)

    func updateTODOItemDetail(_ id: TODOItem.ID, newDetail: String) {
        let command = TODOModel.UpdateTODOItemProperty(id, keyPath: \TODOItem.detail, newValue: newDetail)
        objectWillChange.send()
        model.executeCommand(command)
    }
}

入力された値を使って ViewModelに変更依頼するコード

                Button(action: {
                    if title != item.title {
                        viewModel.updateTODOItemTitle(item.id, newTitle: title)
                    }
                    if detail != item.detail {
                        viewModel.updateTODOItemDetail(item.id, newDetail: detail)
                    }
                    dismiss()
                }, label: {
                    Text("update").font(.title)
                })
                    .buttonStyle(.borderless)

問題点:複数のコマンドが実行されるケースがある

上のコードを見て、違和感を覚えるかもしれません。

title と detail の両方が変更されると、2つのコマンドが実行されます。つまり、UNDO スタックに2つのコマンドが記録されてしまいます。

この問題を解決するには、「複数のコマンドを1つのコマンド相当にして UNDO スタックに記録する」ということが必要になります。

実際の解決策等は次回以降に説明します。

まとめ

Command パターンに、KeyPath と Generics を使用して、要素のプロパティを更新する汎用的なコマンドを作りました。

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

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

ここまでに実装した機能概要
  • KeyPath をつかうと、動的にプロパティを指定できる
  • Generics を使うと 機能を汎用的にできる
  • 推測可能なパラメータを Generics に使うと、暗黙的に指定される

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

ここまでのコード

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

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

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: { 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())
    }
}

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: {
                    if title != item.title {
                        viewModel.updateTODOItemTitle(item.id, newTitle: title)
                    }
                    if detail != item.detail {
                        viewModel.updateTODOItemDetail(item.id, newDetail: 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)
        }
    }
}

コメントを残す

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