SwiftUI と CoreData を組み合わせたアプリの作り方を説明します。
Sponsor Link
目次
環境&対象
以下の環境で動作確認を行なっています。
- macOS Catalina 10.15.7
- Xcode 12.2
- iOS 14.2
リファクタリング概要
いろいろとコードを変更してきて、気になる点が増えてきたので、少し大規模に Refactoring します。
以下の点を直したいと考えてます。
- ContentView をやめたい
- TODOItem の isDone を操作する ViewModel のメソッド名 (toggleDone) を筆頭にメソッド名が不自然なものが多い
- 変更のある箇所で、self.todoItems = self.todoItemStore.filteredItems(predicate) してるのをスマートにしたい
以下は、気になるけど まだ修正しないで置いて、必要性が高まったら改めて考えようと思っている点です。
- 常に、CoreData 変更を行い、そこから Swift の Model を再生成しているのは、パフォーマンス的にOK?
ContentView をリネーム
非常に単純です、Xcode の機能を使用してリネームします。MyTODOMainView としました。
以下は具体的な手順です。
- Cmd + U で全てのテストを行い、パスすることを確認
- ContentView.swift 中の ContentView を選択し、コンテキストメニュー[Refactor]-[Rename…] を選択
- 関係する箇所が同時に表示され、見ながら変更できます。
- Cmd + U で 再度 全てのテストを行い、パスすることを確認したら、Commit。
Xcode の Refactoring メニューは、ファイル名まで変更してくれるので、便利です。
メソッド名の調整
isDone のフラグを toggle させるメソッドは、toggleIsDone とすることにします。
Model, ViewModel いずれにも相当するメソッドがありますが、統一しました。
そのほかにも、統一感がなかったので、統一することにしました。
ViewModel 内の todoItems の更新
これまでは、変更操作を行うメソッドの中で、直接 更新していました。
ある意味直感的ですが、忘れることが懸念されます。
ということで、自動化します。
基本的に、CoreData を変更したら todoItems を更新させることにします。
CoreData を変更すると、NotificationCenter から NSManagedObjectContextObjectsDidChange なる notification が送信されますので、それを契機に todoItems を更新することにしました。
ここまでに作ってきたコード
以下のファイルを作ってきました。
- MyTODOModel.swift
- Model 定義ファイル
- MyTODOViewModel.swift
- ViewModel 定義ファイル
- MyTODOMainView.swift
- View 定義ファイル
- TODOItemView.swift
- TODOItem 表示 View 定義ファイル
- MyTODOApp.swift
- App 定義ファイル
- MyTODO.xcdatamodeld
- CoreData モデル定義ファイル
MyTODOModel.swift
MyTODOModel.swift
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
// // MyTODOModel.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import Foundation import CoreData import os struct TODOItem : Identifiable, Hashable { static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItem", category: "TODOItem") var id: UUID? = nil var title: String = "" var detail: String = "" var isDone: Bool = false init(_ title: String,_ detail: String = "",_ isDone: Bool = false) { self.id = UUID() self.title = title self.detail = detail self.isDone = isDone } } // depend on CoreData struct TODOItemStore { static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItemStore", category: "TODOItemStore") let container: NSPersistentContainer init(_ inMemory:Bool ) { container = NSPersistentContainer(name: "MyTODO") if inMemory { container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") } container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") } }) } var items:[TODOItem] { var items:[TODOItem] = [] let request:NSFetchRequest<CDTODOItem> = CDTODOItem.fetchRequest() do { items = try container.viewContext.fetch(request).map(TODOItem.init) } catch { TODOItemStore.logger.error("error in fetching data from coredata") } return items } func filteredItems(_ predicate: NSPredicate? = nil) -> [TODOItem] { var items:[TODOItem] = [] let request:NSFetchRequest<CDTODOItem> = CDTODOItem.fetchRequest() if let predicate = predicate { request.predicate = predicate } do { items = try container.viewContext.fetch(request).map(TODOItem.init) } catch { TODOItemStore.logger.error("error in fetching data from coredata") } return items } } // MARK: CoreData dependent part extension TODOItem { init(_ cdItem: CDTODOItem) { self.id = cdItem.id self.title = cdItem.title ?? "" self.detail = cdItem.detail ?? "" self.isDone = cdItem.isDone } } extension TODOItemStore { // create Item , then put into coredata @discardableResult func createTODOItem(_ title: String, _ detail: String = "",_ isDone: Bool = false) -> TODOItem { let newItem = TODOItem(title, detail) let description = NSEntityDescription.entity(forEntityName: "CDTODOItem", in: container.viewContext)! let newCDItem = CDTODOItem(entity: description, insertInto: container.viewContext) newCDItem.id = newItem.id newCDItem.title = newItem.title newCDItem.detail = newItem.detail newCDItem.isDone = newItem.isDone save() return newItem } func removeTODOItem(_ item: TODOItem) { guard let id = item.id else { return } let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "CDTODOItem") request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg) guard let items = try? container.viewContext.fetch(request), let cditem = items.first as? CDTODOItem, items.count == 1 else { return } container.viewContext.delete(cditem) save() } func toggleIsDone(_ item: TODOItem) { guard let id = item.id else { return } let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "CDTODOItem") request.predicate = NSPredicate.init(format: "id == %@", id as CVarArg) guard let items = try? container.viewContext.fetch(request), let cditem = items.first as? CDTODOItem, items.count == 1 else { return } cditem.isDone = !cditem.isDone save() } func save() { if !container.viewContext.hasChanges { return } do { try container.viewContext.save() } catch { print(error) } } } |
MyTODOViewModel.swift
MyTODOViewModel.swift
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
// // MyTODOViewModel.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import Foundation import SwiftUI import CoreData import os import Combine class MyTODOViewModel : ObservableObject { var todoItemStore: TODOItemStore @Published var todoItems:[TODOItem] = [] @Published var showUndoneItems:Bool = true var predicate: NSPredicate { if showUndoneItems { return NSPredicate(format: "isDone == false") } return NSPredicate(format: "isDone == true") } static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.MyTODOViewModel", category: "MyTODOViewModel") var cancellables: [AnyCancellable] = [] init(_ inMemory: Bool) { self.todoItemStore = TODOItemStore(inMemory) todoItems = todoItemStore.filteredItems(predicate) cancellables.append(NotificationCenter.default .publisher(for: Notification.Name.NSManagedObjectContextObjectsDidChange) .sink { notification in self.updateTodoItems(notification) }) } func updateTodoItems(_ notification:Notification?) { MyTODOViewModel.logger.debug("updateTodoItems called") self.todoItems = todoItemStore.filteredItems(predicate) } func createTODOItem(_ title: String, _ detail: String = "") -> TODOItem{ let newItem = todoItemStore.createTODOItem(title, detail) todoItems = todoItemStore.filteredItems(predicate) return newItem } func deleteTODOItem(_ item: TODOItem) { todoItemStore.removeTODOItem(item) } func toggleIsDone(_ item: TODOItem) { self.todoItemStore.toggleIsDone(item) } func toggleDisplayFilter() { showUndoneItems.toggle() updateTodoItems(nil) } static func previewViewModel() -> MyTODOViewModel { let viewModel = MyTODOViewModel(true) _ = viewModel.createTODOItem("TODOItem #1", "todo item detail") return viewModel } } |
MyTODOMainView.swift
MyTODOMainView.swift
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
// // ContentView.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import SwiftUI import CoreData import SwiftUIDebugUtil struct MyTODOMainView: View { @EnvironmentObject var viewModel: MyTODOViewModel @State private var showNewItemView = false var body: some View { NavigationView { List { ForEach(viewModel.todoItems, id: \.self) { item in TODOItemView(todoItem: item) .onTapGesture { self.viewModel.toggleIsDone(item) } } .onDelete(perform: deleteItems) } .accessibility(identifier: "TODOList") .navigationTitle("MyTODO") .toolbar { #if os(iOS) ToolbarItem(placement: .navigationBarLeading) { EditButton() } #endif ToolbarItem(placement: .navigationBarTrailing) { HStack { Button(action: { showNewItemView.toggle() }) { Label("Add Item", systemImage: "plus") } Button(action: { viewModel.toggleDisplayFilter() }) { Label("Toggle", systemImage:viewModel.showUndoneItems ? "checkmark.square" : "square") } } } } } .sheet(isPresented: $showNewItemView) { NewTODOItemView(showNewItemView: $showNewItemView) } } private func deleteItems(offsets: IndexSet) { withAnimation { offsets.map { viewModel.todoItems[$0] }.forEach(viewModel.deleteTODOItem) } } } struct NewTODOItemView: View { @EnvironmentObject var viewModel: MyTODOViewModel @Binding var showNewItemView: Bool @State private var itemTitle: String = "" @State private var itemDetail: String = "" var body: some View { VStack { Spacer() HStack { Text("Title : ") TextField("title", text: $itemTitle) .accessibility(identifier: "NewTODOItemTitle") } .padding() HStack { Text("Detail: ") TextField("detail", text: $itemDetail) .accessibility(identifier: "NewTODOItemDetail") } .padding() Spacer() HStack { Button(action: { withAnimation { _ = viewModel.createTODOItem(itemTitle, itemDetail) } showNewItemView.toggle() }, label: { Text("OK") }) .accessibility(identifier: "NewTODOItemOK") .padding() Button(action: { showNewItemView.toggle() }, label: { Text("Cancel") }) .accessibility(identifier: "NewTODOItemCancel") .padding() } .padding() Spacer() } .padding() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { MyTODOMainView().environmentObject(MyTODOViewModel.previewViewModel()) } } |
TODOItemView.swift
TODOItemView.swift
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// // TODOItemView.swift // // Created by : Tomoaki Yagishita on 2020/12/15 // © 2020 SmallDeskSoftware // import SwiftUI struct TODOItemView: View { let todoItem: TODOItem var body: some View { HStack { Image(systemName: todoItem.isDone ? "checkmark.square" : "square") .accessibility(identifier: "TODOItemIsDoneImage") VStack { Text(todoItem.title) .font(.largeTitle) .accessibility(identifier: "TODOItemTitleText") Text(todoItem.detail) .font(/*@START_MENU_TOKEN@*/.body/*@END_MENU_TOKEN@*/) .accessibility(identifier: "TODOItemDetailText") } } } } struct TODOItemView_Previews: PreviewProvider { static var previews: some View { TODOItemView(todoItem: TODOItem("Title", "Detail")) } } |
MyTODOApp.swift
MyTODOApp.swift
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// // MyTODOApp.swift // // Created by : Tomoaki Yagishita on 2020/12/10 // © 2020 SmallDeskSoftware // import SwiftUI import os @main struct MyTODOApp: App { static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.MyTODOApp", category: "MyTODOApp") @StateObject var viewModel: MyTODOViewModel init() { let inMemory:Bool = ProcessInfo.processInfo.arguments.contains("TestWithInMemory") _viewModel = StateObject(wrappedValue: MyTODOViewModel(inMemory)) } var body: some Scene { WindowGroup { MyTODOMainView() .environmentObject(viewModel) } } } |
MyTODO.xcdatamodeld

次回は、既存の TODOItem を編集できるようにします。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link