'Crash when deleting an item from List in SwiftUI with custom RandomAccessCollection

Basic approach

I am currently tring to clean up my Core Data/SwiftUI code, and part of that is moving code out of my Views and into my ViewModels. Since it seems that @FetchRequest does not work in VMs, I tried to create something similar that works in VMs (basically a wrapper around NSFetchedResultsController):

class FetchedData<ResultType: NSManagedObject>: NSFetchedResultsController<NSFetchRequestResult>, NSFetchedResultsControllerDelegate, ObservableObject {
    
    @Published var value: [ResultType] = []
    
    convenience init(entity: ResultType.Type) {
         let request = NSFetchRequest<NSFetchRequestResult>()
         request.entity = ResultType.entity()
         request.sortDescriptors = []

         super.init(
            fetchRequest: request,
            managedObjectContext: PersistenceController.shared.context,
            sectionNameKeyPath: nil, 
            cacheName: nil
         )

         self.delegate = self
        
         do {
            try self.performFetch()
            value = (self.fetchedObjects as? [ResultType]) ?? []
         } catch {
            print("Could not perform fetch")
         }
     }
    
    internal func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        guard let newValue = controller.fetchedObjects as? [ResultType] else {
            return
        }
        
        value = newValue
    }
}

So far so good, I can use this as follows:

struct ContentView: View {

    class ViewModel: ObservableObject {
        private var cancellables: [AnyCancellable] = []
        var data = FetchedData(entity: Element.self)
        
        init() {
            data.$value.sink { _ in
                self.objectWillChange.send()
            }
            .store(in: &cancellables)
        }
    }

    @StateObject var viewModel = ViewModel()

    var body: some View {
        List {
            ForEach(viewModel.data.value, id: \.self) { element in
                // show UI for element
            }
        }
    }
}

Making FetchedData a collection

Now I wanted to simplify the usage of this a bit, and make FetchedData a collection so I can use it directly and don't have to use value. I added this:

extension FetchedData: RandomAccessCollection {

    typealias Element = ResultType
    typealias Index = Int
    typealias Indices = Range<Int>
    typealias Iterator = IndexingIterator<[Element]>

    subscript(position: Int) -> ResultType {
        return value[position]
    }

    var startIndex: Int {
        return value.startIndex
    }

    var endIndex: Int {
        return value.endIndex
    }

    __consuming func makeIterator() -> IndexingIterator<[Element]> {
        return value.makeIterator()
    }
}

and then changed my View to this:

struct ContentView: View {

    class ViewModel: ObservableObject {
        let data = LiveData(entity: Element.self)
    }

    @StateObject var viewModel = ViewModel()

    var body: some View {
        Group {
            List {
                ForEach(viewModel.data, id: \.self) { element in
                    // show UI for element
                }
            }
        }
    }
}

The Issue

It... kind of works. It will show and updated my data, and I can add Elements and the view will update just fine. The problem occurs when I try to delete data using this:

.onDelete { indexSet in
    indexSet.forEach { index in
        let element = viewModel.data[index]
        viewContext.delete(element)
    }
                            
    try? viewContext.save()
}

My app will crash with a Fatal error: Index out of range exception in the subscript method of my extension.

For example, if I had 5 elements in my collection and delete one, first the deletion happens and then SwiftUI apparently queries index 4 from viewModel.data (which doesn't exist anymore, since only 4 elements are left). The strange thing is that SwiftUI seems to query endIndex before that, which returns the correct value, so I don't know why it would query something it knows is out of bounds. Also, it seems that for some reason, deleting the last element seems to work just fine.

I can't really make heads or tails of this. Since it works using viewModel.data.values instead of just viewModel.data I suppose there is something wrong in my extension FetchedData: RandomAccessCollection, but I do not understand the issue here.

Any help would be appreciated!



Solution 1:[1]

Not sure if you still have this issue but I came across the same thing. The only way I could get it fixed was to use indices in the ForEach

ForEach(viewModel.data.indices, id: \.self) { idx in
    // show UI for element
    // using viewModel.data[idx] e.g.
    Text(viewModel.data[idx].description)
}

Sadly, I am unable to explain why this works but relying on the underlying collection does not.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Brian Parkes