[SwiftUI][Realm] SwiftUI と Realm で TODOAppを作る(2: Command パターン)

     
⌛️ 4 min.
Refactoring しつつ TODOアプリとしての機能を追加していきます。

環境&対象

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

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

TODOアプリアーキテクチャ

SwiftUI と Realm を組み合わせた TODOアプリ を作ってきています。

前回までで、プロジェクトセットアップ、新規要素作成ボタン(と作成)を実装しました。

シリーズ記事

[SwiftUI][Realm] SwiftUI と Realm で TODOアプリを作る
[SwiftUI][Realm] SwiftUI と Realm で TODOアプリを作る(3: UNDO/REDO)
[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(4: Generics と KeyPath を使った command)
[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(5: 複数 command の実行)

最低限の機能として、以下の機能実装を目指します。

  • 要素削除機能
  • 要素編集機能(Title と Detail の変更) (追記:記事が長くなりすぎたので、次回に移動しました)

Command パターン

個別に Realm オブジェクトを変更・削除していくのも良いのですが、将来的に UNDO 可能にしたいので、Command パターンを導入していきます。

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

Refactoring

ここまでに作成したコードを Refactoring していきます。
具体的には、TODOItem を作成する機能を Command にしていきます。

TODOApp での Command は、TODOModel の操作しかしない予定なので、TODOModel の extension に定義していきます。

CreateTODOItemCommand

実行するコードは前回のコード部分なので自明です。

作成するクラス CreateTODOItemCommand は、initializer で Title と Detail を受け取り、execute メソッドで、受け取った realm に 指定値を持つような TODOItem を作成することにします。

execute のコード自体は、前回作成した TODOModel の addTODOItem メソッドと同じです。


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

import Foundation

extension TODOModel {
    class CreateTODOItemCommand {
        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 = UUID()
            newItem.title = title
            newItem.detail = detail
            
            try! model.realm.write {
                model.realm.add(newItem)
            }
        }
    }
}

Commandを使って要素作成

作成した Command を使用して要素を作成していきます。
以下のように進めていきます。

  • TODOModel 側に、Command を実行するためのメソッドを用意
  • ViewModel 側を Command を実行して Model 変更するように変更

TODOModel 側に、Command を実行するためのメソッドを用意

TODOModel に、渡された Command を実行するメソッドを用意します。いまは、CreateTODOItemCommand しか作っていないので、受け取る Command は、CreateTODOItemCommand だけです。

なお、前回作成した addTODOItem メソッドは、Command を導入すると不要になるので、削除しています。


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

import Foundation
import RealmSwift

class TODOModel: ObservableObject{
    var config: Realm.Configuration

    init() {
        config = Realm.Configuration()
    }
    
    var realm: Realm {
        return try! Realm(configuration: config)
    }
    
    var items: Results {
        realm.objects(TODOItem.self)
    }
    func executeCommand(_ command: CreateTODOItemCommand) {
        command.execute(self)
    }
}

ViewModel 側を Command を実行して Model 変更するように変更

前回までの ViewModel は、TODOItemModel の addTODOItem を呼び出していましたが、この部分を Command を使用するように変更していきます。


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

import Foundation
import SwiftUI
import RealmSwift

class ViewModel: ObservableObject {
    @ObservedObject public var model: TODOModel = TODOModel()
    
    var todoItems: Results {
        model.items
    }
    
    func addTODOItem(_ title: String, detail: String = "") {
        let command = TODOModel.CreateTODOItemCommand(title, detail: detail)
        objectWillChange.send()
        model.executeCommand(command)
    }
}

View側は変更なし

Model の操作を Command パターンを使うように修正しただけなので、View のコードを修正する必要はありません。

一般化したCommand を受け取る

これまでに作成したコードでは、Model は、CreateTODOItemCommand を executeCommand メソッドで受け取っていました。今後、Command を複数作っていく予定ですので、一般化した Command を受け取るように修正していきます。

Abstract なCommand定義

まずは、CreateTODOItemCommand と今後作る Command の基底クラスを作成します。

Swift 言語としての実装方法は複数あります。例えば、純粋に、 Class の継承関係を使うことも考えられます。
ここでは、protocol を使って、定義していきます。

protocol が AnyObject に準拠することで、class へのみ適用できる protocol となります。


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

TODOModel で実行できる Command は、この protocol へ準拠させるようにします。

例えば、CreateTODOItemCommand は、以下のようになります。


    class CreateTODOItemCommand: TODOModelCommand {
        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 = UUID()
            newItem.title = title
            newItem.detail = detail
            
            try! model.realm.write {
                model.realm.add(newItem)
            }
        }
    }

Model が executeCommand で受け取るのは Abstract な Command

