Exploring SwiftUI Explicit Identity
In this post, I’ll show a few examples of how specifying the explicit identity of a simple SwiftUI view affects the behavior of the view over time depending on its container view.
Background
View identity is an essential part to the deep story behind how the SwiftUI framework converts View
protocol-conforming structs written by us into pixels on the screen, and how that process works over the lifetime of our app. This post assumes you already have a basic understanding of SwiftUI (I recommend viewing the resources at the bottom of this post to build your mental model of SwiftUI).
SwiftUI uses the concept of view identity to track view lifetime, not only for efficient rendering but also to ensure the view-local state (like the kind marked by @State
) that it maintains for us is not discarded prematurely.
SwiftUI tracks view identity in two ways: structural identity and explicit identity. This post discusses explicit identity: associating a Hashable
value to a SwiftUI view through the .id(...)
modifier, or (more commonly) using an initializer of ForEach
, List
, or other enumerable primitive views with an Identifiable
associated value.
For example:
/// An `id` modifier will add explicit identity to the `Text` view returned by `body`.
struct MyView: View {
var body: some View {
Text("Hello world")
.id("text")
}
}
/// This initializer of `List` requires a collection where each element has a stable identity.
/// Each `Text` will be assigned an explicit identifier.
struct MyListView: View {
let items: [String] = ["a", "b"]
var body: some View {
List(items, id: \.self) { item in
Text(item)
// .id(item) <- applied automatically by `List`
}
}
}
For context, it’s overwhelmingly more common to see explicit identity expressed with enumerating views like ForEach
. In my experience, the .id
modifier is usually only seen used alongside ScrollViewReader
to force scroll to a certain view. Or, even more rarely, to force SwiftUI to reload a view, perhaps to trigger a .transition
animation. The trivia in this post is most useful in the realm of the latter.
Experiment setup
Let’s define a view structure we can reuse for each experiment.
struct User: Identifiable, Equatable {
let id: Int
let name: String
}
struct ViewData: Equatable {
var userTop = User(id: 0, name: "Abby")
var userMiddle = User(id: 1, name: "Barry")
var userBottom = User(id: 2, name: "Craig")
}
struct ContentView: View {
@State var viewData = ViewData()
@Namespace var ns // used later for `.matchedGeometryEffect`
var body: some View {
NavigationStack {
// ** Each experiment will replace `EmptyView` **
EmptyView()
.navigationTitle("Experiment Title")
.toolbar {
Button("Swap Abby & Craig") {
withAnimation {
let newBottom = viewData.userTop
viewData.userTop = viewData.userBottom
viewData.userBottom = newBottom
}
}
}
}
}
}
struct UserView: View {
let user: User
// Internal state that will help us understand SwiftUI's lifetime of this view
@State var internalFlag: Bool = false
var body: some View {
HStack {
Text(user.name)
Spacer()
Toggle("", isOn: $internalFlag)
}
}
}
The view for each experiment will usually look like this:
In order to determine what’s happening with SwiftUI’s internal representation of our views in the render tree, we’ll manually set the top and middle toggles (Abby and Barry) to “on”, even though their default value is “off”, like so:
Note: in the post I’ll be eliding styling modifiers in code blocks for brevity.
Experiments
Our goal is to observe the behavior of explicit identity in a several situations. We’ll see subtle differences in behavior based on changing the parent view that contains the views we’ve explicitly identified.
There are 3 cells that display the 3 users. Tapping the button swaps the top and bottom users (Abby and Craig).
Each cell has one piece of internal boolean @State
represented in the UI by a Toggle
control. In the experiments, we’ll manually toggle this state to true. If it resets to false
after tapping the button, we’ll have a clue that SwiftUI thinks the view’s identity changed and it recreated the underlying render tree view.
Note: there are other ways besides @State
to monitor a view’s lifetime: printing to the console inside onAppear
, onDisappear
, onTask
; adding a non-default .transition
; adding Self._printChanges
in the body
property.
All experiments were run on Xcode 15.0.1, iOS 17.0.1, and Swift 5.9.
1. VStack container with no explicit identity
Let’s start with the simplest setup.
VStack {
UserView(user: viewData.userTop)
UserView(user: viewData.userMiddle)
UserView(user: viewData.userBottom)
}
Initial | After 1st tap | After 2nd tap |
---|---|---|
Without explicit identity, SwiftUI is relying on structural identity to track the identity of each of the 3 UserView
s.
This example shows us that SwiftUI is not recreating the underlying render tree views. The names are changing but the toggles are staying the same. As far as SwiftUI is concerned, the UserView
dependency value user
is changing, but the UserView
maintains the same identity.
Abby’s toggle looks like it was inherited by Craig after the first tap. Conceptually, it’s true that you can consider the “name” part as jumping between non-moving cells in this setup.
For the record: LazyVStack
has the same behavior as VStack
when its content views have no explicit identity.
2. VStack container with explicit identity
Let’s add explicit identity via the .id
modifier to each cell.
VStack {
UserView(user: viewData.userTop)
.id(viewData.userTop.id)
UserView(user: viewData.userMiddle)
.id(viewData.userMiddle.id)
UserView(user: viewData.userBottom)
.id(viewData.userBottom.id)
}
Initial | After 1st tap | After 2nd tap |
---|---|---|
The UserView
id
values before and after switching places:
ID Before | ID After |
---|---|
0 | 2 |
1 | 1 |
2 | 0 |
The top and bottom UserView
’s id
values changed, therefore SwiftUI considers them new views, ends the lifetime of their render tree equivalents, and creates new render tree views in their places, including the underlying storage for UserView.internalFlag
.
Both the Text
and Toggle
of the top and bottom cells animate with the default .opacity
transition. Although it looks the same as experiment (1), this is a transition and not an in-place animation.
The middle view (“Barry”) has not changed. Its explicit identity and structural identity did not change.
You may have thought that the top and bottom cells would swap places without being recreated. If that were the case, Abby’s “on” toggle would follow her to the bottom row. Why doesn’t it?
This is behavior specific to VStack
(as opposed to LazyVStack
or ForEach
or List
, which we’ll see in a moment). This behavior is explained in the book Thinking in SwiftUI:
It’s important to note that an explicit identifier like the one above doesn’t override the view’s implicit identity, but is instead applied on top of it.
VStack
seems to enforce strong structural identity of our 3 views, and therefore even though it sees the same view type and the same id but in a different position, it doesn’t try to maintain our views’ identities across the reorder.
3. LazyVStack container with explicit identity
LazyVStack {
UserView(user: viewData.userTop)
.id(viewData.userTop.id)
UserView(user: viewData.userMiddle)
.id(viewData.userMiddle.id)
UserView(user: viewData.userBottom)
.id(viewData.userBottom.id)
}
Initial | After 1st tap | After 2nd tap |
---|---|---|
After the first button tap, LazyVStack
with explicit identity looks like it will have the same behavior as VStack
from experiment 2. However, on the second button tap, we see that Abby’s toggle value has been restored!
What’s going on here?
It seems like SwiftUI is still deriving an identity for each view by combining structural identity and explicit identity, but LazyVStack
isn’t discarding the internal state even if a view with that identity is removed from its jurisdiction.
Let’s think about the usual use case for LazyVStack
. LazyVStack
maintains a group of views within a ScrollView
.
Quoting from A Companion for SwiftUI (emphasis mine):
The child views of lazy stacks and grids are only created as they become visible. Once they have been created and made part of the view hierarchy, they will remain part of it, until they scroll out of view. However, their states will remain in memory.
The only surprising part then is that usually it’s ScrollView > LazyVStack > ForEach
cohort that are handling the adding/removing of views and maintaining explicit identifiers, but in this case we’re the ones doing the manipulating and we’re seeing similar behavior.
4. VStack container with matched geometry effect
VStack(spacing: 6) {
UserView(user: viewData.userTop)
.matchedGeometryEffect(id: viewData.userTop.id, in: ns)
.id(viewData.userTop.id)
UserView(user: viewData.userMiddle)
.matchedGeometryEffect(id: viewData.userMiddle.id, in: ns)
.id(viewData.userMiddle.id)
UserView(user: viewData.userBottom)
.matchedGeometryEffect(id: viewData.userBottom.id, in: ns)
.id(viewData.userBottom.id)
}
Initial | After 1st tap | After 2nd tap |
---|---|---|
.matchedGeometryEffect
is a quirky modifier. I was curious whether we could force VStack
to recognize the top and bottom views as having the same underlying identity.
The result is about half what we’d hope. The top and bottom views swap positions with an animation (good), but the internalFlag
is still reset as we saw in experiment 1.
For those curious, removing the explicit .id
from each cell has the same behavior as experiment 1.
5. ForEach
VStack(spacing: 6) {
ForEach([viewData.userTop, viewData.userMiddle, viewData.userBottom]) { item in
UserView(user: item)
}
}
Initial | After 1st tap | After 2nd tap |
---|---|---|
ForEach
is the first enumerating view we’re experimenting with that has first class support for explicit identity. The User
struct conforms to Identifiable
, and therefore ForEach
is using this initializer (note the Data.Element: Identifiable
):
extension ForEach where ID == Data.Element.ID, Content : View, Data.Element : Identifiable {
/// Creates an instance that uniquely identifies and creates views across
/// updates based on the identity of the underlying data.
///
/// It's important that the `id` of a data element doesn't change unless you
/// replace the data element with a new data element that has a new
/// identity. If the `id` of a data element changes, the content view
/// generated from that data element loses any current state and animations.
///
/// - Parameters:
/// - data: The identified data that the ``ForEach`` instance uses to
/// create views dynamically.
/// - content: The view builder that creates views dynamically.
public init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content)
}
The behavior looks good! SwiftUI is not only maintaining the internal state of each cell, but also providing a slick animation swapping the top and bottom cells.
We can perhaps say that ForEach
provides strong explicit identity and weak structural identity for its child views. Or maybe no structural identity at all.
6. List
List([viewData.userTop, viewData.userMiddle, viewData.userBottom]) { item in
UserView(user: item)
}
Initial | After 1st tap | After 2nd tap |
---|---|---|
List
is the more opinionated version of LazyVStack
+ForEach
, implemented under-the-hood as a UICollectionView
subclass.
The behavior is exactly the same as ForEach
in experiment 5. With the heartbreaking exception that there is no animation in the Xcode Preview (but there is on the full simulator).
Conclusion
Although you probably won’t encounter situations where you need to understand the subtleties of explicit identity we’ve uncovered in this post regularly, hopefully it has helped reinforce your mental model of how SwiftUI handles identity.
Any other interesting view identity behavior or trivia you’ve encountered? Let me know on Mastodon.
Recommended viewing/reading
- A Day in the Life of a SwiftUI View — Chris Eidhof
- Demystify SwiftUI - WWDC21
- Thinking in SwiftUI · objc.io
Full copy/pasteable code
import SwiftUI
struct User: Identifiable, Equatable {
let id: Int
let name: String
}
struct ViewData: Equatable {
var userTop = User(id: 0, name: "Abby")
var userMiddle = User(id: 1, name: "Barry")
var userBottom = User(id: 2, name: "Craig")
}
struct UserView: View {
let user: User
// Internal state that will help us understand SwiftUI's lifetime of this view
@State var internalFlag: Bool = false
var body: some View {
HStack {
Text(user.name)
Spacer()
Toggle("", isOn: $internalFlag)
}
// .transition(.slide.combined(with: .opacity))
}
}
#Preview("1. VStack") {
struct ContentView: View {
@State var viewData = ViewData()
var body: some View {
NavigationStack {
VStack(spacing: 6) {
UserView(user: viewData.userTop)
UserView(user: viewData.userMiddle)
UserView(user: viewData.userBottom)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationTitle("1. VStack")
.toolbar {
Button("Swap Abby & Craig") {
withAnimation {
let swap = viewData.userTop
viewData.userTop = viewData.userBottom
viewData.userBottom = swap
}
}
}
}
}
}
return ContentView()
}
#Preview("2. VStack w/ID") {
struct ContentView: View {
@State var viewData = ViewData()
var body: some View {
NavigationStack {
VStack(spacing: 6) {
UserView(user: viewData.userTop)
.id(viewData.userTop.id)
UserView(user: viewData.userMiddle)
.id(viewData.userMiddle.id)
UserView(user: viewData.userBottom)
.id(viewData.userBottom.id)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationTitle("2. VStack w/ID")
.toolbar {
Button("Swap Abby & Craig") {
withAnimation {
let swap = viewData.userTop
viewData.userTop = viewData.userBottom
viewData.userBottom = swap
}
}
}
}
}
}
return ContentView()
}
#Preview("3. LazyVStack w/ID") {
struct ContentView: View {
@State var viewData = ViewData()
var body: some View {
NavigationStack {
LazyVStack(spacing: 6) {
UserView(user: viewData.userTop)
.id(viewData.userTop.id)
UserView(user: viewData.userMiddle)
.id(viewData.userMiddle.id)
UserView(user: viewData.userBottom)
.id(viewData.userBottom.id)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationTitle("3. LazyVStack w/ID")
.toolbar {
Button("Swap Abby & Craig") {
withAnimation {
let swap = viewData.userTop
viewData.userTop = viewData.userBottom
viewData.userBottom = swap
}
}
}
}
}
}
return ContentView()
}
#Preview("4. VStack w/MGE") {
struct ContentView: View {
@State var viewData = ViewData()
@Namespace var ns
var body: some View {
NavigationStack {
VStack(spacing: 6) {
UserView(user: viewData.userTop)
.matchedGeometryEffect(id: viewData.userTop.id, in: ns)
.id(viewData.userTop.id)
UserView(user: viewData.userMiddle)
.matchedGeometryEffect(id: viewData.userMiddle.id, in: ns)
.id(viewData.userMiddle.id)
UserView(user: viewData.userBottom)
.matchedGeometryEffect(id: viewData.userBottom.id, in: ns)
.id(viewData.userBottom.id)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationTitle("4. VStack w/MGE")
.toolbar {
Button("Swap Abby & Craig") {
withAnimation {
let swap = viewData.userTop
viewData.userTop = viewData.userBottom
viewData.userBottom = swap
}
}
}
}
}
}
return ContentView()
}
#Preview("5. ForEach") {
struct ContentView: View {
@State var viewData = ViewData()
var body: some View {
NavigationStack {
VStack(spacing: 6) {
ForEach([viewData.userTop, viewData.userMiddle, viewData.userBottom]) { item in
UserView(user: item)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationTitle("5. ForEach")
.toolbar {
Button("Swap Abby & Craig") {
withAnimation {
let swap = viewData.userTop
viewData.userTop = viewData.userBottom
viewData.userBottom = swap
}
}
}
}
}
}
return ContentView()
}
#Preview("6. List") {
struct ContentView: View {
@State var viewData = ViewData()
var body: some View {
NavigationStack {
List([viewData.userTop, viewData.userMiddle, viewData.userBottom]) { item in
UserView(user: item)
}
.listStyle(.plain)
.navigationTitle("6. List")
.toolbar {
Button("Swap Abby & Craig") {
withAnimation {
let swap = viewData.userTop
viewData.userTop = viewData.userBottom
viewData.userBottom = swap
}
}
}
}
}
}
return ContentView()
}