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 で TODOApp を作る(4: Generics と KeyPath を使った command)
[SwiftUI][Realm] SwiftUI と Realm で TODOApp を作る(5: 複数 command の実行)
今回は、作成と削除に対しての UNDO を実装していきます。
以下がわかるようになる気がします
・アプリへの UNDO/REDO の実装
・Command パターンを使った UNDO/REDO の実装
TODOアプリアーキテクチャノート
UNDO 機能を実装するときは、以下のような項目が検討項目になります。
・何を UNDO/REDO 対象とするか
・何をしたら UNDO/REDO バッファがクリアされるか
・どういう単位で UNDO/REDO できるか
・だれが UNDO を管理するか
今回のアプリでは、現時点では以下のようになる予定です。
・何を UNDO/REDO 対象とするか
→ TODOItem の作成・削除を対象とする。
・何をしたら UNDO/REDO バッファがクリアされるか
→ 操作により作成/削除 すると REDO バッファはクリアされる。UNDO バッファのクリアはアプリがメモリ上から消された時(積極的に削除しないが、積極的に保存もしない)
・どういう単位で UNDO/REDO できるか
→ 現時点では、作成のみ、削除のみ なので、そのままの単位
・だれが UNDO を管理するか
→ TODOItem 対象操作が UNDO 対象なので、TODOModel で管理する
前回 Model 操作に Command パターンを導入しましたので、実装した Command 毎にUNDO する機能を実装して、Model として UNDO/REDO できるようにします。
なお、REDO は、通常の execute と同じになるはずです。(そうでないとすると、UNDO が以前の状態に戻せていないということになります)
Command Protocol の UNDO 対応
前回作成した Command 向けプロトコル TODOModelCommand を UNDO にも対応させます。
UNDO 対応した TODOModelCommand protocol
protocol TODOModelCommand: AnyObject {
func execute(_ model: TODOModel)
func undo(_ model: TODOModel)
}
UNDO 時に実行される予定の undo メソッドを追加しました。
なお、REDO 時には、execute が再度呼ぶことで対応できるため、redo メソッドの定義は不要です。
TODOModel の拡張:UNDO/REDO バッファ追加
実行した command を記録しておくためのバッファを TODOModel に追加します。
・バッファは、UNDO バッファ・REDO バッファの2つを追加します。
・どちらのバッファも、First-In/First-Out で使用します。(Stack的に使用するということです)
・command の execute 時に、実行した command を UNDO バッファに記録します
・UNDO 時には、UNDO バッファの一番上の command を undo し、REDO バッファに移します
・REDO 時には、REDO バッファの一番上の command を execute し、UNDO バッファに移します
UNDO/REDO 可能かどうかで UNDO/REDO ボタンの enable/disable を制御したいため、UNDO/REDO が可能かどうかを確認する undoable/redoable というメソッドも追加し、外部から状態を取得できるようにします。
undo/redo バッファを持つ TODOModel
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 {
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)
}
}
CreateTODOItemCommand の UNDO対応
TODOItem を作成する操作の UNDO は、作成した要素を無かったことにすることです。つまり、作成した要素の削除です
このコマンドの undo は、execute で作成した要素を削除する という実装になります。そのために、execute 実行時に作成した要素を id を保持しておくようにしておき、undo 時に、削除するようにします。
また、redo 時を考慮して、ID 指定が必要であれば、指定 ID を持つ要素として作成します。
UNDO 対応 CreateTODOItemCommand
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)
}
}
}
RemoveTODOItemCommand の UNDO対応
TODOItem を削除する操作の UNDO は、削除した要素を再作成することです。
ここでは、削除した要素のプロパティを記録しておき、UNDO 時に、そのプロパティを使って、再作成することにします。
アプリ側で振った ID も記憶しておいて、同じ ID になるように作成しています。
なお、title, detail は、そのコマンドがまだ execute されていない時には、nil を保持するようにして、コマンドがすでに実行されたかをチェックできるようにしています。
UNDO 対応 CreateTODOItemCommand
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
}
}
}
View の UNDO 対応
iPhone では、デバイスをシェイクすると UNDO するのがよくある実装ですが、ここでは、ボタンを配置して、UNDO/REDO できるようにします。
画面下の位置に、UNDO ボタン, REDO ボタンとして配置します。それぞれ 実行可能でない時には、disable になるようにします。ViewModel の undo, redo, undoable, redoable はすぐに実装予定です。
View への UNDO/REDO ボタン配置
ToolbarItemGroup(placement: .bottomBar) {
Button(action: {
viewModel.undo()
}, label: {
Text("UNDO")
})
.disabled(!viewModel.undoable)
Button(action: {
viewModel.redo()
}, label: {
Text("REDO")
})
.disabled(!viewModel.redoable)
Spacer()
}
ViewMode の UNDO 対応
ViewModel は、Model と View を接続します。具体的には、以下の情報です。
・UNDO 実施
・REDO 実施
・UNDO 可能か確認
・REDO 可能か確認
すでに、Model 側には用意してありますので、接続するだけになります。
UNDO 対応 ViewModel
//
// 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 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
}
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)
}
}
まとめ
Command パターンを使って、UNDO/REDO 処理を実装しました。
ここまでで 以下のように動作するアプリになっています。
# 要素取得結果をソートしていないので、表示順序が UNDO 等によって変更されてしまっています。
- Command パターンを使った UNDO/REDO 処理を実装した
説明は以上です。
不明な点やおかしな点ありましたら、コメントもしくはこちらまで。
ここまでのコード
参考までにここまでに実装したコードを転記しておきます。
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 {
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
}
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
}
}
}
}
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 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
}
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")
})
}
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())
}
}
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