現在、TODOModel は、以下のメソッドで Command を受け取り実行しています。


    func executeCommand(_ command: CreateTODOItemCommand) {
        command.execute(self)
    }

このままでは、CreateTODOItemCommand しか受け取れないので、ここも TODOModelCommand を受け取るように修正していきます。


    func executeCommand(_ command: TODOModelCommand) {
        command.execute(self)
    }

ここまでの Refactoring で、新しいコマンドを定義し、TODOModel に実行させる用意ができたことになります。

TODOItem 削除

次に、TODOItem を削除する機能を実装していきます。

要素はすべて ID で管理していきますので、削除コマンドを作成する前に、TODOModel に、ID から要素を取得するメソッドを追加しておきます。


    func itemFromID(_ id: TODOItem.ID) -> TODOItem? {
        items.first(where: {$0.id == id})
    }

削除コマンド

指定した ID を持つ TODOItem を削除するコマンドを作成します。要素を削除するには、realm.delete を使用します。


    class RemoveTODOItemCommand {
        var id: UUID
        
        init(_ id: TODOItem.ID) {
            self.id = id
        }
        
        func execute(_ model: TODOModel) {
            guard let itemToBeRemoved = model.itemFromID(self.id) else { return } // no item
            try! model.realm.write {
                model.realm.delete(itemToBeRemoved)
            }
        }
    }

削除のための UI

TODOModel で実行するための Command は作りましたが、UI が作れていません。

iOS アプリの定番である “Edit” ボタンを使って編集モードにし、編集モードで表示される削除ボタンを押下された時に削除されるようにしていきます。

具体的には、以下の2つです。

  • “Edit” ボタンを追加する
  • 削除ボタンに対応する

“Edit” ボタンを追加する

iOS アプリであれば、Editボタンは、EditButton ビューを配置することで追加できます。
Apple のドキュメントは、こちら

削除ボタンに対応する

リスト中の要素については、onDelete を設定することで、削除に対応させることができます。
onDelete は、ForEach に設定することができる View Modifier です。
Apple のドキュメントは、こちら

onDelete に渡す closure の中で、削除処理を行います。実際には、ViewModel に要素の削除を依頼します。削除処理のために、ViewModel には、removeTODOItem というメソッドを作成予定です。


struct MainView: View {
    @EnvironmentObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.todoItems.freeze()) { item in
                    Text("\(item.title)")
                }
               // onDelete 内で、削除処理
                .onDelete { indexSet in
                    if let index = indexSet.first {
                        // ViewModel には、removeTODOItem メソッドを作成予定
                        viewModel.removeTODOItem(viewModel.todoItems[index].id)
                    }
                }
            }
            .navigationTitle("RealmTODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    HStack {
                   // Edit ボタンを表示 (iOS のみ)
                    #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")
                    })
                }
            }
        }
    }
}

コマンドとUIをつなぐ

MVVM で作っていますので、View は、削除詳細はもちろん知りません。あくまで、ユーザーから削除するというリクエストがあったことを ViewModel に伝えます。
ViewModel は、リクエストに応じた処理を行います。処理によっては、ViewModel 内で処理できるかもしれませんし、要素削除のように Model へのリクエストが必要になるかもしれません。

要素削除の Command は、先ほど作成しましたので、ViewModel では、Command を使用して、Model に処理依頼します。


    func removeTODOItem(_ id: TODOItem.ID) {
        // (1) RemoveTODOItemCommand を用意
        let command = TODOModel.RemoveTODOItemCommand(id)
        // (2) Model が変更されることを通知
        objectWillChange.send()
        // (3) Command を実行
        model.executeCommand(command)
    }

まとめ

Command パターンを使った、Model 変更処理を実装しました。

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

ここまでに実装した機能概要
  • Command パターンを使った Model 変更処理を実装した
  • EditButton を使って Edit ボタンを表示した
  • onDelete を指定して、削除機能を実装した

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

ここまでのコード

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

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
    
    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) {
        command.execute(self)
    }
}

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

extension TODOModel {
    class CreateTODOItemCommand: TODOModelCommand {
        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 = UUID()
            newItem.title = title
            newItem.detail = detail
            
            try! model.realm.write {
                model.realm.add(newItem)
            }
        }
    }
    class RemoveTODOItemCommand: TODOModelCommand {
        var id: UUID
        
        init(_ id: TODOItem.ID) {
            self.id = id
        }
        
        func execute(_ model: TODOModel) {
            guard let itemToBeRemoved = model.itemFromID(self.id) else { return } // no item
            try! model.realm.write {
                model.realm.delete(itemToBeRemoved)
            }
        }
    }
}

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

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

コメントを残す

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