Lifetime of State Properties in SwiftUI
Surprisingly Subtle
This post is a look inside how (a small part of) SwiftUI works. I’m mainly writing this as part of my extended memory, so that I can go back to it and read about how it works. We’re currently in the process of updating our book Thinking in SwiftUI and figuring out some of the obscure behaviors of SwiftUI. While this might not make it into the book (we keep the book concise on purpose) I figured it’s worth writing up.
One of the challenging parts of SwiftUI is really understanding the way it manages view state (for example, through @State
and @StateObject
). In theory, it’s pretty simple: anytime you want associated view state you just create a property with @State
and you’re done. For example, here’s a simple view with associated state:
struct Item: View {
var id: Int
@State private var counter = 0
var body: some View {
VStack {
Text("Item \(id)")
Button("Counter: \(counter)") {
counter += 1
}
}
}
}
When SwiftUI renders this view, it creates associated storage to hold the value of counter
. As long as the view exists, the memory for counter
is there, and once the view stops existing, the memory is gone.
However, when you have worked with @State
(or @StateObject
), you will notice that there might be some strange behavior. Sometimes your state disappears, especially when working with a List
(or to be precise: any view that uses ForEach
directly or indirectly).
To understand this better, we have to ask ourselves an existential question:
What does it mean for a view to exist?
Joking aside, here’s what I think happens:
When a List
is rendered on screen, it only allocates memory for the children that are directly on screen (on iOS, anyway). We can quickly verify this by initializing a @State
property with a value expression that prints a line:
struct StateItemTest: View {
@State var item: Int = {
print("Initing")
return 0
}()
let body = Text("Hello")
}
// Later:
List(0..<1000) { id in
StateItemTest()
}
When we show the list view, we’ll see a print statement for every row that’s on screen. But these are created lazily: as we scroll down more and more print statements appear. So we have now established at least one thing:
List creates its subviews lazily
. By the way, this is the same for ForEach
in other lazy contexts. For example, when you put a ForEach
inside a LazyVStack
and that inside a ScrollView
, you get the same effect.
My next question was: when do these state values get destroyed again?
Is this when we scroll the views out of sight? We can verify this by having a long list of views with modifiable state properties. For example, we can put our Item
view from above inside a long list:
List(0..<1000) { id in
Item(id: id)
}
When we run the above list, we can change some state at the top of the list, scroll to the very bottom, and scroll back up. The state will still be there. So we have established another thing:
The children of a List
will be kept around
. The lifetime of a @State
‘s property is directly tied to the lifetime of a view. Once a list child is created, it never goes away again, unless the list goes away itself. (You can verify this by putting the list inside a navigation link and navigating back). Again, this doesn’t just apply to List
but any lazy ForEach
.
However, this behavior puzzled me. I’ve written many SwiftUI lists, and I swear that I have seen state objects go away. What gives? In my case, I’ve seen this behavior when loading a list that contains images for each cell, and the images would get reloaded after scrolling back up.
It turns out that while the children of a List
will be kept around (including their associated state), the bodies of those views will get destroyed. These will get recreated lazily once the view appears on screen again. If we run the same example as before, but wrap our Item
view in another layer, we’ll see that our state goes away as the view disappears:
struct ItemWrapper: View {
var id: Int
var body: some View {
ZStack {
Item(id: id)
}
}
}
This is the behavior in the version of SwiftUI that comes with Xcode 13. The full code is available as a gist. The behavior might change in the future, as none of this is documented.