macosswiftuicore-dataswiftui-listswiftui-navigationsplitview

How to prevent SwiftUI DetailView from crashing when deleting the last entity from CoreData collection?


I have a CoreData entity called TestItem:

enter image description here

If there's 1 entity in collection, deleting this entity will crash the app with an error: "Thread 1: EXC_BREAKPOINT (code=1, subcode=0x197c0b8b4)". As it seems, nothing will prevent DetailView from crashing once it's been initialized with ObservedObject, which cannot be nil. I can't find a way to deinit DetailView before deleting the object. To have more information why I couldn't deinit DetailView, see my previous post

Here is a code example reproducing the error as following:


import SwiftUI
import CoreData

struct ContentView: View {
    let persistence = PersistenceController.shared
    
    @FetchRequest(entity: TestItem.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \TestItem.date, ascending: false)])
    var items: FetchedResults<TestItem>
    
    @State var selectedItem: TestItem?
    @State var selectedItemIndex: Int?
    
    var body: some View {
        NavigationSplitView {
            List(selection: $selectedItem) {
                ForEach(items) { item in
                    VStack(alignment: .leading) {
                        Text(item.name)
                            .foregroundColor(selectedItem == item ? .blue : .white)
                    }
                    .onTapGesture {
                        selectedItem = item
                        selectedItemIndex = items.firstIndex(of: item)
                    }
                }
            }
            .toolbar {
                Button(action: {
                    addItem()
                }) {
                    Image(systemName: "plus.square")
                }
            }
        } detail: {
            if let selectedItem = selectedItem {
                DetailView(item: selectedItem, onDelete: {
                    guard !items.isEmpty else {
                        self.selectedItem = nil
                        selectedItemIndex = nil
                        return
                    }
                    
                    if selectedItemIndex! > items.indices.last! {
                        selectedItemIndex = selectedItemIndex! - 1
                    }
                    
                    self.selectedItem = items[selectedItemIndex!]
                })
            }
        }
    }
    
    private func addItem() {
        let newItem = TestItem(context: persistence.container.viewContext)
        newItem.name = "Test Item"
        newItem.date = Date()
        
        do {
            try persistence.container.viewContext.save()
            selectedItem = newItem
            selectedItemIndex = 0
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
    }
}

struct DetailView: View {
    @ObservedObject var item: TestItem
    var onDelete: () -> Void
    
    var body: some View {
        VStack {
            Text(item.name)
            
            DatePicker("Date", selection: $item.date, displayedComponents: [.date])
            
            HStack {
                Button(action: {
                    deleteItem(item)
                }) {
                    Image(systemName: "trash")
                }
                Spacer()
            }
            
            Spacer()
        }
    }
    
    private func deleteItem(_ item: TestItem) {
        item.managedObjectContext?.delete(item)

        DispatchQueue.main.async {
            do {
                try item.managedObjectContext?.save()
                onDelete()
            } catch let error as NSError {
                print("Error deleting data: \(error.localizedDescription)")
            }
        }
    }
}


Solution

  • It turns out that the crashed is caused by the DatePicker that I guess holds a strong reference to the Item object. If we break this reference the app will not crash when the last object is deleted.

    This can be done by using a State object instead for the DatePicker selection

    @State private var selectedDate: Date = .now
    

    used here

    DatePicker("Date", selection: $selectedDate, displayedComponents: [.date])
    

    and update the item when this property is changed

    .onChange(of: selectedDate) { date in
        item.date = date
    }
    

    We also need to update selectedDate when a new TestItem object is selected in the sidebar

    .onChange(of: item) { newItem in
        selectedDate = newItem.date
    }
    

    I don't know if this relevant for any other components than the DatePicker, I for instance changed so that I could edit the name attribute using a TextField but that didn't cause any issues so it's definitely not an issue for all components where you can mutate the content.