This article is reprinted from: onevcat.com/2021/01/swi… For the purpose of conveying more information, the copyright of this article belongs to the original author or source organization.

@ State basis

In SwiftUI, we use @State for private State management and drive the display of the View, which is the foundation of the foundation. For example, the following ContentView will display the number +1 when the plus button is clicked:

struct ContentView: View {
    @State private var value = 99
    var body: some View {
        VStack(alignment: .leading) {
            Text("Number: (value)")
            Button("+") { value += 1 }
        }
    }
}
Copy the code

When we want to pass this state value to the child View, we simply declare a variable in the child View. The following views are exactly the same in their presentation:

struct DetailView: View {
    let number: Int
    var body: some View {
        Text("Number: (number)")
    }
}

struct ContentView: View {
    @State private var value = 99
    var body: some View {
        VStack(alignment: .leading) {
            DetailView(number: value)
            Button("+") { value += 1 }
        }
    }
}
Copy the code

When the @state value in ContentView changes, contentView. body is re-evaluated, DetailView is recreated, and Text containing the new number is re-rendered. Everything is going well.

At sign State in the child View

If we don’t want exactly this passive pass-through, but rather want the DetailView to own the incoming State value and manage it itself, one approach is to let the DetailView hold its @state and pass it in via the initialization method:

struct DetailView0: View {
    @State var number: Int
    var body: some View {
        HStack {
            Text("0: (number)")
            Button("+") { number += 1 }
        }
    }
}

// ContentView
@State private var value = 99
var body: some View {
    // ...
    DetailView0(number: value)
}
Copy the code

This works, but violates what the @state documentation says about the attribute tag:

… declare your state properties as private, to prevent clients of your view from accessing them.

If @state can’t be marked private, something is wrong. The naive idea is to declare @state private and then set it with the appropriate init method. More often than not, we may need an initialization method to solve another, more “real” problem: using an appropriate initialization method to do something with the value passed in. For example, if we wanted to implement a View that could +1 any incoming data before displaying it:

struct DetailView1: View {
    @State private var number: Int

    init(number: Int) {
        self.number = number + 1
    }
    //
}
Copy the code

But this will give you a compilation error!

Variable ‘self.number’ used before being initialized

In the latest version of Xcode, the above method is fault-free: In the case of a type match in the initialization method, Swift compiles it to the value stored in the internal underlying store and sets it up. However, in the case of a type mismatch, this mapping is still temporarily invalid. For example, var number: Int? And the input parameter number: Int is an example. Therefore, I have decided to reserve the following discussion for a while.

At first you may be confused about this mistake. We’ll look at the reason for this error later in this article. Put that aside for now, and try to get it to compile. The easiest way to do that is to declare number as an Int, okay? :

struct DetailView1: View {
    @State private var number: Int?

    init(number: Int) {
        self.number = number + 1
    }

    var body: some View {
        HStack {
            Text("1: (number ?? 0)")
            Button("+") { number = (number ?? 0) + 1 }
        }
    }
}

// ContentView
@State private var value = 99
var body: some View {
    // ...
    DetailView1(number: value)
}
Copy the code

Q&a time, what do you think the Text in DetailView1 will display? Is it 0 or 100?

If you answered 100, congratulations, you missed the mark. “Surprising” because although we set self.number = 100 in init, number is nil when the body is first evaluated, so 0 is displayed on the screen.

@ within the State

The problem is @state: SwiftUI simplifies and emulates normal variable reading and writing via property Wrapper, but we must always keep in mind that @State Int is not the same as Int and is not a storage property in the traditional sense at all. The Property Wrapper does basically three things:

  1. Store variables for the underlyingState<Int>This struct provides a set of getters and setters, this oneStateIt’s stored in the structIntThe exact number of.
  2. Before the body is evaluated for the first time, theState<Int>Associate to currentView, for it corresponds to the current in the heapViewAllocate a storage location.
  3. for@StateModified variable set to observe when the value changes, triggering a new onebodyEvaluate and refresh the screen.

The public part of State we can see is just a few initialization methods and the standard value of a property Wrapper:

struct State<Value> : DynamicProperty {
    init(wrappedValue value: Value)
    init(initialValue value: Value)
    var wrappedValue: Value { get nonmutating set }
    var projectedValue: Binding<Value> { get }
}
Copy the code

However, by printing and dumping the value of State, it is easy to know its several private variables. Further, the relatively complete and “private” State structure can be roughly guessed as follows:

struct State<Value> : DynamicProperty { var _value: Value var _location: StoredLocation<Value>? var _graph: ViewGraph? Var wrappedValue: Value {get {_value} set {updateValue(newValue)}} func _linkToGraph(graph: ViewGraph) { if _location == nil { _location = graph.getLocation(self) } if _location == nil { _location = graph.createAndStore(self) } _graph = graph } func _renderView(_ value: Value) {if let graph = _graph {// Valid State Value _value = Value graph.triggerRender(self)}}Copy the code

SwiftUI uses Meta Data to find State variables in the View and injects the ViewGraph used for rendering into State. When State changes, the Graph is called to refresh the screen. The mechanics of the State rendering section are beyond the scope of this article. Have a chance to explore further in a later blog.

The @state declaration brings an automatically generated private storage property in the current View to store the real State struct value. For example, DetailView1 above, due to @state number, is actually equivalent to:

struct DetailView1: View { @State private var number: Int? private var _number: State<Int? > // automatically generated //... }Copy the code

@state var number: Int

struct DetailView1: View {
    @State private var number: Int

