42

Im finding it impossible to use core data with SwiftUI, because as I pass a core data to a view observed object variable, the navigation link view will hold a reference to the object even after the view has disappeared, so as soon as I delete the object from context the app crashes, with no error messages.

I have confirmed this by wrapping the core data object variable into a view model as an optional, then set the object to nil right after the context delete action and the app works fine, but this is not a solution because I need the core data object to bind to the swift ui views and be the source of truth. How is this suppose to work? I seriously cannot make anything remotely complex with SwiftUI it seems.

I have tried assigning the passed in core data object to a optional @State, but this does not work. I cannot use @Binding because it's a fetched object. And I cannot use a variable, as swiftui controls require bindings. It only makes sense to use a @ObservedObject, but this cannot be an optional, which means when the object assigned to it gets deleted, the app crashes, because i cannot set it to nil.

Here is the core data object, which is an observable object by default:

class Entry: NSManagedObject, Identifiable {

    @NSManaged public var date: Date
}

Here is a view that passes a core data entry object to another view.

struct JournalView: View {

    @Environment(\.managedObjectContext) private var context

    @FetchRequest(
        entity: Entry.entity(),
        sortDescriptors: [],
        predicate: nil,
        animation: .default
    ) var entries: FetchedResults<Entry>

    var body: some View {
        NavigationView {
            List {
                ForEach(entries.indices) { index in
                    NavigationLink(destination: EntryView(entry: self.entries[index])) {
                        Text("Entry")
                    }
                }.onDelete { indexSet in
                    for index in indexSet {
                        self.context.delete(self.entries[index])
                    }
                }
            }
        }
    }
}

Now here is the view that accesses all the attributes from the core data entry object that was passed in. Once, I delete this entry, from any view by the way, it is still referenced here and causes the app to crash immediately. I believe this also has something to do with the Navigation Link initializing all destination view before they are even accessed. Which makes no sense why it would do that. Is this a bug, or is there a better way to achieve this?

I have even tried doing the delete onDisappear with no success. Even if I do the delete from the JournalView, it will still crash as the NavigationLink is still referencing the object. Interesting it will not crash if deleting a NavigationLink that has not yet been clicked on.

struct EntryView: View {

    @Environment(\.managedObjectContext) private var context
    @Environment(\.presentationMode) private var presentationMode

    @ObservedObject var entry: Entry

    var body: some View {
        Form {

            DatePicker(selection: $entry.date) {
                Text("Date")
            }

            Button(action: {
                self.context.delete(self.entry)
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("Delete")
            }
        }
    }
}

UPDATE

The crash is taking me to the first use of entry in the EntryView and reads Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0).. thats the only message thrown.

The only work around I can think of is to add a property to the core data object "isDeleted" and set it to true instead of trying to delete from context. Then when the app is quit, or on launch, I can clean and delete all entries that isDeleted? Not ideal, and would prefer to figure out what it wrong here, as it appears I'm not doing anything different then the MasterDetailApp sample, which seems to work.

Eye of Maze
  • 1,327
  • 8
  • 20

11 Answers11

13

I basically had the same issue. It seems that SwiftUI loads every view immediately, so the view has been loaded with the Properties of the existing CoreData Object. If you delete it within the View where some data is accessed via @ObservedObject, it will crash.

My Workaround:

  1. The Delete Action - postponed, but ended via Notification Center
    Button(action: {
      //Send Message that the Item  should be deleted
       NotificationCenter.default.post(name: .didSelectDeleteDItem, object: nil)

       //Navigate to a view where the CoreDate Object isn't made available via a property wrapper
        self.presentationMode.wrappedValue.dismiss()
      })
      {Text("Delete Item")}

You need to define a Notification.name, like:

extension Notification.Name {

    static var didSelectDeleteItem: Notification.Name {
        return Notification.Name("Delete Item")
    }
}
  1. On the appropriate View, lookout for the Delete Message

// Receive Message that the Disease should be deleted
    .onReceive(NotificationCenter.default.publisher(for: .didSelectDeleteDisease)) {_ in

        //1: Dismiss the View (IF It also contains Data from the Item!!)
        self.presentationMode.wrappedValue.dismiss()

        //2: Start deleting Disease - AFTER view has been dismissed
        DispatchQueue.main.asyncAfter(deadline: .now() + TimeInterval(1)) {self.dataStorage.deleteDisease(id: self.diseaseDetail.id)}
    }

  1. Be safe on your Views where some CoreData elements are accessed - Check for isFault!

    VStack{
         //Important: Only display text if the disease item is available!!!!
           if !diseaseDetail.isFault {
                  Text (self.diseaseDetail.text)
            } else { EmptyView() }
    }

A little bit hacky, but this works for me.

sTOOs
  • 564
  • 1
  • 5
  • 9
  • 1
    Awesome, thank you for this solution. I will have to try with this out. The workaround I came up with in the meantime was to add an attribute to the entity named "inTrash" and set that to true on delete, filter out trash in the fetch requests and clean up all the trash on launch, not ideal but this is working for me as well. – Eye of Maze Dec 06 '19 at 22:05
10

I have had the same issue for a while, the solution for me was pretty simple: In the View where the @ObservedObject is stored I simply put this !managedObject.isFault.

I experienced this class only with ManagedObjects with a date property, I don't know if this is the only circumstance the crash verifies.

import SwiftUI

struct Cell: View {
    
    @ObservedObject var managedObject: MyNSManagedObject
    
    var body: some View {
        if !managedObject.isFault {
           Text("\(managedObject.formattedDate)")
        } else {
            ProgressView()
        }
    }
}

