Khắc Hào
Khắc Hào

Reputation: 2678

Using ForEach loop with Binding causes index out of range when array shrinks (SwiftUI)

I have an app that

  1. Has a collection of editable items (like a bunch of notes)
  2. Need to bind these items to a child view that can edit each item (like a note editor)

But every time the array reduces in size, it causes an index out of range error that is not directly because of my code

As far as I know, it's because: after the loop refreshes with the changed array, the views it created before somehow isn't completely removed and still trying access the out of range part. But that's all I can figure out myself

Here is my sample code:

import SwiftUI


struct Test: View {
    @State var textArray = ["A","B","C"]

    var body: some View {
        VStack {
            ForEach(textArray.indices, id: \.self){ index in
                TextView(text: self.$textArray[index])
                    .padding()
            }

            //Array modifying button

            Button(action: {
                textArray = ["A","B"]
            }) {
                Text("Shrink array")
                    .padding()
            }
        }
    }
}

struct TextView: View {
    @Binding var text: String

    var body: some View {
        Text(text)
    }
}

Is there any better way to satisfy the two requirements above without causing this problem? Thank you.

Upvotes: 12

Views: 3885

Answers (2)

GrandSteph
GrandSteph

Reputation: 2271

Finally got the ins and outs of that issue that I was experiencing myself.

The problem is architectural. It is 2 folds:

  1. You are making a copy of your unique source of truth. ForEach loops Textfield but you are passing a copy through Binding. Always work on the single source of truth
  2. Combined with ForEach ... indices is supposed to be a constant range (hence the out of range when you remove an element)

The below code works because it loops through the single source of truth without making a copy and always updates the single source of truth. I even added a method to change the string within the subview since you originally passed it as a binding, I imagine you wanted to change it at some point


import SwiftUI

class DataSource: ObservableObject {
    @Published var textArray = ["A","B","C"]
}

struct Test: View {

    @EnvironmentObject var data : DataSource

    var body:some View {
        VStack{
            ForEach(self.data.textArray , id: \.self) {text in
                TextView(text: self.data.textArray[self.data.textArray.firstIndex(where: {text == $0})!])
            .padding()
            }

            //Array modifying button
            Button(action: {
                self.data.textArray.removeLast()
            }){
                Text(" Shrink array ")
                .padding()
            }
        }
    }
}

struct TextView:View {

    @EnvironmentObject var data : DataSource

    var text:String

    var body:some View {
        VStack {
            Text(text)
            Button(action: {
                let index = self.data.textArray.firstIndex(where: {self.text == $0})!
                self.data.textArray[index] = "Z"
            }){
                Text("Change String ")
                .padding()
            }
        }
    }    
}

#if DEBUG
struct test_Previews: PreviewProvider {
    static var previews: some View {
        Test().environmentObject(DataSource())
    }
}
#endif

Upvotes: 4

Fabian
Fabian

Reputation: 5348

@State does seem to not be able to handle this, but ObservableObject works.

I do not claim to know why apart from my best guess, which is that @State tries too hard to avoid redraws by anticipating what the user wants, but in so doing does not support this.

Meanwhile ObservableObject redraws everything on each small change. Works.

class FlashcardData: ObservableObject {
    @Published var textArray = ["A","B","C"]

    func updateData() {
        textArray = ["A","B"]
    }
}

struct IndexOutOfRangeView: View {
    @ObservedObject var viewModel = FlashcardData()

    var body:some View {
        VStack{
            ForEach(viewModel.textArray.indices, id: \.self){ index in
                TextView(text: self.$viewModel.textArray[index])
                    .padding()
            }
            Button(action: {
                self.viewModel.textArray = ["A","B"]
            }){
                Text(" Shrink array ")
                    .padding()
            }
        }
    }
}

struct TextView:View {
    @Binding var text:String
    var body:some View {
        Text(text)
    }
}

Upvotes: 6

Related Questions