I am trying to use Core Data with MVVM in SwiftUI, but I can't manage to make the List in my view update whenever there are changes to Core Data.
Please check this example. Why my List doesn't update if items is a Published variable?
QUESTION:
How to make the List update every time I add or delete items?
Right now I need to force quit the app and open it again to see changes.
I've searched for many similar questions and I wasn't able to make it work. Maybe I'm missing something obvious.
EXAMPLE CODE:
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#StateObject var vm = ContentViewModel()
var body: some View {
NavigationView {
List {
ForEach(vm.items) { item in
Text(item.text!)
}
}
.toolbar {
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
ToolbarItem(placement: .destructiveAction){
Button(action: deleteAll) {
Image(systemName: "trash")
.foregroundColor(.red)
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.text = "List item"
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteAll() {
vm.items.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
ContentViewModel.swift
import Foundation
import CoreData
final class ContentViewModel: ObservableObject {
// Why my List doesn't update if items is a Published variable?
#Published var items: [Item] = []
let container: NSPersistentContainer = PersistenceController.shared.container
init() {
let fetchRequest = NSFetchRequest<Item>(entityName: "Item")
do {
items = try container.viewContext.fetch(fetchRequest)
} catch let error {
print("Error fetching. \(error)")
}
}
}
Persistence.swift
(it's almost the same as default Core Data example file in Xcode 14)
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.text = "Abc"
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "CoreDataMVVM")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
xcdatamodeld has 1 entity: Item with 1 attribute: text: String
We don't need MVVM in SwiftUI because the View struct is a view model. If you use a view model object instead, you'll have this consistency problem. To make the list update, use #FetchRequest in the View struct.
FYI FetchRequest is a DynamicProperty struct and gets the Environment's managedObjectContext in its update func.
Related
I'm using CoreData + CloudKit in my app, and my views aren't reflecting changes in CloudKit. For example, when I delete records on the web CloudKit dashboard, they still show in my view in the simulator. Running the app again from Xcode still reflects old data in the simulator.
I was able to recreate this problem using the default Xcode project with CloudKit and CoreData boxes checked and minimal additions to Persistence.swift.
Here's that code
Persistence.swift
import CloudKit
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "TestProject")
let containerIdentifier = "[MY CONTAINER IDENTIFIER]"
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
// Default store
let defaultStoreLocation = container.persistentStoreDescriptions.first!.url!
// Setup public store
let publicStoreLocation = defaultStoreLocation.deletingLastPathComponent().appendingPathComponent("public.sqlite")
let publicStoreDescription = NSPersistentStoreDescription(url: publicStoreLocation)
publicStoreDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: containerIdentifier)
publicStoreDescription.cloudKitContainerOptions?.databaseScope = .public
// Load persistent store descriptions
container.persistentStoreDescriptions = [publicStoreDescription]
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
And here's the default ContentView
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
} label: {
Text(item.timestamp!, formatter: itemFormatter)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
Here's the default App
TestProjectApp.swift
import SwiftUI
#main
struct TestProjectApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
Have a look at this https://developer.apple.com/videos/play/wwdc2020/10650/ at around 14:00 on deleting items in the public database.
You would have to set a flag isTrashed for example and then filter items based on that instead of deleting them for the Public database.
I have the following code:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
} label: {
// Text(item.timestamp!, formatter: itemFormatter)
ItemCellView(model: ItemCellViewModel(item: item))
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
struct ItemCellView: View {
#StateObject var model:ItemCellViewModel
var body: some View {
Text(model.item.timestamp!, formatter: itemFormatter)
.foregroundColor(.blue)
}
}
class ItemCellViewModel: ObservableObject {
#Published var item:Item
init(item:Item) {
self.item = item
}
deinit {
print("ItemCellViewModel EDINIT \(self)")
}
}
It draws this:
PROBLEM:
ItemCellViewModel deinit is NOT called after I swipe to delete the item.
Can someone tell me why the ItemCellViewModel sticks around even after the ItemCellView is gone?
This is a simplified version of a codebase I am working in. I need that model to go away when the view is "deleted" by the user. Why is SwiftUI keeping ItemCellViewModel around??
View is not deleted in a fact (just removed from visible area) because List caches some number of views (visible area + ~2) and StateObject is persistent storage of view which keeps its state. So observed behavior is by-design.
Get rid of the view model object, in SwiftUI we use value types and the View struct is the view model that SwiftUI uses to create and update UIKit/AppKit views on our behalf. Learn this in SwiftUI Essentials WWDC 2019. Also you can’t nest an ObservableObject inside an ObservableObject either. To fix this change the ItemCellView to this:
struct ItemCellView: View {
#ObservedObject var item: Item
var body: some View {
Text(item.timestamp!, formatter: itemFormatter)
.foregroundColor(.blue)
}
}
I'm having some confusion with CoreData using SwiftUI, I'm just doing an example project for a Toggle. I set the newly created item in PersistenceController but the toggle always remains off. I don't have much experience using CoreData, my previous question I about CoreData was incorrect and I was misinformed about a fix.
PersistenceController
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.bool = false
}
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
ContentView
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Item.bool, ascending: true)], animation: .default)
private var items: FetchedResults<Item>
var body: some View {
VStack {
Toggle("", isOn: Binding<Bool>(
get: { self.items.first?.bool ?? false },
set: {
self.items.first?.bool = $0
try? viewContext.save()
}
))
.labelsHidden()
.frame(width: 100, height: 100, alignment: .center)
}
}
}
As it goes from the name of variable, preview is only used in preview, at least in newly generated project. So on real run your database if empty
Using sortDescriptors your list of items is sorted ascending by bool. This means that as soon as you set first item bool to true, it gets moved to the end of the list, and you're reading bool value from an other object
To create a new Item at start when your database is empty, you need to wait persistent store load, fetch items and check that there're no items, in this case - create a new one. Replace PersistenceController init with following:
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "SwiftUICodeDataPlayground")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { [self] (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
if (try? container.viewContext.fetch(Item.fetchRequest()).isEmpty) != false {
let newItem = Item(context: container.viewContext)
newItem.bool = false
}
})
}
I am trying to create a simple application that leverages CloudKit to sync between multiple iOS devices (via iCloud) and possibly macOS devices (still via iCloud).
Problem:
I have a core data entity which seems to work great locally in my app. When I switch to using Cloudkit I am unable to see changes on another device without closing/reopening the app.
I am using the Cloudkit template in Xcode with the SwiftUI lifecycle. ie, PersistenceController and managed object context.
I think this is a view not refreshing issue, but am not 100% sure. Once the app on a different device is closed and reopened then it loads the new data successfully. This applies to additions and deletes.
Testing:
I have tested this using Testflight as well as running two simulators locally.
Code:
PersistenceController.swift
import CoreData
struct PersistenceController {
static var shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for i in 0..<10 {
let newItem = Card(context: viewContext)
newItem.cardValue = String(i)
newItem.cardOrder = Int32(i)
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
//Is this needed?
lazy var updateContext: NSManagedObjectContext = {
let _updateContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
_updateContext.parent = PersistenceController.shared.updateContext
return _updateContext
}()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "Cards")
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)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(entity: Card.entity(),
sortDescriptors: [NSSortDescriptor(key: "cardOrder", ascending: true)])
var cards: FetchedResults<Card>
var body: some View {
NavigationView {
VStack {
Text("Scrum +")
.font(.largeTitle)
.fontWeight(.regular)
Spacer()
List(cards, id: \.id) {card in
NavigationLink(destination: PointRow(card: card)) {
PointRow(card: card)
}
}
Spacer()
NavigationLink(destination: SettingsView().environment(\.managedObjectContext, self.managedObjectContext)
) {
Text("Card Settings")
}
.padding(.bottom)
}
}
}
}
SettingsView.swift (where saving/deleting/reordering happens)
import SwiftUI
struct SettingsView: View {
#State private var editMode = EditMode.inactive
#State private var showModal = false
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(key: "cardOrder", ascending: true)])
var cards: FetchedResults<Card>
var body: some View {
VStack {
Text("Card Values")
.font(.largeTitle)
HStack {
EditButton()
.padding(.leading)
Spacer()
addButton
.padding(.trailing)
}
.padding(.vertical)
List {
ForEach(cards) {
item in
Text(item.cardValue!)
}
.onDelete(perform: onDelete)
.onMove(perform: onMove)
.environment(\.editMode, $editMode)
}
.sheet(isPresented: $showModal) {
//Show the view to add a new card.
SettingsModal(showModal: self.$showModal, numberOfCards: cards.count).environment(\.managedObjectContext, self.managedObjectContext)
}
}
}
private var addButton: some View {
switch editMode {
case .inactive:
return AnyView(Button(action: onAdd) { Image(systemName: "plus") })
default:
return AnyView(EmptyView())
}
}
func onAdd() {
showModal = true
}
func onDelete(indexSet: IndexSet) {
// print("Deleting card at index -> " + indexSet.first)
let cardToDelete = self.cards[indexSet.first!]
self.managedObjectContext.delete(cardToDelete)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
reorder()
}
private func onMove(source: IndexSet, destination: Int) {
// pointCards.points.move(fromOffsets: source, toOffset: destination)
// source.
let firstCard = self.cards[source.first!]
//If there is a card located in the destination.
if (destination < self.cards.count) {
let secondCard = self.cards[destination]
let tmp = Int(secondCard.cardOrder)
//Increment all place holders from the destination on.
for i in tmp..<self.cards.count {
self.cards[i].cardOrder += 1
}
}
firstCard.cardOrder = Int32(destination)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
self.managedObjectContext.refreshAllObjects()
//Reorder just in case moved to the end.
reorder()
}
private func reorder() {
for i in 0..<cards.count {
cards[i].cardOrder = Int32(i)
}
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
}
Screenshots of my Core Data model using Cloudkit and of the Entitlement selection.
Capabilities
CoreData Properties
UPDATE:
I have found the solution.
The Persistent Controller should have the following three lines added to the init function:
//Setup auto merge of Cloudkit data
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
//Set the Query generation to .current. for dynamically updating views from Cloudkit
try? container.viewContext.setQueryGenerationFrom(.current)
Then this must be ran on real devices. For whatever reason I am unable to get this working in the simulator, but on real devices it syncs in about 30 seconds without closing the app.
Thanks everyone for your help!
I am using the new SwiftUI App protocol and passing my Core Data NSPersistentContainer via #Environment:
final class PersistentStore: ObservableObject {
static let shared = PersistentStore()
let persistentContainer: NSPersistentContainer
private init() {
self.persistentContainer = {
let container = NSPersistentContainer(name: "SillyModel")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
}
func saveContext() {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
#main
struct SillyApp: App {
#Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
NavigationView {
ProjectGridView()
}
.environment(\.managedObjectContext, PersistentStore.shared.persistentContainer.viewContext)
}
}
}
I then use #Enviroment in ProjectGridView to get the managedObjectContect from the environment, attempt a #FetchRequest and display a new LazyVGrid:
struct ProjectGridView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(
entity: Project.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Project.title, ascending: true)
]
) var projects: FetchedResults<Project>
let columns = [GridItem(.adaptive(minimum: 80.0))]
var body: some View {
return ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(projects, id:\.title) { (project: Project) in
Text("\(project.title ?? "")")
}
}
.padding(.horizontal)
}
}
}
I get this error:
[error] error: No NSEntityDescriptions in any model claim the NSManagedObject subclass 'Project' so +entity is confused. Have you loaded your NSManagedObjectModel yet ?
I've tried writing code that saves new Project()s and even have #FetchRequest working elsewhere. I'm guessing it may have something to do with the "Lazy" part of LazyVGrid? Any ideas?
Okay, found a fix from the Apple Developer forum. This appears to simply be a beta bug:
To fix, create an extension to your model class:
import Foundation
import CoreData
extension Project {
class func swiftUIFetchRequest() -> NSFetchRequest<Project> {
let request = NSFetchRequest<Project>(entityName: "Project")
request.sortDescriptors = [NSSortDescriptor(keyPath: \Project.title, ascending: true)] /* You can add custom sort descriptors here */
return request
}
}
Then call #FetchRequest like this:
#FetchRequest(fetchRequest: Project.swiftUIFetchRequest()) var projects: FetchedResults<Project>
Thank you to the person in the Apple Developer forum who figured this out.