Composition over configuration
Small types, wrapped, instead of one type with fifty properties
UIKit’s UILabel has around forty configurable properties. SwiftUI’s Text has none — every
question you would answer with a property is answered by wrapping Text in something else.
Text("Hello")
.font(.headline)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
None of those four lines mutates the Text. Each returns a new value wrapping the previous one, and
what reaches the renderer is a nest four layers deep. That is the whole trade: a large API surface
on one type is replaced by a small API surface on many types that fit together.
Why the trade is worth taking
The obvious win is that behaviour composes without the framework anticipating your combination.
.padding() was not written with Text in mind; it works on anything, including views that did not
exist when it was written, including yours.
The subtler win is that there is no invalid state to configure your way into. A UILabel with
numberOfLines = 0 and a fixed height and adjustsFontSizeToFitWidth set is a real object in a
contradictory state, and the class has to decide what to do about it. A SwiftUI view has no
contradictory state because it has no state — it is a value, and the contradiction can only exist
as two wrappers disagreeing, which is resolved by the layout rules rather than by a property
precedence table nobody remembers.
Tip
The test for whether you have understood this: .font(.title) applied to a VStack works, and
it is not a special case. It writes into the environment, and every Text below reads it. A
modifier is not “a setter for the view on its left”.
@ViewBuilder, and what it actually builds
The closures you write in body are not ordinary closures. @ViewBuilder is a result builder that
turns a sequence of statements into a single nested value:
VStack {
Text("Title")
Text("Subtitle")
}
That does not produce an array of views. It produces VStack<TupleView<(Text, Text)>> — a type
encoding exactly two children of exactly those types, settled at compile time.
This is why the types get long, and why the compiler used to complain about ten children: every child is a generic parameter. It is also why views are cheap. There is no array to allocate, no heap traffic, no dynamic dispatch to reach the children.
An if in a builder becomes _ConditionalContent<TrueBranch, FalseBranch>, which matters more than
it looks — it is why the two branches are different views with different identities, a point the
next chapter is entirely about.
Extracting a subview costs nothing
Because a view is a value and its body is only read when needed, splitting a large body into
smaller views is free. This is the opposite of the UIKit instinct, where another view means another
object, another layer and another thing to lay out.
struct OrderRow: View {
let order: Order
var body: some View {
HStack {
OrderBadge(status: order.status)
VStack(alignment: .leading) {
Text(order.title)
Text(order.placedAt, format: .dateTime)
}
}
}
}
There is a real performance argument for doing this, not just a tidiness one: SwiftUI invalidates at
the granularity of a view. If everything is one enormous body, any change re-runs all of it. Split
into OrderBadge and the rest, a status change re-runs OrderBadge.body alone.
Warning
Prefer a struct conforming to View over a var someSection: some View property or a
@ViewBuilder function. Computed properties and functions are inlined into the parent’s body
and get no identity, no storage and no separate invalidation — so you keep the tidiness and
throw away the performance benefit.
Where it stops paying
Composition has a cost, and honesty about it saves arguing with the framework later.
Deeply wrapped views make type names enormous, which turns a compiler error thirty modifiers down into something unreadable. The fix is extracting subviews, which is the thing you should be doing anyway.
More seriously, not everything composes. Some modifiers must be outermost to work at all
(.searchable wants to find a navigation container above it), some only work on a direct child of a
specific parent (.listRowInsets outside a List is silently ignored), and a few are order
sensitive in ways no rule predicts. When a modifier does nothing, the first hypothesis should be
that it is in the wrong place in the nest, not that it is broken.