肘子的 Swift 记事本

@State Research in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

This article aims to explore and analyze the implementation and operational characteristics of @State in SwiftUI. It concludes with an idea and example for extending the functionality of @State. Readers should have a basic understanding of reactive programming in SwiftUI.

Why is this Research Significant?

At the end of last year, I used SwiftUI to develop my first iOS App “Health Notes,” marking my first encounter with the concept of reactive programming. After gaining some basic knowledge and experience, I was deeply impressed by this programming paradigm. However, I encountered some peculiar issues in use. As I mentioned in Old Man New Soldier - A Development Memoir of an iOS APP, I found that as the number of views (View) and the volume of data increased, the app’s responsiveness began to lag, becoming sticky and unresponsive. This issue was partly due to my inefficient code and poor data structure design, but further analysis revealed that a significant part of the problem stemmed from the implementation of reactivity in SwiftUI. Improper use could lead to a significant decrease in response speed as the amount of data and views increases. After some research and analysis, I plan to discuss these issues in two articles and try to provide a current usage approach.

Data (State) Driven

In SwiftUI, views are driven by data (state). According to Apple, views are a function of state, not a sequence of events. Whenever a view is created or resolved, a dependency is created between that view and the state data used within it. When the state’s information changes, the dependent views immediately reflect these changes and redraw. SwiftUI provides @State, @ObservedObject, @EnvironmentObject, etc., to create different types and scopes of state.

Types and Scopes

Image source: SwiftUI for Absolute Beginners

@State is only used within the current view, and its corresponding data type is a value type (if it must correspond to a reference type, a new instance must be recreated each time it is assigned).

Swift
struct DemoView:View{
  @State var name = "Elbow"
  var body:some View{
    VStack{
      Text(name)
      Button("Rename"){
        self.name = "Big Elbow"
      }
    }
  }
}

Executing the above code, we can observe two scenarios:

  1. Using @State allows us to modify values in the structure without using ‘mutating’.

  2. When the state value changes, the view automatically redraws to reflect the change.

How Does @State Work?

Before analyzing how @State works, we need to understand a few concepts:

Property Wrappers

As one of the new features in Swift 5.1, property wrappers add a layer between managing how a property is stored and defining the property’s code. This feature benefits validation, persistence, encoding, and decoding of values.

Its implementation is simple, as shown in the example below, which defines a wrapper to ensure the wrapped value always remains less than or equal to 12. If a larger number is stored, it will store 12. The presented value (projected value) returns whether the current wrapped value is even.

Swift
@propertyWrapper
struct TwelveOrLess {
    private var number: Int
    init() { self.number = 0 }
    var wrappedValue: Int {
        get { return number }
        set { number = min(newValue, 12) }
    }
    var projectedValue: Bool {
        self.number % 2 == 0
    }
}

For more details, refer to the official documentation.

Binding

Binding is a first-class reference to data in SwiftUI, serving as the bridge for two-way data binding. It allows reading and writing data without owning it. Bindings can be made to various types, including State, ObservedObject, and even another Binding. Binding itself is a wrapper for Getter and Setter.

Definition of State

Swift
@frozen @propertyWrapper public struct State<Value> : DynamicProperty {

    /// Initialize with the provided initial value.
    public init(wrappedValue value: Value)

    /// Initialize with the provided initial value.
    public init(initialValue value: Value)

    /// The current state value.
    public var wrappedValue: Value { get nonmutating set }

    /// Produces the binding referencing this state value
    public var projectedValue: Binding<Value> { get }
}

Definition of DynamicProperty

Swift
public protocol DynamicProperty {

    /// Called immediately before the view's body() function is
    /// executed

, after updating the values of any dynamic properties
    /// stored in `self`.
    mutating func update()
}

Working Principle

Previously, we discussed that @State serves two purposes:

  1. By using @State, we can modify the values in a structure without needing to use mutating.

  2. When the state value changes, the view is automatically redrawn to reflect the change in state.

Based on these points, let’s analyze how these functionalities are achieved.

  • @State itself contains @propertyWrapper, which means it is a property wrapper.

  • public var wrappedValue: Value { get nonmutating set } implies that its wrapped value is not stored locally.

  • Its projected value (projected value) is of Binding type, meaning it’s just a conduit, a reference to the wrapped data.

  • It follows the DynamicProperty protocol, which provides the interface needed to create dependencies between data (state) and views. Currently, it exposes only a few interfaces, limiting our full utilization.

With this understanding, let’s attempt to construct our own version of a @State prototype with the following code:

Swift
@propertyWrapper
struct MyStates: DynamicProperty {
    init(wrappedValue: String) {
        UserDefaults.standard.set(wrappedValue, forKey: "myString")
    }
    
    var wrappedValue: String {
        nonmutating set { UserDefaults.standard.set(newValue, forKey: "myString") }
        get { UserDefaults.standard.string(forKey: "myString") ?? "" }
    }
    
    var projectedValue: Binding<String> {
        Binding<String>(
            get: { String(self.wrappedValue) },
            set: {
                self.wrappedValue = $0
        }
        )
    }
    
    func update() {
        print("Redraw view")
    }
}

This is a State wrapper for the String type.

We use UserDefaults to wrap and store the data locally. The wrapped data is also read from the local UserDefaults.

To wrap other types of data and to improve storage efficiency, the code can be further modified as follows:

Swift
@propertyWrapper
struct MyState<Value>: DynamicProperty {
    private var _value: Value
    private var _location: AnyLocation<Value>?
    
    init(wrappedValue: Value) {
        self._value = wrappedValue
        self._location = AnyLocation(value: wrappedValue)
    }
    
