I have an array of elements that contains a nested array of other elements inside.
When deleting a row of an array, sometimes a crash occurs with the message
‘Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range’
not pointing at concrete line of code.
Here’s my minimal reproducible code:
// View components
struct ContentView: View {
@StateObject var viewModel: ViewModel = .init()
var body: some View {
ScrollView {
LazyVStack {
ForEach($viewModel.assetsRows, id: .self) { assetsRow in
VStack {
Button(action: {
viewModel.deleteSelected(assetsIn: assetsRow.wrappedValue)
}, label: {
HStack {
Image(systemName: "trash")
Text("Delete row")
}
})
RowView(assetsRow: assetsRow)
}
}
}
}
}
}
struct RowView: View {
@Binding var assetsRow: AssetsRowModel
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach($assetsRow.items, id: .self) { item in
GridItemView(
assetItem: item,
image: .init(systemName: "photo.fill")
)
}
}
}
}
}
struct GridItemView: View {
@Binding var assetItem: AssetItem
@State var image: Image?
var body: some View {
Group {
if let image = image {
image
} else {
ProgressView()
}
}
.frame(width: 200, height: 120)
.overlay(alignment: .bottomTrailing) {
Toggle(isOn: $assetItem.isSelected) {
Text("checkmark")
}
.padding(4)
}
.onAppear {
// fetch image logic
}
}
}
@MainActor final class ViewModel: ObservableObject {
@Published var assetsRows: [AssetsRowModel] = {
var array: [AssetsRowModel] = []
for i in 0..<30 {
array.append(.init(items: [.init(), .init(), .init()]))
}
return array
}()
// removing items causes crash (not 100% times)
func deleteSelected(assetsIn row: AssetsRowModel) {
withAnimation {
assetsRows.removeAll { element in
element.id == row.id
}
}
}
// other fetching logic
}
// Models
struct AssetsRowModel: Identifiable, Equatable, Hashable {
var id = UUID()
var items: [AssetItem]
}
struct AssetItem: Identifiable, Hashable {
var id = UUID()
var isSelected = false
}
extension AssetItem: Equatable {
static func ==(lhs: AssetItem, rhs: AssetItem) -> Bool {
(lhs.id == rhs.id)
}
}
Tried to change @Binding
to @State
in RowView
, it’s prevent the crash, but isSelected
doesn’t working properly, because it’s not ‘binding’ with viewModel’s value.
I guess this is an internal SwiftUI bug. (Xcode 15.4, iOS 17+)
2
Answers
Try this approach using
ForEach($viewModel.assetsRows)
without theid: .self
and the
$assetsRow
etc … for the bindings. Also nowithAnimation
in theViewModel
,withAnimation
is only meaningful when used in aView
.There is a race condition in your code that is exacerbated by the use of
withAnimation
, although its use is not necessarily causal.As always when looking for problems, it is advisable to first reduce the problem to its essence and minimum:
As you can see, I have reduced the list to one element, but I have delayed the asynchronous animation by 3 seconds.
If you now press the delete button twice in quick succession, you will see that your app crashes with the ‘Index out of range’ error.
The problem is that you can trigger the delete method via the Button element on an element that has already been deleted in the data model, but is still visible in the view.
To solve the problem, you must therefore ensure that no further deletion (or any other action) can be triggered for an element that has already been deleted.
Removing the
withAnimation
is probably the easiest solution, because it ensures that the view without animation disappears almost immediately and can therefore no longer be tapped.Depending on your specific app, you can of course also introduce a state for the deletion process to prevent multiple executions. The following very simple example shows one possibility:
Also note that your code could of course have several more such problems that we can’t necessarily see in your simple example. However, my description above and the example should help you to identify and solve these problems.