Sponsor Link
環境&対象
- 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)
}
}
}
Sponsor Link