3

Why, in the following app when clicking through to 'Nice Restaurant' and trying to add a contributor, does the app crash with the error: Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range?

The error, in the Xcode debugger, has no obviously useful stack trace and points straight at the '@main' line.

There are no explicit array indices used in the code nor any uses of members like .first.

I'm using Xcode Version 13.4.1 (13F100) I'm using simulator: iPhone 13 iOS 15.5 (19F70)

import SwiftUI

struct CheckContribution: Identifiable {
    let id: UUID = UUID()
    var name: String = ""
}

struct Check: Identifiable {
    var id: UUID = UUID()
    var title: String
    var contributions: [CheckContribution]
}

let exampleCheck = {
    return Check(
        title: "Nice Restaurant",
        contributions: [
            CheckContribution(name: "Bob"),
            CheckContribution(name: "Alice"),
        ]
    )
}()

struct CheckView: View {
    @Binding var check: Check
    @State private var selectedContributor: CheckContribution.ID? = nil
    
    func addContributor() {
        let newContribution = CheckContribution()
        check.contributions.append(newContribution)
        selectedContributor = newContribution.id
    }
    
    var body: some View {
        List {
            ForEach($check.contributions) { $contribution in
                TextField("Name", text: $contribution.name)
            }
            Button(action: addContributor) {
                Text("Add Contributor")
            }
        }
    }
}


@main
struct CheckSplitterApp: App {
    @State private var checks: [Check] = [exampleCheck]
    var body: some Scene {
        WindowGroup {
            NavigationView {
                List {
                    ForEach($checks) { $check in
                        NavigationLink(destination: {
                            CheckView(check: $check)
                        }) {
                            Text(check.title).font(.headline)
                        }
                    }
                }
            }
        }
    }
}

I've noticed that:

  • If I unroll the ForEach($checks) the crash doesn't occur (but I need to keep the ForEach so I can list all the checks)
  • If I don't take a binding to the CheckContribution (ForEach($check.contributions) { $contribution in then the crash doesn't occur (but I need the binding so subviews can modify the CheckContribution
  • If I don't set the selectedContributor then the crash doesn't occur (but I need the selectedContributor in the real app for navigation purposes)
Jared Khan
  • 333
  • 2
  • 12
  • Apparently SwiftUI doesn't like the multiple levels of `ForEach` bindings like this, which is...disappointing. You can write your own `bindingForContribution` function and not use the `$` syntax, but I still think there should be a cleaner way. I would *not* go for the reference type solution as suggested in an answer, as you'll likely break lots of other SwiftUI functionality by using a reference type. My vote for most likely to have a clean solution is @Asperi – jnpdx Jul 10 '22 at 23:57
  • just put `Button(action: addContributor)...` outside your `List`, works for me. You are adding a `CheckContribution`, while you are looping over the array of `CheckContributions`. – workingdog support Ukraine Jul 11 '22 at 00:00
  • @jnpdx One concern I can think of (which is not a problem in this case) is that using `@State` with reference types is bad. Here the `@State private var checks` is still an `Array` (struct). I think an `Array` of reference types held in `@State` is still OK in SwiftUI. Since UI changes will happen only when the number of elements changes. Please correct me if I'm wrong and I would update my answer. – alobaili Jul 11 '22 at 00:19
  • @jnpdx I agree with you that changing `Check` to be a reference type is a bad idea here. The UI wouldn't update when trying to change any of the other properties. I will update or delete my answer. – alobaili Jul 11 '22 at 00:29
  • I replaced my answer with what I think is a better one that keeps `Check` as a `struct` and preserve UI updates without issues. – alobaili Jul 11 '22 at 00:56

3 Answers3

1

The cleanest way I could find that actually works is to further separate the nested ForEach into a subview and bind the contributors array to it.

struct CheckView: View {
    @Binding var check: Check
    @State private var selectedContributor: CheckContribution.ID? = nil

    func addContributor() {
        let newContribution = CheckContribution()
        check.contributions.append(newContribution)
        selectedContributor = newContribution.id
    }

    var body: some View {
        List {
            ContributionsView(contributions: $check.contributions)

            Button(action: addContributor) {
                Text("Add Contributor")
            }

            // Test that changing other properties still works.
            Button("Change title", action: changeTitle)
        }
        .navigationTitle(check.title)
    }

    func changeTitle() {
        check.title = "\(Int.random(in: 1...100))"
    }
}

struct ContributionsView: View {
    @Binding var contributions: [CheckContribution]

    var body: some View {
        ForEach($contributions) { $contribution in
            TextField("Name", text: $contribution.name)
        }
    }
}

I'm still not sure about the internals of SwiftUI, and why it works this way. I hope it helps. And maybe another more experienced user can provide a clear explanation to this.

alobaili
  • 761
  • 9
  • 23
  • In the real app contribution item view needs to have a binding to the seletedContributor because each one is going to be a navigation link with a `.tag`. If I add `@Binding var selectedContributor: CheckContribution.ID?` to the ContributionsView in this example and pass the binding down, I get the same old crash. – Jared Khan Jul 11 '22 at 22:35
0

If you really want the Button to be in the List, then you could try this approach using a separate view, works well for me:

struct CheckView: View {
    @Binding var check: Check
    @State private var selectedContributor: CheckContribution.ID? = nil
    
    var body: some View {
        List {
            ForEach($check.contributions) { $contribution in
                TextField("Name", text: $contribution.name)
            }
            AddButtonView(check: $check)  // <-- here
        }
    }
}

struct AddButtonView: View {
    @Binding var check: Check
    
    func addContributor() {
        let newContribution = CheckContribution(name: "new contribution")
        check.contributions.append(newContribution)
    }
    
    var body: some View {
        Button(action: addContributor) {
            Text("Add Contributor")
        }
    }
}
  • Yes I also came to the same conclusion in my answer. It's interesting how the trick seems to be creating another custom subview. I still don't understand why this works. Great find. – alobaili Jul 11 '22 at 01:18
  • Thanks for this answer. Here, the AddButtonView is not updating selectedContributor and I'm looking for a solution that does keep that up-to-date. In my original code, removing the write to selectedContributor also causes the app not to crash. Adding that back in to your example by passing a binding to selectedContributor down to AddButtonView causes this to crash again. – Jared Khan Jul 11 '22 at 07:34
0

I had the same error but with tabview. Moreover, the fall was only on iOS 15, but on iOS 16 it worked perfectly and there were no falls.

I tried both through indexes, and through checking for finding the index inside the range, but nothing helped.

The solution was found in the process of debugging: I noticed that it was falling even before the predstavlenie appeared (it worked Appear). I did a simple check to see if the data array is empty

if !dataArray.isEmpty {
    TabView(selection: $selection) {
        ForEach(dataArray, id: \.self) { item in
            ...
        } 
    }
}

And it worked - there were no more crashes on iOS 15. Apparently there was some problem with the processing of empty arrays before iOS 16.

Sergei Volkov
  • 818
  • 8
  • 15