    var wrappedValue: Value {
        get { _location?._value.pointee ?? _value }
        nonmutating set { _location?._value.pointee = newValue }
    }
    
    var projectedValue: Binding<Value> {
        Binding<Value>(
            get: { self.wrappedValue },
            set: { self._location?._value.pointee = $0 }
        )
    }
    
    func update() {
        print("Redraw view")
    }
}

class AnyLocation<Value> {
    let _value = UnsafeMutablePointer<Value>.allocate(capacity: 1)
    init(value: Value) {
        self._value.pointee = value
    }
}

With this, we have completed our prototype of @MyState.

The reason it’s a prototype is that, although we follow the DynamicProperty protocol, our code cannot establish dependencies with views. We can use @MyState just like @State, supporting binding and modification, except the view does not automatically refresh 😂.

But at least we can roughly understand how @State allows us to modify and bind data in views.

When Are Dependencies Established?

Currently, I cannot find any more specific information or implementation clues about how SwiftUI establishes dependencies. However, we can speculate on how the compiler handles the timing of data and view dependency associations through the following two code snippets:

Swift
struct MainView: View {
    @State var date: String = Date().description
    var body: some View {
        print("mainView")
        return Form {
            SubView(date: $date)
            Button("Modify Date") {
                self.date = Date().description
            }
        }
    }
}

struct SubView: View {
    @Binding var date: String
    var body: some View {
        print("subView")
        return Text(date)
    }
}

Executing this code, when we click Modify Date, we get the following output:

Bash
mainView
subView
...

Although we declared date using @State in MainView and modified its value there, since we didn’t use the value of date for display or decision-making in MainView, no matter how we modify the value of date, MainView will not be redrawn. I speculate that the dependency of @State with the view is established during the ViewBuilder parsing. The compiler, while parsing our body, determines whether changes in the data of date will affect the current view. If not, no dependency association is established.

We can analyze the compiler’s reaction to ObservedObject with another piece of code:

Swift
struct MainView: View {
    @ObservedObject var store = AppStore()
    
    var body: some

 View {
        print("mainView")
        return Form {
            SubView(date: $store.date)
            Button("Modify Date") {
                self.store.date = Date().description
            }
        }
    }
}

struct SubView: View {
    @Binding var date: String
    var body: some View {
        print("subView")
        return Text(date)
    }
}

class AppStore: ObservableObject {
    @Published var date: String = Date().description
}

After execution, the output is as follows:

Bash
mainView
subView
mainView
subView
...

We replaced @State with @ObservedObject, and even though we didn’t display the value of store.date or use it for decisions in MainView, as soon as we change the date value in the store, MainView will refresh and redraw. This suggests that SwiftUI adopts a different timing for creating dependencies for ObservedObject: the dependency is established upon initialization of MainView, regardless of whether the body requires it. Once send is triggered in ObservableObject’s objectWillChange, a redraw is initiated. Hence, ObservedObject’s dependency is likely established when MainView is initialized.

The reason for examining this issue is that the difference in timing for creating these dependencies can lead to significant differences in view update efficiency. This difference is precisely what my next article will focus on.

Crafting Your Own Enhanced @State

@State utilizes the property wrapper feature for its intended functionality, but property wrappers are widely used in many areas, including data validation and side effects. Can we integrate multiple functional properties into one?

In this article, we will create a prototype @State through coding, which cannot establish dependencies with views. How can we create this kind of dependency association?

@State is not only used for wrapping properties, but it is also a standard struct itself. It establishes dependencies with views through internal interfaces that are not exposed.

The following two usage methods are equivalent:

Swift
@State var name = ""
self.name = "Elbow"
var name = State<String>(wrappedValue:"")
self.name.wrappedValue = "Elbow"

Therefore, we can create a new property wrapper by using State as the wrapped value type, achieving our ultimate goal — a fully functional, extendable enhanced @State.

Swift
@propertyWrapper
struct MyState<Value>:DynamicProperty{
    typealias Action = (Value) -> Void
    
    private var _value:State<Value>
    private var _toAction:Action?
    
    init(wrappedValue value:Value){
        self._value = State<Value>(wrappedValue: value)
    }
    
    init(wrappedValue value:Value,toAction:@escaping Action){
        self._value = State<Value>(wrappedValue: value)
        self._toAction = toAction
    }
    
    public var wrappedValue: Value {
        get {self._value.wrappedValue}
        nonmutating set {self._value.wrappedValue = newValue}
    }
    
    public var projectedValue: Binding<Value>{
        Binding<Value>(
            get: {self._value.wrappedValue},
            set: {
                self._value.wrappedValue = $0
                self._toAction?($0)
        }
        )
    }
    
    public func update() {
       print("Redrawing view")
    }
    
}

This code is just an example and can be customized according to one’s needs.

Swift
@MyState var name = "hello"  // Implements the same functionality as the standard @State
@MyState<String>(
  wrappedValue: "hello", 
  toAction: {print($0)}
) var name
// Executes the function defined in toAction after each assignment (including modifications via Binding)

What Next?

In the era of rising popularity of reactive programming, more and more people are using the Single Source of Truth architectural approach for design and development. How to use @State, which is scoped only to the current view? Just from the name, Apple has given it the most fundamental name — State. State belongs to the SwiftUI architecture, while ObservableObject belongs to the Combine architecture. SwiftUI’s optimization for State is obviously better than for ObservableObject. How to maximize the benefits of SwiftUI optimization while maintaining a single data source? I will explore this further in my next article.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways