5.2 — Views, modifiers & the rendering model

Opening scenario

A junior on your team opens a PR. Their body is 400 lines, the screen flickers when the user scrolls, and Instruments shows body being called ~60 times per second on a list row. They ask: “Is SwiftUI just slow?”

No. SwiftUI is fast — the problem is that the developer doesn’t have a mental model of what body is, when it runs, how view identity is computed, or what AnyView does to the diffing algorithm. This chapter is that mental model. Without it, you write SwiftUI that compiles, runs, and silently destroys your scroll performance.

ConceptWhat it is
ViewA value-typed description of UI, not a UI element
bodyA computed property called by SwiftUI whenever inputs change
View identityHow SwiftUI decides “same view, update” vs “new view, replace”
ModifierA function returning a new view that wraps the receiver
some ViewOpaque type — single concrete type known at compile time
AnyViewType-erased wrapper — defeats compile-time view diffing

Concept → Why → How → Code

A view is not what you think

In UIKit, UILabel is a thing — a CALayer-backed object that occupies pixels. In SwiftUI, Text("hi") is a value that describes a label. The actual rendering object lives behind the scenes, owned by SwiftUI.

let view: Text = Text("hi")
print(MemoryLayout<Text>.size)        // small, stack-allocated
print(type(of: view.body))            // Text — body returns itself for leaf views

Views are structs. Cheap to create. Cheap to throw away. SwiftUI throws away and recreates view structs constantly — that’s not the work; the work is the diff against the previous structure.

body is a pure function (treat it that way)

struct Greeting: View {
    let name: String
    var body: some View {
        Text("Hello, \(name)")
    }
}

body should be a pure function of the view’s stored properties + observed state. SwiftUI calls it any time it suspects something changed; calling it must be cheap and side-effect-free.

What does “cheap” mean in practice? On the order of microseconds for typical views. If your body does network calls, mutates state outside of onAppear/task, accesses singletons that mutate, or allocates large objects, you’ll see:

  • Stale state showing in the UI
  • Crashes from publishing changes during view updates (“Publishing changes from within view updates is not allowed”)
  • Scroll jank
  • Recursive body invocations
// ❌ side effect in body
var body: some View {
    Task { await viewModel.refresh() }   // runs on every render
    return Text(viewModel.title)
}

// ✅ side effect in lifecycle modifier
var body: some View {
    Text(viewModel.title)
        .task { await viewModel.refresh() }   // runs once on appear
}

View identity — the most important concept in SwiftUI

SwiftUI tracks views by identity. When state changes, SwiftUI walks the new view tree and the old tree in parallel:

  • Same identity at the same position → it’s the same view; reuse the underlying rendering object, update properties
  • Different identity → tear down old, build new (loses state, runs onAppear)

Two kinds of identity:

  1. Structural identity — derived from the view’s type and position in the view graph. if condition { TextA() } else { TextB() } — these are different identities. Even if condition { Text("a") } else { Text("a") } are different.
  2. Explicit identity — via the .id(_:) modifier. Forces a new identity when the value changes.
struct Demo: View {
    @State private var flag = false
    @State private var resetCount = 0

    var body: some View {
        VStack {
            // Structural: same Text type at same position regardless of flag
            Text(flag ? "Off" : "On")

            // Different structural identity per branch:
            if flag {
                Text("A")    // identity #1
            } else {
                Text("B")    // identity #2 — different from #1
            }

            // Explicit identity changes whenever resetCount changes:
            CounterView()
                .id(resetCount)
        }
    }
}

Why this matters: any state (@State, @StateObject, scroll position, focus, animation in flight) is tied to identity. Change identity → state resets. Forget this and you’ll get bugs like “the form clears itself every time the parent re-renders”.

View modifiers — chaining and wrapping

Text("hi")
    .font(.title)
    .foregroundStyle(.blue)
    .padding()
    .background(.yellow)

Each modifier returns a new view that wraps the receiver. The chain is read left-to-right, applied outside-in. The order matters — padding().background() puts the background outside the padding; background().padding() puts padding around the background.

A modifier is just a method that returns some View:

extension View {
    func bordered() -> some View {
        self
            .padding(8)
            .background(RoundedRectangle(cornerRadius: 8).strokeBorder(.gray))
    }
}

Custom modifiers via ViewModifier:

struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
            .shadow(radius: 4, y: 2)
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardStyle()) }
}

Text("Hello").cardStyle()

Use a ViewModifier when the wrapping logic is non-trivial (multiple modifiers, state, environment access). Use an extension method for simple chains.

some View vs AnyView

// some View — opaque return type; one concrete type per function
var body: some View {
    Text("hi")     // type: Text
}

// some View with branches works if both branches share a common type via ViewBuilder
var body: some View {
    if condition {
        Text("a")
    } else {
        Text("b")    // _ConditionalContent<Text, Text> via @ViewBuilder
    }
}

// AnyView — type erasure; cost is real
var body: some View {
    if condition {
        AnyView(Text("a"))
    } else {
        AnyView(Image(systemName: "star"))
    }
}

some View keeps the static type. SwiftUI can compute structural identity and diff at compile time — fast path.

AnyView boxes the view into a heap allocation with a type tag. SwiftUI loses static visibility, falls back to dynamic diffing, often invalidates subtrees unnecessarily. Avoid AnyView unless you genuinely need heterogeneous arrays (and even then, prefer enums + @ViewBuilder or ForEach with a sum type).

The right fix for heterogeneous content:

enum Card { case text(String), image(Image) }

struct CardView: View {
    let card: Card
    var body: some View {
        switch card {
        case .text(let s): Text(s)
        case .image(let img): img
        }
    }
}

ForEach(cards) { CardView(card: $0) }   // no AnyView needed

@ViewBuilder — the magic behind the trailing closure

VStack { Text("a"); Text("b") } looks like a closure with two statements. It is — annotated with @ViewBuilder. The result builder collects each statement into a tuple view (TupleView), supports if/else/switch, and produces a single some View.

You can use it on your own functions:

@ViewBuilder
func header(showsSubtitle: Bool) -> some View {
    Text("Title").font(.title)
    if showsSubtitle {
        Text("Subtitle").font(.subheadline)
    }
}

Limit: max ~10 statements per builder before the compiler complains (Group { } to break up). Each statement becomes a Tuple slot.

EquatableView — manual diffing for performance

By default SwiftUI re-invokes body whenever any input it reads might have changed. For expensive views, you can opt in to custom equality:

struct PriceChart: View, Equatable {
    let ticks: [PricePoint]

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.ticks.count == rhs.ticks.count &&
        lhs.ticks.last?.value == rhs.ticks.last?.value
    }

    var body: some View {
        Canvas { /* expensive drawing */ }
    }
}

// Usage
PriceChart(ticks: ticks).equatable()

SwiftUI calls your ==; if true, it skips re-rendering. Use sparingly — bad equality functions cause stale UI.

The render pipeline in one diagram

┌─────────────────┐     mutation     ┌──────────────────┐
│  @State /       │ ───────────────▶ │  invalidate      │
│  @Observable    │                  │  reading views   │
└─────────────────┘                  └──────────┬───────┘
                                                │
                                                ▼
                                     ┌──────────────────┐
                                     │  call `body`     │
                                     │  on invalidated  │
                                     │  views           │
                                     └──────────┬───────┘
                                                │
                                                ▼
                                     ┌──────────────────┐
                                     │  diff new graph  │
                                     │  vs old graph    │
                                     │  by identity     │
                                     └──────────┬───────┘
                                                │
                                                ▼
                                     ┌──────────────────┐
                                     │  apply minimal   │
                                     │  changes to      │
                                     │  underlying      │
                                     │  UIView/NSView   │
                                     └──────────────────┘

That cycle happens at up to display refresh rate (60/120 Hz). Your job: keep body cheap, stable identity, no side effects.

In the wild

  • Apple’s Music app SwiftUI rewrite famously had performance issues at launch — root cause was excessive view invalidation and AnyView use deep in lists. Subsequent updates pushed state ownership down to leaf views.
  • Robinhood charts use Canvas (Phase 7) wrapped in EquatableView for tick streams at 30+ Hz updates.
  • Apple’s Stocks app uses Equatable on chart subviews; you can see in profiler that they avoid re-rendering the chart axes when only the price line changes.
  • Airbnb’s experiments with SwiftUI flagged AnyView and large @ViewBuilder blocks as the top two perf issues in their internal report.

