0

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+)

5
  • Remove that Equatable implementation. Also use .animation instead of with animation Commented Dec 9, 2024 at 12:15
  • The Equatable implementation is literally telling SwiftUI only redraw when the id changes Commented Dec 9, 2024 at 12:17
  • \.self isn't a valid key path for the id param Commented Dec 9, 2024 at 13:42
  • @StateObject isn't for view models that's what the View struct is for Commented Dec 9, 2024 at 13:43
  • @MadMan All @State and @StateObject properties should always (without exception) be private. This means that the way you create the GridItemView is already problematic. States should never be injected from outside, but always generated in the view itself. Bindings are there for external injections. Commented Dec 9, 2024 at 19:24

1 Answer 1

1

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:

@MainActor
final class ViewModel: ObservableObject {
    @Published var assetsRows: [AssetsRowModel] = [
        AssetsRowModel(items: [.init(), .init(), .init()])
    ]

    func deleteSelected(assetsIn row: AssetsRowModel) {
        withAnimation(.default.delay(3)) {
            assetsRows.removeAll { element in
                element.id == row.id
            }
        }
    }
}

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:

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach($viewModel.assetsRows, id: \.self) { assetsRow in
                    RowWithButtonView(assetsRow: assetsRow) {
                        viewModel.deleteSelected(assetsIn: $0)
                    }
                }
            }
        }
    }
}

struct RowWithButtonView: View {
    @State private var isDeleted = false
    @Binding var assetsRow: AssetsRowModel
    let deleteAction: (AssetsRowModel) -> Void

    var body: some View {
        VStack {
            Button(action: {
                isDeleted = true
                deleteAction(assetsRow)
            }, label: {
                HStack {
                    Image(systemName: "trash")
                    Text("Delete row")
                }
            })
            .disabled(isDeleted)

            RowView(assetsRow: $assetsRow)
        }
    }
}

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.

Sign up to request clarification or add additional context in comments.

1 Comment

Your solution works well in this example. I'll have to try implementing it in a real application.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.