SwiftUI's ForEach crashes when array's element is removed

Viewed 563

I try to lay out my UI elements in rows - two elements in a row, for that I use two ForEach, one for rows and one for elements in a row. Each UI element has @Binding so I pass a structure from my model's array. I should be able to add or remove the elements dynamically and everything works except one thing - when I remove the only element from a row my app crashes with error Fatal error: Index out of range: file Swift/ContiguousArrayBuffer.swift, line 444. I have read several topics on SO but I haven't found an answer.

This is how my code looks like:

struct PickerElement: Hashable {
    let id: Int
    let title: String
    let value: Int
}
struct CellModel: Hashable {
    var element: PickerElement?
    var error: String?
}
struct PickerButton: View {
    @Binding var value: CellModel?
    @Binding var error: String?

    (...)
}
class MyModel: ObservableObject {

    @Published var counter = 0
    @Published var cellModels = [CellModel]()

    private var cancellables = Set<AnyCancellable>()

    $counter.sink { value in
        let diff = value - self.cellModels.count
        
        if diff > 0 {
            self.cellModels.append(contentsOf:
                Array(repeating: CellModel(), count: diff)
            )
        } else if diff < 0 {
            self.cellModels = Array(
                self.cellModels.prefix(value)
            )
        }
    }.store(in: &cancellables)

}
struct MyView: View {
    @ObservedObject var model: MyModel
    
    var body: some View {
        VStack(spacing: 8) {
            layOutElements()
        }
    }

    @ViewBuilder
    func layOutElements() -> some View {
        let elementsCount = model.cellModels.count
        
        if elementsCount > 0 {
            VStack {
                HStack {
                    Spacer()
                    Text("Some title").font(.caption)
                    Spacer()
                }.padding()

                // count number of rows
                let rowsCount = Int(ceil(Double(elementsCount) / 2.0))

                // lay out rows
                ForEach(0 ..< rowsCount, id: \.self) { rowIndex in
                    layOutRow(rowIndex: rowIndex,
                              elementsCount: elementsCount,
                              rowsCount: rowsCount)
                }
            }
        }
    }
@ViewBuilder
private func layOutRow(rowIndex: Int, elementsCount: Int, rowsCount: Int) -> some View {
    HStack(alignment: .top, spacing: 8) {
        let firstCellInRowIndex = rowIndex * 2
        let lastCellInRowIndex = min(elementsCount - 1, firstCellInRowIndex + 1)

        ForEach(firstCellInRowIndex ... lastCellInRowIndex, id: \.self) { elementIndex in
            PickerButton(value: $model.cellModels[elementIndex].element, // <--- *1
                         error: $model.cellModels[elementIndex].error) // <--- *2
        }

        // *1 , *2 - if I changed the lines and pass dummy bindings (not array's elements) there, the code would work without any glitches

        if rowIndex == rowsCount - 1 && !elementsCount.isMultiple(of: 2) {
            Spacer()
                .frame(minWidth: 0, maxWidth: .infinity)
        }
    }
}

If I change a value of @Published var counter = 0 everything works properly, views are added and removed, but while decrementing if SwiftUI tries to remove the last remaining element from a row the app crashes. As I have commented in the code above, if I don't bind PickerButton to the structures from my model's array, the app doesn't crash. How to fix this issue? (I need to use indexes because I have to count rows and cells in a row)

1 Answers

The crash happens when elementsCount in layOutElements() becomes 0. Then layOutElements() doesn't return a View, which should be impossible. You can add an else to your if statement that returns EmptyView() or some placeholder.

Other than that, in my experience ForEach with dynamic ranges is a mess. If you need indexes you can use Array.enumerated() which has given me better results.

Related