Home > Software design >  SwiftUI: View crashes when removing object from array stored in observableObject
SwiftUI: View crashes when removing object from array stored in observableObject

Time:07-06

I have an observable object that stores an array of contentItems (struct). My "root" view owns the observable object and subviews are generated with ForEach. Each subview has a textfield that should modify its content stored in the array. This works fine until I use the delete button to remove one item of the array. This causes the view to crash. The error message says: "Fatal error: Unexpectedly found nil while unwrapping an Optional value". Of corse it can't find the index because it doesn't exist any more. Why is the subview still in the render loop??
For better understanding I simplified my code. This code runs on iOS/iPadOS


Simplified code:
import SwiftUI

class obs: ObservableObject {
    
    @Published var contentArray : [contentItem] = []
    
    func removeItem(id: UUID) {
        contentArray.remove(at: contentArray.firstIndex(where: {  $0.id == id })!)
    }
}

struct contentItem  {
    var id : UUID = UUID()
    var str : String = ""
}

struct ForEachViewWithObservableObjetTest: View {
    
    @StateObject var model : obs = obs()
    
    var body: some View {
        VStack{
            Button("add") { model.contentArray.append(contentItem()) }
                .padding(.vertical)
            
            ScrollView{
                ForEach(model.contentArray, id: \.id) { content in
                    contentItemView(id: content.id, model: model)
                }
            }
        }
    }
}

struct contentItemView : View {
    
    var id : UUID
    @ObservedObject var model : obs
    
    var body: some View {
        
        HStack{
            
            TextField("Placeholder", text: $model.contentArray[ model.contentArray.firstIndex(where: { $0.id == id })! ].str)
                .fixedSize()
                .padding(3)
                .background(.teal)
                .foregroundColor(.white)
                .cornerRadius(7)
            
            Spacer()
            
            Image(systemName: "xmark.circle")
                .font(.system(size: 22))
                .foregroundColor(.red)
                 
                // tap to crash - I guess
                .onTapGesture { model.removeItem(id: id) }
            
        }.padding()
         .padding(.horizontal, 100)
    }
}

I were able to fix the issue by adding an if else check into the binding wrapper but this feels wrong and like a bad workaround.

   TextField("Placeholder", text:
                        Binding<String>(
                            get: {
                                if let index = model.contentArray.firstIndex(where: { $0.id == id }) {
                                      return model.contentArray[index].str
                                }
                                else { return "Placeholder" }
                
                            }, set: { newValue in
                                if let index = model.contentArray.firstIndex(where: { $0.id == id }) {
                                      model.contentArray[index].str = newValue
                                }
                                else { }
                            }))

With this method I noticed that while deleting the subview, the textfield in the subview refreshes and thus causes the crash.

How can I fix this issue properly?

CodePudding user response:

Best would be to pass the ContentItem itself down to your ContentItemView. In order to do so see the following commented code.

class Obs: ObservableObject {
    
    @Published var contentArray : [ContentItem] = []
    
    func removeItem(id: UUID) {
        contentArray.remove(at: contentArray.firstIndex(where: {  $0.id == id })!)
    }
}

struct ContentItem: Identifiable {
    var id : UUID = UUID()
    var str : String = ""
}

struct ForEachViewWithObservableObjetTest: View {
    
    @StateObject var model: Obs = Obs()
    
    var body: some View {
        VStack{
            Button("add") { model.contentArray.append(ContentItem()) }
                .padding(.vertical)
            
            ScrollView{
                
                //iterate over the contentarray
                // but with the binding
                ForEach($model.contentArray) { $content in
                    //pass the content binding on to the subview
                    ContentItemView(content: $content, model: model)
                }
            }
        }
    }
}

struct ContentItemView : View {
    //Add the contentitem as binding
    @Binding var content: ContentItem
    @ObservedObject var model: Obs
    
    var body: some View {
        
        HStack{
            //use the content binding
            TextField("Placeholder", text: $content.str)
                .fixedSize()
                .padding(3)
                .background(.teal)
                .foregroundColor(.white)
                .cornerRadius(7)
            
            Spacer()
            
            Image(systemName: "xmark.circle")
                .font(.system(size: 22))
                .foregroundColor(.red)
                 
                // tap to crash - I guess
                // pass the id of the contentitem on to the viewmodel
                .onTapGesture { model.removeItem(id: content.id) }
            
        }.padding()
         .padding(.horizontal, 100)
    }
}

Remarks:

  • You got your naming wrong. Uppercased for classes and structs and lowercased for properties.
  • As your ContentItem allready contains an id you can simply conform to Identifiable protocol and omit the , id: \.id argument in your ForEach loop.
  • Related