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 anid
you can simply conform toIdentifiable
protocol and omit the, id: \.id
argument in yourForEach
loop.