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
//
// 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.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.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 = 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 = 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
//
// 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
//
// 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
//
// 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
//
// 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