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