Common misconceptions

  1. body is the view.” No. body returns a description of the view. SwiftUI owns the rendering objects.
  2. “Calling body 60 times per second is bad.” It’s bad only if body is expensive. SwiftUI is designed assuming body is microseconds-cheap.
  3. “Modifier order doesn’t matter, it’s all the same view.” Order matters significantly. .frame(width: 100).background(.red) paints a 100-wide red area; .background(.red).frame(width: 100) paints the background at intrinsic size, then constrains the layout. Different visual result.
  4. some View is just a fancy Any.” No. some View is a single, concrete, compile-time-known type. The compiler infers the exact type (e.g., ModifiedContent<Text, _PaddingLayout>). It’s the opposite of Any.
  5. “I should give every view an .id().” No — that forces identity changes and resets state. Only use .id() when you explicitly want a state reset (e.g., changing the user shown in a profile screen).

Seasoned engineer’s take

The SwiftUI performance bugs I’ve shipped (or caught in review) almost all fall into:

  1. AnyView in a loop — kills the type-based fast path
  2. State read at the wrong scope — putting @StateObject in the root view that owns the whole feature, so any leaf mutation re-runs the root body
  3. Heavy work in body — date formatters, image decoding, network calls
  4. Unstable identityForEach(items, id: \.self) on items that aren’t Hashable-stable, causing constant tear-down/rebuild
  5. @StateObject initialized with parent values — the @autoclosure runs only once, so the view holds stale data

When I review SwiftUI PRs I scan for those five patterns first, before reading any logic.

TIP: Drop let _ = Self._printChanges() in any body to log what triggered the re-render. The output names the property that changed. Removing this is one of the highest-leverage performance debug techniques you have.

WARNING: Don’t allocate inside body. DateFormatter(), JSONDecoder(), NumberFormatter() — instantiate once (static let, @State, or pass in) and reuse. Re-creating these per render is a measurable cost in lists.

Interview corner

Junior-level: “What’s the difference between padding().background() and background().padding()?”

The first applies padding to the view, then puts a background behind the padded view (background extends through the padded area). The second puts a background behind the view at its intrinsic size, then adds padding around the backgrounded view (background does not extend through the padding). Visual result: in the first, the background fills the padded area; in the second, the padding is outside the background.

Mid-level: “What is view identity in SwiftUI and why does it matter? Give an example bug caused by misuse.”

Identity is how SwiftUI decides “same view, update its properties” vs “new view, tear down and rebuild.” Identity comes from structural position + explicit .id(_:). State (@State, @StateObject, scroll position, focus, animation) is tied to identity — change identity, lose state.

Bug example: a profile screen with ProfileView(user: user).id(user.id) resets every time the user changes — correct. But if you accidentally do .id(UUID()) thinking it forces an update, you generate a new identity every render, so state never persists and onAppear runs forever.

Senior-level: “You have a 100-row LazyVStack with a complex chart in each row. Scrolling is janky. Walk through your debug + fix process.”

First, profile with Instruments (Time Profiler + SwiftUI template). Confirm body is the hot path and identify the row view.

Diagnose:

  1. Check for AnyView — replace with concrete type or @ViewBuilder + Group.
  2. Look for objects allocated in body (formatters, decoders) — hoist to static let or @State.
  3. Check for state read at the wrong scope — is the entire list re-rendering on each scroll position update?
  4. Add Self._printChanges() to the row body — what property is changing per scroll?
  5. If the chart legitimately needs to re-render only when its data changes, wrap it in Equatable conformance and .equatable().

Fixes (in priority order):

  • Make the chart a View, Equatable with == comparing only the data points.
  • Cache decoded data in the view model (pass plain values down, not full models).
  • Use .drawingGroup() on the chart to offload to Metal (acceptable trade — caches as bitmap).
  • If still bad, drop the chart into UIViewRepresentable and use a UIKit chart implementation.

Red flag in candidates: Reaching for AnyView as the default solution to “I have heterogeneous children” without considering enum + @ViewBuilder.

Lab preview

Lab 5.4 is where you’ll write your first reusable ViewModifiers and ButtonStyles — that lab exercises both modifier composition and some View discipline.


Next: State management