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.
| Concept | What it is |
|---|---|
View | A value-typed description of UI, not a UI element |
body | A computed property called by SwiftUI whenever inputs change |
| View identity | How SwiftUI decides “same view, update” vs “new view, replace” |
| Modifier | A function returning a new view that wraps the receiver |
some View | Opaque type — single concrete type known at compile time |
AnyView | Type-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
bodyinvocations
// ❌ 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:
- Structural identity — derived from the view’s type and position in the view graph.
if condition { TextA() } else { TextB() }— these are different identities. Evenif condition { Text("a") } else { Text("a") }are different. - 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
AnyViewuse deep in lists. Subsequent updates pushed state ownership down to leaf views. - Robinhood charts use
Canvas(Phase 7) wrapped inEquatableViewfor tick streams at 30+ Hz updates. - Apple’s Stocks app uses
Equatableon 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
AnyViewand large@ViewBuilderblocks as the top two perf issues in their internal report.
Common misconceptions
- “
bodyis the view.” No.bodyreturns a description of the view. SwiftUI owns the rendering objects. - “Calling
body60 times per second is bad.” It’s bad only ifbodyis expensive. SwiftUI is designed assumingbodyis microseconds-cheap. - “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. - “
some Viewis just a fancyAny.” No.some Viewis a single, concrete, compile-time-known type. The compiler infers the exact type (e.g.,ModifiedContent<Text, _PaddingLayout>). It’s the opposite ofAny. - “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:
AnyViewin a loop — kills the type-based fast path- State read at the wrong scope — putting
@StateObjectin the root view that owns the whole feature, so any leaf mutation re-runs the rootbody - Heavy work in
body— date formatters, image decoding, network calls - Unstable identity —
ForEach(items, id: \.self)on items that aren’tHashable-stable, causing constant tear-down/rebuild @StateObjectinitialized with parent values — the@autoclosureruns 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 anybodyto 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:
- Check for
AnyView— replace with concrete type or@ViewBuilder+Group. - Look for objects allocated in
body(formatters, decoders) — hoist tostatic letor@State. - Check for state read at the wrong scope — is the entire list re-rendering on each scroll position update?
- Add
Self._printChanges()to the row body — what property is changing per scroll? - If the chart legitimately needs to re-render only when its data changes, wrap it in
Equatableconformance and.equatable().
Fixes (in priority order):
- Make the chart a
View, Equatablewith==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
UIViewRepresentableand 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