I am writing an iOS app using SwiftUI and Core Data. I am very new to Core Data and try to understand something:
Why try self.moc.save() changes self.item.isDeleted from true to false?
It happens after I delete a Core Data object (isDeleted changes to true), but later saving managed object context changes it to false. Why is that?
Here is an example:
ContentView.swift
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
var fetchRequest: FetchRequest<Item>
var items: FetchedResults<Item> { fetchRequest.wrappedValue }
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) {item in
NavigationLink(destination: DetailsView(item: item)) {
Text("\(item.name ?? "default item name")")
}
}
}
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button(action: {
for number in 1...3 {
let item = Item(context: self.moc)
item.date = Date()
item.name = "Item \(number)"
do {
try self.moc.save()
}catch{
print(error)
}
}
}) {
Text("Add 3 items")
}
)
}
}
init() {
fetchRequest = FetchRequest<Item>(entity: Item.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Item.name, ascending: true)
])
}
}
DetailsView.swift
import SwiftUI
struct DetailsView: View {
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var item: Item
var body: some View {
VStack {
Text("\(item.name ?? "default item name")")
}
.navigationBarItems(
trailing:
Button(action: {
self.moc.delete(self.item)
print(self.item.isDeleted)
self.presentationMode.wrappedValue.dismiss()
print(self.item.isDeleted)
do {
try self.moc.save()
print(self.item.isDeleted)
}catch{
print(error)
}
}) {
Text("Delete")
.foregroundColor(.red)
}
)
.onDisappear {
print(self.item.isDeleted)
if !self.item.isDeleted {
print(self.item.isDeleted)
self.item.name = "new name"
print(self.item.isDeleted)
do {
try self.moc.save()
}catch{
print(error)
}
}
}
}
}
What I expected will happen:
self.moc.delete(self.item) will delete an object and mark self.item.isDeleted as true.
try self.moc.save will save moc
if !self.item.isDeleted will prevent code execution if item is deleted (without this, I was getting an error: Mutating a managed object (...) after it has been removed)
It didn't work. I have added print(self.item.isDeleted) on few lines and breakpoints on those lines to check what exactly happens.
What happened is this:
self.moc.delete(self.item) deleted an object and marked self.item.isDeleted as true.
try self.moc.save saved moc and...
self.item.isDeleted changed to be false
if !self.item.isDeleted didn't prevent code execution, because isDeleted was false at this point.
Is it a bug? Or I don't understand the life cycle of Core Data objects and isDeleted changes as it should?
Why try self.moc.save() changes self.item.isDeleted from true to
false? It happens after I delete a Core Data object (isDeleted changes
to true), but later saving managed object context changes it to false.
Why is that?
It behaves as documented - returns true before save, and not in other cases
Here is snapshot of Apple documentation for NSManagedObject:
Summary
A Boolean value that indicates whether the managed object will be
deleted during the next save. Declaration
var isDeleted: Bool { get } Discussion
true if Core Data will ask the persistent store to delete the object
during the next save operation, otherwise false. It may return false
at other times, particularly after the object has been deleted. The
immediacy with which it will stop returning true depends on where the
object is in the process of being deleted. If the receiver is a fault,
accessing this property does not cause it to fire.
Related
I'm trying to reorder my CoreData items, but when I try reorder items and drop cell my list view is being dismissed. All changes are saved and displayed correctly in other views and in the list that is modified.
Here's my code
import SwiftUI
struct DaysOrderView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Days.entity(), sortDescriptors:[NSSortDescriptor(key: "id", ascending: true)]) var days : FetchedResults<Days>
var body: some View {
List {
ForEach(days, id: \.self ) { day in
HStack {
Image(systemName: day.isDisplayed.boolValue ? "checkmark.circle.fill" : "checkmark.circle").foregroundColor(.yellow)
Text(day.name)
}
}
.onMove(perform: onMove)
}
.toolbar {
EditButton()
}
}
private func onMove(source: IndexSet, destination: Int) {
var revisedItems: [ Days ] = days.map{ $0 }
revisedItems.move(fromOffsets: source, toOffset: destination )
for reverseIndex in stride( from: revisedItems.count - 1,
through: 0,
by: -1 )
{
revisedItems[ reverseIndex ].id = Int16( reverseIndex )
}
}
}
An id is not arbitrary, and changing id's to change the sort order can have consequences. If you want a changeable order, I would recommend keeping track of it with an attribute dedicated to the sort order and change that.
I put together a quick app using both a sortOrder attribute and an id attribute. When I used 'idas anInt16` and sorted and changed on that, I got the following error:
ForEach<FetchedResults, Int16, NavigationLink<Text, Text>>: the
ID 2 occurs multiple times within the collection, this will give
undefined results!
Swapping it out for a sortOrder attribute gave no errors. Since you didn't, understandably with Core Data, post the entire code, I can't be 100% sure, but I am betting your problem comes down to this issue. Also, you should make your id a UUID() that is what it exists for, and do not change the id. It is like the parents with multiple kids swapping the kids' names on a daily basis. It might work, but most likely it is chaos.
FWIW the code used to test:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.sortOrder, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item \(item.sortOrder) at \(item.timestamp!, formatter: itemFormatter)")
} label: {
Text(item.timestamp!, formatter: itemFormatter)
}
}
.onDelete(perform: deleteItems)
.onMove(perform: onMove)
}
.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()
newItem.id = UUID()
newItem.sortOrder = Int16(items.count)
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 func onMove(source: IndexSet, destination: Int) {
var revisedItems: [ Item ] = items.map{ $0 }
revisedItems.move(fromOffsets: source, toOffset: destination )
for reverseIndex in stride( from: revisedItems.count - 1,
through: 0,
by: -1 )
{
revisedItems[ reverseIndex ].sortOrder = Int16( reverseIndex )
}
}
}
It is the base "Use Core Data" code that I added the attributes to.
I'm having an issue where SwiftUI is rendering a View for a Core Data object that was just deleted. I have reproduced this issue using the base SwiftUI+Core Data template Xcode gives you.
import SwiftUI
import CoreData
struct ContentView: View {
#State var selectedItem: Item?
#Environment(\.managedObjectContext) private var managedObjectContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
VStack {
List {
ForEach(items) { item in
NavigationLink(
destination: DetailView(item: item).environment(\.managedObjectContext, managedObjectContext),
tag: item,
selection: $selectedItem
){
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
}
}
.onDelete(perform: deleteItems)
}
}
.navigationTitle("Items")
.toolbar {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: managedObjectContext)
newItem.timestamp = Date()
do {
try managedObjectContext.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(managedObjectContext.delete)
do {
try managedObjectContext.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 DetailView: View {
#ObservedObject var item: Item
#Environment(\.managedObjectContext) var managedObjectContext
#State var show = false
var body: some View {
Text("Detail item at \(item.timestamp!, formatter: itemFormatter)")
.navigationTitle("Detail")
.toolbar {
Button {
show = true
} label: {
Text("Edit")
}
}
.sheet(isPresented: $show) {
Popup(item: item)
.environment(\.managedObjectContext, managedObjectContext)
}
}
}
struct Popup: View {
var item: Item
#Environment(\.managedObjectContext) var managedObjectContext
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Text("Popup item at \(item.timestamp!, formatter: itemFormatter)")
Button {
item.timestamp = Calendar.current.date(byAdding: .second, value: 1, to: item.timestamp!)!
try! managedObjectContext.save()
presentationMode.wrappedValue.dismiss()
} label: {
Text("Add second and close")
}
}
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
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: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "NavigationLinkDelete")
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)")
}
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
To summarize, ContentView retrieves some items from Core Data and renders them in a list. If the user taps on one, the NavigationLink shows DetailView. The user can then hit an Edit button to open a Popup sheet where the user can edit the item (in this case, the user can only add one second to the Core Data Item being edited.)
If the user first taps an item in the List to see the detail view, navigates backwards to see the List again, swipes that list item they had previously tapped, and hits Delete, the item will be deleted but the app will crash inside the DetailView body when it is trying to retrieve the timestamp for an item.
The issue is that DetailView observes the Item with the #ObservedObject property wrapper. I do this because when the user edits the item in the Popup and that sheet is dismissed, I want the DetailView to update automatically. If I remove that #ObservedObject then performing the above steps won't result in a crash, but the DetailView doesn't update when the Popup is dismissed.
I believe because DetailView is observing the Item, when the user deletes the Item it causes the last DetailView's body to re-render, despite the fact that the NavigationView isn't showing an Item's details. The DetailView isn't visible at the point the user swipes to delete the List row, so it's not clear why this is happening.
I can mitigate this issue by checking item.isFault in the DetailView's body and only accessing fields if it's false. This seems like a bit of a hack though.
Is this a SwiftUI bug? Is there a better way to achieve what I want?
I have been running into the crash on delete with SwiftUI
and CoreData as I am learning. I have experienced it on row view implementations and on the detail view on iPad where it is showing.
I see a lot of discussion of the problem, but I think your "hack" might be the best. My solution preference at this time:
Use ObservedObject and check for the item.isFault to present a placeholder or empty view.
If you can, use a let of the model object in cases where the view doesn't need to respond to changes and can just go away as the FetchRequest change propogates.
Do something more complicated where you mark the object for delete at a future time and filter the deleted objects out of the fetch. That way it doesn't go away suddenly and cause a crash. You might want to use the trash and will need to clean it eventually.
It seems like it would be nice for SwiftUI to delay updating views on deleted model objects, but this is all new to me.
This is a FAQ, but I don't see a common answer.
I have a view model which handles the loading of new data once the app launches and when a new item is added. I have an issue when it comes to showing new items when are added from a new view, for example, a sheet or even a NavigationLink.
View Model
class GameViewModel: ObservableObject {
//MARK: - Properties
#Published var gameCellViewModels = [GameCellViewModel]()
var game = [GameModel]()
init() {
loadData()
}
func loadData() {
if let retrievedGames = try? Disk.retrieve("games.json", from: .documents, as: [GameModel].self) {
game = retrievedGames
}
self.gameCellViewModels = game.map { game in
GameCellViewModel(game: game)
}
print("Load--->",gameCellViewModels.count)
}
func addNew(game: GameModel){
self.game.append(game)
saveData()
loadData()
}
private func saveData() {
do {
try Disk.save(self.game, to: .documents, as: "games.json")
}
catch let error as NSError {
fatalError("""
Domain: \(error.domain)
Code: \(error.code)
Description: \(error.localizedDescription)
Failure Reason: \(error.localizedFailureReason ?? "")
Suggestions: \(error.localizedRecoverySuggestion ?? "")
""")
}
}
}
View to load the ViewModel data, leading add button is able to add and show data but the trailing which opens a new View does not update the view. I have to kill the app to get the new data.
NavigationView{
List {
ForEach(gameList.gameCellViewModels) { gameList in
CellView(gameCellViewModel: gameList)
}
}.navigationBarTitle("Games Played")
.navigationBarItems(leading: Text("Add").onTapGesture {
let arr:[Int] = [1,2,3]
self.gameList.addNew(game: GameModel(game: arr))
}, trailing: NavigationLink(destination: ContentView()){
Text("Play")
})
}
Play View sample
#State var test = ""
var body: some View {
VStack(){
TextField("Enter value", text: $test)
.keyboardType(.numberPad)
Button(action: {
var arr:[Int] = []
arr.append(Int(self.test)!)
self.gameList.addNew(game: GameModel(game: arr))
}) {
Text("Send")
}
}
}
To what I can see the issue seems to be here:
List {
// Add id: \.self in order to distinguish between items
ForEach(gameList.gameCellViewModels, id: \.self) { gameList in
CellView(gameCellViewModel: gameList)
}
}
ForEach needs something to orientate itself on in order to know what elements are already displayed and which are not.
If this did not solve the trick. Please update the code you provided to Create a minimal, Reproducible Example
How to change just one toggle in a list without subviews? I know how to make it work if I extract Subview from everything inside ForEach, but how to do it on one view?
I cannot use subview, because I have a problem later if I want to delete an item from this subview. It gives me some errors I don't know how to fix, so I am trying to make it on one view where I don't have this error.
The code for the list is quite simple:
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
var fetchRequest: FetchRequest<Item>
var items: FetchedResults<Item> { fetchRequest.wrappedValue }
#State private var doneStatus : Bool = false
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) {item in
HStack {
Text("\(item.name ?? "default item name")")
Spacer()
Toggle(isOn: self.$doneStatus) {
Text("Done")
}
.labelsHidden()
.onAppear {
self.doneStatus = item.done
}
.onTapGesture {
self.doneStatus.toggle()
item.done.toggle()
try? self.moc.save()
}
}
}
.onDelete(perform: removeItem)
}
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button(action: {
for number in 1...3 {
let item = Item(context: self.moc)
item.date = Date()
item.name = "Item \(number)"
item.done = false
do {
try self.moc.save()
}catch{
print(error)
}
}
}) {
Text("Add 3 items")
}
)
}
}
init() {
fetchRequest = FetchRequest<Item>(entity: Item.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Item.name, ascending: true)
])
}
func removeItem(at offsets: IndexSet) {
for offset in offsets {
let item = items[offset]
moc.delete(item)
}
try? moc.save()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
//Test data
let testItem = Item.init(context: context)
testItem.date = Date()
testItem.name = "Item name"
testItem.done = false
return ContentView().environment(\.managedObjectContext, context)
}
}
I am using 1 Core Data Entity: Item. With 3 attributes: date (Date), done (Boolean), name (String).
PROBLEM
When I tap on one toggle, all other toggles change as well.
I couldn't find a solution working with Core Data. I guess maybe I should use .id instead of .self? And add another attribute to my entity: id (UUID). But I tried to do it and failed.
I will appreciate any kind of help.
You bound all Toggle to one state... so
remove this
// #State private var doneStatus : Bool = false
bind Toggle dynamically to currently iterating item (note: .onAppear/.onTapGesture not needed anymore)
Toggle(isOn: Binding<Bool>(
get: { item.done },
set: {
item.done = $0
try? self.moc.save()
})) {
Text()
}
.labelsHidden()
TL;DR version:
I am using .onDisappear method in SwiftUI in my app having Core Data. How to make this method conditional if a particular instance of my Entity exists? (Specifically, based on existence of the item details I am looking at, self.item)
--
MORE DETAILS:
Simple example of the problem
Here is a very simple app with a list of items. One Entity is: Item. It has 2 attributes: date (Date), name (String). Please create this Entity if you will use the code below.
There are 2 SwiftUI views:
ContentView.swift
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
var fetchRequest: FetchRequest<Item>
var items: FetchedResults<Item> { fetchRequest.wrappedValue }
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) {item in
NavigationLink(destination: DetailsView(item: item)) {
Text("\(item.name ?? "default item name")")
}
}
}
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button(action: {
for number in 1...3 {
let item = Item(context: self.moc)
item.date = Date()
item.name = "Item \(number)"
do {
try self.moc.save()
}catch{
print(error)
}
}
}) {
Text("Add 3 items")
}
)
}
}
init() {
fetchRequest = FetchRequest<Item>(entity: Item.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Item.name, ascending: true)
])
}
}
DetailsView.swift
import SwiftUI
struct DetailsView: View {
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var item: Item
var body: some View {
VStack {
Text("\(item.name ?? "default item name")")
}
.navigationBarItems(
trailing:
Button(action: {
self.moc.delete(self.item)
do {
try self.moc.save()
self.presentationMode.wrappedValue.dismiss()
}catch{
print(error)
}
}) {
Text("Delete")
.foregroundColor(.red)
}
)
//This is causing problems. It tries to save new item name, even if I just deleted this item.
.onDisappear {
self.item.name = "new name"
try? self.moc.save()
}
}
}
What this example app does is:
Creates 3 items in Core Data for testing purposes.
When you tap on one item, you will go to DetailsView.
I want to be able to Delete and item from the DetailsView.
I am aware of this great tutorial from Paul Hudson: https://www.hackingwithswift.com/books/ios-swiftui/deleting-from-a-core-data-fetch-request on how to add Delete on the view with list. And it works great. But this is not what I need for my app.
THE PROBLEM
When I delete an item from the DetailsView, I am getting an error:
020-06-26 19:02:24.534275+0700 list-delete[57017:18389643] [error]
error: Mutating a managed object 0x80784bd2555f567e
x-coredata://99AA316D-D816-49BB-9C09-943F307C3174/Item/p41
(0x600002f03d40) after it has been removed from its context. CoreData:
error: Mutating a managed object 0x80784bd2555f567e
x-coredata://99AA316D-D816-49BB-9C09-943F307C3174/Item/p41
(0x600002f03d40) after it has been removed from its context.
I have eventually figured out that the problem is with .onDisappear method. Which is triggered even after deleting the item.
The main purpose of using .onDisappear here was to make it available to edit an item in DetailsView and .onDisappear would save the changes in CoreData. And this alone works great too.
How I fixed the bug
I have added a #State private var itemExists = true. When I tap Delete button, self.itemExists changes to false. And I have added an IF statement to .onDisappear using this itemExists variable. Updated code of DetailsView looks like this:
import SwiftUI
struct DetailsView: View {
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var item: Item
#State private var itemExists = true
var body: some View {
VStack {
Text("\(item.name ?? "default item name")")
}
.navigationBarItems(
trailing:
Button(action: {
self.moc.delete(self.item)
self.itemExists = false
do {
try self.moc.save()
self.presentationMode.wrappedValue.dismiss()
}catch{
print(error)
}
}) {
Text("Delete")
.foregroundColor(.red)
}
)
.onDisappear {
if self.itemExists {
self.item.name = "new name"
try? self.moc.save()
}
}
}
}
Now the question is if this additional variable is necessary? Or is there a simpler way of checking if Core Data's Entity instance exists?
I have tried another IF:
if self.item != nil {
But this just gave me a warning:
Comparing non-optional value of type 'Item' to 'nil' always returns
true
isDeleted will be true on any managed object that has been marked for deletion. You can check that instead of tracking with a separate variable. You might want to only save the context on onDisappear instead of after the deletion, though, as the documentation seems to suggest it's intended for use between deleting and saving.