Luca
  • 914
  • 9
  • 18
9

I encountered the same issue and did not really find a solution to the root problem. But now I "protect" the view that uses the referenced data like this:

var body: some View {
    if (clip.isFault) {
        return AnyView(EmptyView())
    } else {
        return AnyView(actualClipView)
    }
}

var actualClipView: some View {
    // …the actual view code accessing various fields in clip
}

That also feelds hacky, but works fine for now. It's less complex than using a notification to "defer" deletion, but still thanks to sTOOs answer for the hint with .isFault!

Benjamin Graf
  • 111
  • 2
  • 3
  • This is a really nice and small fix to the issue. Thanks! – funkenstrahlen Oct 14 '20 at 11:53
  • Tangential comment, but it would be better to wrap the body in a `Group` and remove the `AnyView` wrappers. E.g.: ``` var body: some View { Group { if clip.isFault { EmptyView() } else { actualClipView } } } ``` – Andrew Bennet Feb 01 '21 at 09:01
4

After some research online, it's clear to me that this crash can be caused by many things related to optionals. For me, I realized that declaring a non-optional Core Data attribute as an optional in the NSManagedObject subclass was causing the issue.

Specifically, I have a UUID attribute id in Core Data that cannot have a default value, but is not optional. In my subclass, I declared @NSManaged public var id: UUID. Changing this to @NSManaged public var id: UUID? fixed the problem immediately.

Dovizu
  • 405
  • 3
  • 13
3

I had the same issue recently. Adding an entity property to the view fixed it.

ForEach(entities, id: \.self) { entity in
    Button(action: {

    }) {
        MyCell(entity: entity)
    }
}

To

ForEach(entities, id: \.self) { entity in
    Button(action: {

    }) {
        MyCell(entity: entity, property: entity.property)
    }
}

I suspect that the nullable Core Data entity is the cause of the issue, where as adding a non-nil property as a var (e.g, var property: String) fixed it

sacriorv
  • 31
  • 1
  • This worked for me. I changed `UserRow(user: entity)` to `UserRow(user: entity, property: entity.contactID)` which is a string and my ObservedObject is `entity` so it passes both `entity` and the contactID to the cellView but I don't use the ContactID for anything, it just stopped it from crashing when the Contact was deleted, the list refreshes as it should. Thanks – Kurt L. Oct 14 '20 at 00:00
  • This did not work for me. iOS 16. – Mikrasya Dec 03 '22 at 08:45
1

A view modifier for this (based on conditional view modifiers):

import SwiftUI
import CoreData

extension View {
    @ViewBuilder
    func `if`<Transform: View>(
        _ condition: Bool,
        transform: (Self) -> Transform
    ) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

extension View {
    func hidingFaults(_ object: NSManagedObject) -> some View {
        self.if(object.isFault) { _ in EmptyView() }
    }
}

Having said that, it's worth checking you're performing CoreData operations asynchronously on the main thread, doing it synchronously can be a source of grief (sometimes, but not always).

Luke Howard
  • 469
  • 5
  • 5
  • Do you mean that we should be doing viewContext.perform to cause it to be asynchronous? That alone didn't fix the crash. I had to hide the faults with your solution. – Chris Slade Jul 30 '21 at 00:02
1

I have tried all previous solutions, none worked for me.

This one, worked.

I had my list like this:

List {
  ForEach(filteredItems, id: \.self) { item in
    ListItem(item:item)
  }
.onDelete(perform: deleteItems)


private func deleteItems(offsets: IndexSet) {
  //deleting items

This was crashing.

I modified the code to this one

List {
  ForEach(filteredItems, id: \.self) { item in
    ListItem(item:item)
  }
  .onDelete { offsets in
     // delete objects
  }

This works fine without crashing.

For heaven's sake, Apple!

Duck
  • 34,902
  • 47
  • 248
  • 470
1

Apple says this (and it works perfectly) :

The behavior you've reported is the result of a system bug, and should be fixed in a future release. As a workaround, you can prevent the race condition by wrapping your deletion logic in NSManagedObjectContext.perform:

private func deleteItems(offsets: IndexSet) {
withAnimation {
    viewContext.perform {
        offsets.map { molts[$0] }.forEach(viewContext.delete)
        do {
            try viewContext.save()
        } catch {
            viewContext.rollback()
            userMessage = "\(error): \(error.localizedDescription)"
            displayMessage.toggle()
        }
    }
}

You can find the full thread here https://developer.apple.com/forums/thread/668299

Vincent Zgueb
  • 1,491
  • 11
  • 11
1

For me, I got this because of a force-unwrapped binding.

I used a Binding($item.someProperty)! like this: TextField("Description", text: Binding($item.someProperty)!).

This was because item is a Core Data class and hence someProperty is a String? instead of a String. Binding(*)! was a solution proposed in https://stackoverflow.com/a/59004832.

I changed the implementation to use a null coalescing operator for bindings as proposed in https://stackoverflow.com/a/61002589, now it doesn't crash anymore.

Arjan
  • 16,210
  • 5
  • 30
  • 40
0

Wrap your deletion logic in a withAnimation block to prevent a crash after deleting a Core Data object. No need for isFault, isDeleted, or deferring execution in other complicated ways.

withAnimation {
    context.delete(object)
    do try catch etc...
}
0

My experience with the problem:

Check if you are on the main queue. My crashes disappeared when wrapping my CoreData delete call into a DispatchQueue.main.async call. CoreData is not thread safe and coming from another queue might lead to those crashes.

Nico S.
  • 3,056
  • 1
  • 30
  • 64