iosswiftswiftuiswiftui-listdeclarative-programming

SwiftUI list item not updated if model is wrapped in @State


Given

Tapping on a button in the main view, is expected the model to change and propagate the changes to the ItemView.

The problem is that the changes only propagate if the model struct is stored in the ItemView as a normal variable; but if i add the @State property wrapper these do not happen. The view will update but not change (like if the data has been cached).

Question 1: is this an expected behaviour? If so, why? I was expecting to have the ItemView to only update when the model change by observing it throw @State, this way instead the view will always refresh whenever the list commands it, even if the data is not updated?

Question 2: Is it normal otherwise to have the items of a list using plain structs properties as models? Using observable classes would create much more complexity when handling the array in the view model and also make more complicated the List refreshing/identifying mechanism seems to me.

In the example the model does not need the @State, since changes are only coming from outside, in real world i would need it when it's the view itself to trigger the changes?

This is a stripped down version to reproduce the issue (create a project and replace ContentView with following):

import SwiftUI

struct Deck: Identifiable {

    let id: Int
    var name: String

    init(_ name: String, _ id: Int) {
        self.name = name
        self.id = id
    }
}

struct ItemView: View {
    // @State var deck: Deck  // DOES NOT WORK !!! <-------------------
    let deck: Deck             // WORKS (first element is updated)

    var body: some View { Text(deck.name) }
}

class Model: ObservableObject {

    @Published var decks: [Deck] = getData()

    static func getData(changed: Bool = false) -> [Deck] {
        let firstElement = changed ? "CHANGED ELEMENT" : "0"
        return [Deck(firstElement, 0), Deck("1", 1), Deck("2", 2)]
    }

    func changeFirst() { self.decks = Self.getData(changed: true) }
}

struct ContentView: View {

    @StateObject var model = Model()

    var body: some View {
        List {
            ForEach(model.decks) { deck in
                ItemView(deck: deck)
            }
            Button(action: model.changeFirst) {
                Text("Change first item")
            }
        }
    }
}

Tested with Xcode 13 / iPhone13 Simulator (iOS 15)


Solution

  • Question 1 Yes, it is expected because @State and @Published are sources of truth. @State breaks the connection with @Published and makes a copy.

    Question 2 If all the changes are outside (one-way connection) you don't need wrappers of any kind for the children when dealing with value types.

    If you need a two-way connection you use @Binding when dealing with a struct/value type.

    https://developer.apple.com/wwdc21/10022

    https://developer.apple.com/documentation/swiftui/managing-user-interface-state

    https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app