    init(number: Int) {
        self.number = number + 1
    }
    //
}
Copy the code

Int? When initialized, the declaration defaults to nil and lets _number do the initialization (State
>(_value: nil, _location: nil)); A non-optional number requires an explicit initialization value, otherwise the underlying _number will not be initialized when self.number is called.

The question of why Settings in init don’t work is solved. The @state setting only works after the View is added to the graph (before the body is evaluated for the first time).

In current versions of SwiftUI, automatically generated storage variables are underlined before the name of the State variable. Here’s another code style hint: While some languages use underscores to indicate private variables within a type, in SwiftUI it is best to avoid names such as _name as it can be captured by system-generated code (similar situations occur in some other Property Wrappers, Binding, etc.).

Several options are available

Once you know how a State struct works, there are several options to achieve the original goal of doing something to the incoming data in init.

The first is the direct operation _number:

struct DetailView2: View {
    @State private var number: Int

    init(number: Int) {
        _number = State(wrappedValue: number + 1)
    }

    var body: some View {
        return HStack {
            Text("2: (number)")
            Button("+") { number += 1 }
        }
    }
}
Copy the code

Since we are now directly involved in initializing _number, it has the correct initial value of 100 before it is added to the View. However, because _number obviously does not exist in any document, there is a risk that this behavior may fail at any time in the future.

An alternative is to temporarily store the number obtained in init and then assign it when the @state number is available (in the body) :

struct DetailView3: View { @State private var number: Int? private var tempNumber: Int init(number: Int) { self.tempNumber = number + 1 } var body: some View { DispatchQueue.main.async { if (number == nil) { number = tempNumber } } return HStack { Text("3: (number ?? 0)") Button("+") { number = (number ?? 0) + 1}}}}Copy the code

However, this approach is not very reasonable. The State document clearly states that:

You should only access a state property from inside the view’s body, or from methods called by it.

While DetailView3 works as expected, it is questionable whether accessing and changing state via dispatchqueue.main.async is recommended. Also, since the body can actually be evaluated multiple times, this part of the code will be run multiple times, and you have to consider whether it is correct when the body is reevaluated (for example, we need to add number == nil to avoid resetting the value). As well as creating waste, this makes maintenance more difficult.

A better place to set the initial value for this method is in onAppear:

struct DetailView4: View {
    @State private var number: Int = 0
    private var tempNumber: Int

    init(number: Int) {
        self.tempNumber = number + 1
    }

    var body: some View {
        HStack {
            Text("4: (number)")
            Button("+") { number += 1 }
        }.onAppear {
            number = tempNumber
        }
    }
}
Copy the code

While detailView4.init sets tempNumber to the most recent incoming value every time body is evaluated in ContentView, onAppear in DetailView4.body is called only once when it first appears on the screen. Multiple Settings are avoided while having some initialization logic.

If @state must be initialized from the outside, this method is recommended by the author. Initializer directly initializes @state from the outside, which is an anti-pattern: On the one hand it actually violates the assumption that @State should be purely private, and on the other hand since the View in SwiftUI is only a “virtual” structure rather than a real render object, it can be created multiple times in the body of another View, even though it is represented as the same View. Making an @state assignment in an initialization method is likely to result in an accidental overwriting of an existing State that has changed, which is often not the desired result.

State, Binding, StateObject, ObservedObject

The @stateObject situation is similar to @State: Views have ownership of the State, and they are not reinitialized with a new View init. This behavior is the opposite of Binding and ObservedObject: Using Binding and ObservedObject means that the View is not responsible for the underlying storage, and the developer has to decide and maintain the declaration cycle for “non-all” states.

Of course, if the DetailView doesn’t need its own, independently managed state, and instead wants to use the value directly from the ContentView and report back the changes to that value, there’s no question about using the standard @bining:

struct DetailView5: View {
    @Binding var number: Int
    var body: some View {
        HStack {
            Text("5: (number)")
            Button("+") { number += 1 }
        }
    }
}
Copy the code

State to reset

For this scenario, the most appropriate way to initialize the local State (or StateObject) is to assign a value in.onAppear. If you want to “synchronize” the parent view’s value to the child view again after the initial setting, you can choose to use the ID Modifier to remove the existing state from the child View. This can also be useful in some scenarios:

struct ContentView: View {
    @State private var value = 99

    var identifier: String {
        value < 105 ? "id1" : "id2"
    }
    
    var body: some View {
        VStack(alignment: .leading) {
            DetailView(number: value)
            Button("+") { value += 1 }
            Divider()
            DetailView4(number: value)
                .id(identifier)
    }
}
Copy the code

After being modified by the ID modifier, DetailView4 will check for the same identifier each time the body is evaluated. If inconsistencies occur, the original DetailView4 in the graph is deprecated, all states are cleared, and recreated. As a result, the latest value value will be re-set to DetailView4.tempNumber via the initialization method. The onAppear of the new View will also be triggered, and the processed input value will be displayed again.

conclusion

It saves a lot of trouble for @state to follow exactly how the documentation is intended to be used, and to avoid getting and setting its value outside of the body. A correct understanding of the working mode of @State and the timing of each change can help us find the right direction of analysis when we are confused, and finally give a reasonable explanation and prediction for these behaviors.

Download iOS related materials