punkbit
punkbit

Reputation: 7717

Why isn't a closure in @State array property triggering state change?

A test view has @State showTitle, title and items where the title value text is controlled by a closure assigned to a CTA show title.

When the showTitle state changes, the value presented in the body Content of test view changes accordingly:

Text({ self.showTitle ? "Yes, showTitle!" : "No, showTitle!" }())

While the case where the closure is a value in the array items does not change. Why isn't the closure triggering the title state?

NestedView(title: $0.title())

I've done tests with both Foobar as Struct and Class.

import SwiftUI

struct Foobar: Identifiable {
    var id: UUID = UUID()
    var title: () -> String

    init (title: @escaping () -> String) {
        self.title = title
    }
}

struct test: View {
    @State var showTitle: Bool = true
    @State var title: String
    @State var items: [Foobar]

    var body: some View {
        VStack {
            Group {
                Text("Case 1")
                Text({ self.showTitle ? "Yes, showTitle!" : "No, showTitle!" }())
            }
            Group {
                Text("Case 2")
                ForEach (self.items, id: \.id) {
                    NestedView(title: $0.title())
                }
            }
            Button("show title") {
                print("show title cb")
                self.showTitle.toggle()
            }
        }.onAppear {
            let data = ["hello", "world", "test"]
            for title in data {
                self.items.append(Foobar(title: { self.showTitle ? title : "n/a" }))
            }
        }
    }
}

struct NestedView: View {
    var title: String
    var body: some View {
        Text("\(title)")
    }
}

What's expected is that "Case 2" to have a similar side-effect we have in "Case 1" that should display "n/a" on showTitle toggle.

Output:

enter image description here

Upvotes: 0

Views: 392

Answers (2)

Cristik
Cristik

Reputation: 32879

From what I understand, the reason why the initial code does not work is related to the showTitle property that is passed to the Array and holds a copy of the value

You were right to blame the closure capturing the value at the onAppear time. Basically due to this, SwiftUI doesn't know to refresh the list when the showTitle value changes, as there's no Binding involved that SwiftUI can use to know when to re-render the list.

I can provide two alternative solutions, that don't require another class just to hold the bool value. Both solutions involve communicating to SwiftUI that you need the showTitle binding to refresh the titles.

  1. Don't use a closure for title, defer the title computation to the list builder:
struct Foobar: Identifiable {
    var id: UUID = UUID()
    var title: String

    init (title: String) {
        self.title = title
    }    
}

...

ForEach (self.items, id: \.id) {
    NestedView(title: self.showTitle ? $0.title : "n/a" )
}

...

.onAppear {
    let data = ["hello", "world", "test"]
    self.items = data.map { Foobar(title: $0) }
}
  1. Convert the title closure to a (Binding<Bool>) -> String one, inject the $showTitle binding from the view:
struct Foobar: Identifiable {
    var id: UUID = UUID()
    var title: ((Binding<Bool>) -> String)
    
    init (title: @escaping (Binding<Bool>) -> String) {
        self.title = title
    }
}

...

ForEach (self.items, id: \.id) {
    // here we pass the $showTitle binding, thus SwiftUI knows to re-render
    // the view when the binding value is updated
    NestedView(title: $0.title(self.$showTitle))
}

...

.onAppear {
    let data = ["hello", "world", "test"]
    self.items = data.map { Foobar(title: { $0.wrappedValue ? title : "n/a" })) }
}

Personally, I'd go with the first solution, since it better transmit the intent.

Upvotes: 1

punkbit
punkbit

Reputation: 7717

From what I understand, the reason why the initial code does not work is related to the showTitle property that is passed to the Array and holds a copy of the value (creates a unique copy of the data).

I did think @State would make it controllable and mutable, and the closure would capture and store the reference (create a shared instance). In other words, to have had a reference, instead of a copied value! Feel free to correct me, if that's not the case, but that's what it looks like based on my analysis.

With that being said, I kept the initial thought process, I still want to pass a closure to the Array and have the state changes propagated, cause side-effects, accordingly to any references to it!

So, I've used the same pattern but instead of relying on a primitive type for showTitle Bool, created a Class that conforms to the protocol ObservableObject: since Classes are reference types.

So, let's have a look and see how this worked out:

import SwiftUI

class MyOption: ObservableObject {
    @Published var option: Bool = false        
}

struct Foobar: Identifiable {
    var id: UUID = UUID()
    var title: () -> String

    init (title: @escaping () -> String) {
        self.title = title
    }
}

struct test: View {
    @EnvironmentObject var showTitle: MyOption
    @State var title: String
    @State var items: [Foobar]

    var body: some View {
        VStack {
            Group {
                Text("Case 1")
                Text(self.showTitle.option ? "Yes, showTitle!" : "No, showTitle!")
            }
            Group {
                Text("Case 2")
                ForEach (self.items, id: \.id) {
                    NestedView(title: $0.title())
                }
            }
            Button("show title") {
                print("show title cb")
                self.showTitle.option.toggle()
                print("self.showTitle.option: ", self.showTitle.option)
            }
        }.onAppear {
            let data = ["hello", "world", "test"]
            for title in data {
                self.items.append(Foobar(title: { self.showTitle.option ? title : "n/a" }))
            }
        }
    }
}

struct NestedView: View {
    var title: String
    var body: some View {
        Text("\(title)")
    }
}

The result as expected:

enter image description here

Upvotes: 1

Related Questions