Search code examples
swiftswiftui

SwiftUI: Why does @State persist in subviews?


This seems like a simple question but I am struggling to find any resources about it online.

As an example, I have this code:

import SwiftUI

struct ChildView: View {
    @Binding var parentText: String
    @State var childText: String = ""
    
    var body: some View {
        Form {
            TextField("Parent Text", text: $parentText)
            TextField("Child Text", text: $childText)
        }
    }
}

struct ParentView: View {
    @State var parentText: String = ""
    
    var body: some View {
        VStack {
            Text(parentText)
            ChildView(parentText: $parentText)
        }
    }
}

#Preview {
    ParentView()
}

To test, I ran this code and set child text to "C" and then the parent text to "P". This works fine and results in a view with P and C in the form. What I am wondering is why the child text is not set to the empty string once I update the parent text to "P".

My understanding of @State is that when parentText changes, the ParentView body will reload, which means it will reinitialize ChildView(), and I would expect childText to also be reinitialized to the empty string. Obviously, this is not the case.

Why does this happen? Is this just an optimization by Swift that I cannot generally rely on, or is this behavior predictable? If it is predictable, under what conditions will the child views State variables maintain their values?


Solution

  • On a language level, the @State property wrapper is translated to something like this (for childText):

    private var _childText: State<String> = State(wrappedValue: "")
    
    var childText: String {
        get { _childText.wrappedValue }
        nonmutating set { _childText.wrappedValue = newValue }
    }
    
    var $childText: String {
        get { _childText.projectedValue }
    }
    

    The only stored property here is _childText, so whenever ChildView.init is called, the initialiser call State(wrappedValue: "") gets run.

    As Cheolhyun's answer says, each @State has its own persistent storage somewhere in memory. When State(wrappedValue: "") gets run for the first time, this persistent storage is allocated and initialised to have an initial value of "", and _childText.wrappedValue and .projectedValue both refer to that storage location.

    As you said, when parentText updates, ParentView.body is evaluated, and hence ChildView.init and State(wrappedValue: "") gets run again. This time, however, State(wrappedValue: "") doesn't allocate a new storage. SwiftUI finds that the ChildView hasn't changed its identity, so it reuses the same storage. It initialises _childText in such a way that _childText.wrappedValue and .projectedValue both refer to that old storage location.

    Note that "identity" mentioned above is a very important concept in SwiftUI. The WWDC video "Demystifying SwiftUI" explains what identity is in detail.

    As a simple example, we can set ChildView's identity using the id modifier. Let's make it so that its identity is parentText, so that every time parentText changes, ChildView changes its identity.

    ChildView(parentText: $parentText).id(parentText)
    

    Now, when you change parentText, you can see that the child text text field resets to an empty string. The two text fields also loses focus, because they are now two completely different text fields from before, and that's because the ChildView now is a completely different ChildView from before, and that's because ChildView's identity changed.