iosNudge

What is view identity in SwiftUI, and why does it matter?

HardAsked oftenSwiftUIPerformance 2 min read

TL;DR

SwiftUI views are structs that are created again and again. Identity tells SwiftUI "this is still the same view". There are two kinds: structural identity (position in the view tree) and explicit identity (id). Same identity: state is kept and changes animate smoothly. New identity: old state is destroyed and the view starts fresh.

Structural identity

SwiftUI knows a view by its type and its place in body.

var body: some View {
    if isLoggedIn {
        HomeView()   // identity: "first branch of the if"
    } else {
        LoginView()  // identity: "second branch of the if"
    }
}

The two branches are different views. Switching between them removes one and inserts the other, with all their @State.

The common bug: branching on a modifier

// Bad: two different views, the TextField loses focus and state
if isError {
    TextField("Email", text: $email).border(.red)
} else {
    TextField("Email", text: $email)
}

// Good: one view, only the modifier value changes
TextField("Email", text: $email)
    .border(isError ? .red : .clear)

Prefer one view with changing values over if/else that creates different views. The same applies to a custom .if(condition) modifier. It looks nice but it changes identity.

Explicit identity

ForEach and List

ForEach(messages) { message in   // Message: Identifiable
    MessageRow(message: message)
}

SwiftUI uses message.id to match rows between updates. It can then animate inserts and moves.

// Risky: identity is the index
ForEach(messages.indices, id: \.self) { i in
    MessageRow(message: messages[i])
}

If you insert at the top, every index shifts. Rows get the wrong state and animations break. IDs must be stable and unique. A new UUID() on each fetch is also bad.

The .id(_:) modifier

Changing .id destroys the view and creates a new one.

ProfileView(userID: userID)
    .id(userID) // reset all @State when the user changes

Useful to reset a form or restart .task. Expensive if used carelessly, for example .id(UUID()) rebuilds the view on every update.

Identity, lifetime and state

The lifetime of @State and .task follows identity, not the struct.

struct Stopwatch: View {
    @State private var seconds = 0 // lives as long as this identity
    var body: some View { Text("\(seconds)") }
}

The Stopwatch struct is created many times. seconds survives because the identity stays the same.

Why AnyView hurts

AnyView hides the type. SwiftUI cannot compare structures easily, so it may rebuild more. Use @ViewBuilder or some View instead.

@ViewBuilder
func badge(for status: Status) -> some View {
    switch status {
    case .new: Text("New")
    case .done: Image(systemName: "checkmark")
    }
}

Transitions need identity changes

.transition(...) only runs when a view is inserted or removed. That means an identity change. A changed modifier value animates instead.

Common follow-up questions

  • Why does my TextField lose focus when I type? Something gives it a new identity each update, often .id(UUID()) or an if around it.
  • Does ForEach require Identifiable? No. You can pass id: \.someKeyPath. It must still be stable and unique.
  • What does onAppear do on an identity change? The old view gets onDisappear and the new one gets onAppear.
  • How do you reset @State from the parent? Change the child's .id.