Aнгел
Aнгел

Reputation: 1429

SwiftUI State var array not updating child views

For some reason I don't understand, when I add/remove items from a @State var in MainView, the OutterViews are not being updated properly.

What I am trying to achieve is that the user can only "flag" (select) one item at a time. For instance, when I click on "item #1" it will be flagged. If I click on another item then "item #1" will not be flagged anymore but only the new item I just clicked.

enter image description here

Currently, my code shows all items as if they were flagged even when they are not anymore. The following code has the minimum structure and functionality I'm implementing for MainView, OutterView, and InnerView.

I've tried using State vars instead of the computed property in OutterView, but it doesn't work. Also, I tried using a var instead of the computed property in OutterViewand initialized it in init() but also doesn't work.

Hope you can help me to find what I am doing wrong. Thanks!

struct MainView: View {
    @State var flagged: [String] = []
    
    var data: [String] = ["item #1", "item #2", "item #3", "item #4", "item #5"]
    
    var body: some View {
        VStack(spacing: 50) {
            VStack {
                ForEach(data, id:\.self) { text in
                    OutterView(text: text, flag: flagged.contains(text)) { (flag: Bool) in
                        if flag {
                            flagged = [text]
                        } else {
                            if let index = flagged.firstIndex(of: text) {
                                flagged.remove(at: index)
                            }
                        }
                    }
                }
            }
            
            Text("Flagged: \(flagged.description)")
            
            Button(action: {
                flagged = []
            }, label: {
                Text("Reset flagged")
            })
        }
    }
}

struct OutterView: View {
    @State private var flag: Bool
    
    private let text: String
    private var color: Color { flag ? Color.green : Color.gray }
    private var update: (Bool)->Void
    
    var body: some View {
        InnerView(color: color, text: text)
            .onTapGesture {
                flag.toggle()
                update(flag)
            }
    }
    
    init(text: String, flag: Bool = false, update: @escaping (Bool)->Void) {
        self.text = text
        self.update = update
        _flag = State(initialValue: flag)
    }
}

struct InnerView: View {
    let color: Color
    let text: String
    
    var body: some View {
        Text(text)
            .padding()
            .background(
                Capsule()
                    .fill(color))
    }
}

Upvotes: -1

Views: 1070

Answers (1)

jnpdx
jnpdx

Reputation: 52312

Here's a simple version that does what you're looking for (explained below):

struct Item : Identifiable {
    var id = UUID()
    var flagged = false
    var title : String
}

class StateManager : ObservableObject {
    @Published var items = [Item(title: "Item #1"),Item(title: "Item #2"),Item(title: "Item #3"),Item(title: "Item #4"),Item(title: "Item #5")]
    
    func singularBinding(forIndex index: Int) -> Binding<Bool> {
        Binding<Bool> { () -> Bool in
            self.items[index].flagged
        } set: { (newValue) in
            self.items = self.items.enumerated().map { itemIndex, item in
                var itemCopy = item
                if index == itemIndex {
                    itemCopy.flagged = newValue
                } else {
                    //not the same index
                    if newValue {
                        itemCopy.flagged = false
                    }
                }
                return itemCopy
            }
        }
    }
    
    func reset() {
        items = items.map { item in
            var itemCopy = item
            itemCopy.flagged = false
            return itemCopy
        }
    }
}

struct MainView: View {
    @ObservedObject var stateManager = StateManager()
    
    var body: some View {
        VStack(spacing: 50) {
            VStack {
                ForEach(Array(stateManager.items.enumerated()), id:\.1.id) { (index,item) in
                    OutterView(text: item.title, flag: stateManager.singularBinding(forIndex: index))
                }
            }
            
            Text("Flagged: \(stateManager.items.filter({ $0.flagged }).map({$0.title}).description)")
            
            Button(action: {
                stateManager.reset()
            }, label: {
                Text("Reset flagged")
            })
        }
    }
}

struct OutterView: View {
    var text: String
    @Binding  var flag: Bool
    private var color: Color { flag ? Color.green : Color.gray }
    
    var body: some View {
        InnerView(color: color, text: text)
            .onTapGesture {
                flag.toggle()
            }
    }
}

struct InnerView: View {
    let color: Color
    let text: String
    
    var body: some View {
        Text(text)
            .padding()
            .background(
                Capsule()
                    .fill(color))
    }
}

What's happening:

  1. There's a Item that has an ID for each item, the flagged state of that item, and the title
  2. StateManager keeps an array of those items. It also has a custom binding for each index of the array. For the getter, it just returns the state of the model at that index. For the setter, it makes a new copy of the item array. Any time a checkbox is set, it unchecks all of the other boxes.
  3. The ForEach now gets an enumeration of the items. This could be done without enumeration, but it was easy to write the custom binding by index like this. You could also filter by ID instead of index. Note that because of the enumeration, it's using .1.id for the id parameter -- .1 is the item while .0 is the index.
  4. Inside the ForEach, the custom binding from before is created and passed to the subview
  5. In the subview, instead of using @State, @Binding is used (this is what the custom Binding is passed to)

Using this strategy of an ObservableObject that contains all of your state and passes it on via @Published properties and @Bindings makes organizing your data a lot easier. It also avoids having to pass closures back and forth like you were doing initially with your update function. This ends up being a pretty idiomatic way of doing things in SwiftUI.

Upvotes: 1

Related Questions