10

I have an app that

  1. Individually extracts every element of an array (through indices)
  2. Then bind it to a struct that can make use of that single element (viewing and editing)

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
            //Text View
            TextView(text: self.$TextArray[index])
            .padding()
            }
            //Array modifying button
            Button(action: {
                self.TextArray = ["A","B"]
            }){
                Text(" Shrink array ")
                .padding()
            }
        }
    }
}

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




#if DEBUG
struct test_Previews: PreviewProvider {
    static var previews: some View {
        test()
    }
}
#endif

Is there any better way to satisfy the two requirements above without causing this problem or any way to circumvent this problem ? Any responses are really appreciated.

Nguyễn Khắc Hào
  • 1,980
  • 2
  • 15
  • 25
  • Well to be honest my app is a flashcard app. A flashcard can flip, be edited, include photos and many more so I'm not sure I can include such features into a List instead of a Stack. The array is actually the user's data for the cards and not the app's user interface so it changes all the time :( – Nguyễn Khắc Hào Aug 23 '19 at 19:48

2 Answers2

6

@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)
    }
}
Fabian
  • 5,040
  • 2
  • 23
  • 35
4

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
GrandSteph
  • 2,053
  • 1
  • 16
  • 23
  • Thanks! This solution works and doesn't require switching to `ObservableObject` , so its marked as the new accepted answer – Nguyễn Khắc Hào May 08 '20 at 22:54
  • Im interested in how this works, What is viewModel here? It sounds like an observable object like the previous answer which makes this not a new answer at all, but rather a copy, – Fabian May 10 '20 at 00:28
  • @Fabien, in computer science there are many way to do the same thing. The newly accepted answer allows the OP to keep his structure of choice, it's certainly not a copy of the previous one (yours). Coincidently my answer was downvoted once right after it was taken away from you. That would be appropriate if you the downvoter could explain why a proxy binding is such a bad choice or maybe why ObservableObject is more in line with the SwiftUI paradigm. In the meantime, I'll keep helping this community by not downvoting your solution that is also acceptable in my opinion. – GrandSteph May 10 '20 at 08:39
  • @GrandSteph You did not answer my questions at all. I am the same as you in wanting to help people. But the same answer does help no one but you. I ask again, what is the difference between the proxy binding answer and ObservableObject? It seems this is also using the same viewModel from the last answer. – Fabian May 10 '20 at 19:46
  • @GrandSteph I hope I am not discouraging you. I really appreciate new answers in case they can make a difference :) – Fabian May 10 '20 at 19:56
  • @Fabien I agree with what you say, my point was that you downvoted my answer for no good reason. Usually, when people downvote, they comment on why so everybody understand what is wrong with it. What you do is say it's a copy of your answer (which is not, it's another way of doing it) and then ask a totally legit question which I can't answer. – GrandSteph May 11 '20 at 10:11
  • 1
    @Fabian Its not a copied answer. You can use `@State var TextArray` and its still works. i've edited his answer to make it clearer – Nguyễn Khắc Hào May 12 '20 at 05:08
  • 2
    Haha I take it back, I was confused due to `viewModel.TextArray` instead of `textArray` on its own. I wonder why it works though, do you have any idea? – Fabian May 12 '20 at 19:52
  • 1
    I was confused at first too, but didn't want to edit someone else's answer to my preference. I have no idea why it works but it really did... – Nguyễn Khắc Hào May 13 '20 at 10:13
  • 1
    @Fabian observableObject works because it forces a redraw of the view when changed (just look at the definition in Xcode when you select this type) The issue arises because the ForEach expects a constant range to minimize the number of redraws. The observable object forces the ForEach to redraw (since its a view) and therefore with the updated range. I’m not sure about why proxy binding works but my best guess is that by explicitly setting the binding it forces ForEach to redraw. – GrandSteph May 15 '20 at 06:48
  • 1
    @Fabien ObservableObject was the way to go you were right on this. It works because being the single source of truth it gets updated before the view and the view is aware of it when it redraws. However, in your answer you made a copy of it and although it might work, it might also cause issues or headaches later. – GrandSteph Jun 16 '20 at 08:51