Death by a Thousand Extensions
How Tiny Helpers Break SwiftUI Layouts
View extensions are brilliant for avoiding duplication, keeping code declarative, and establishing your "house style". However, because SwiftUI's layout engine makes multiple measurement passes, tiny abstractions can obfuscate seemingly small adjustments which will ultimately change how your views look.
Layout adjustments
Imagine a standard extension for a view that sets the full frame, used like this:
struct ContentView: View {
var body: some View {
VStack(spacing: 32) {
Image("content-header")
.resizable()
.scaledToFit()
.fullWidth()
Text("Dashboard")
.fullWidth()
VStack(alignment: .center) {
…
}
}
}
}
One would probably guess the function looks like this:
extension View {
func fullWidth() -> some View {
frame(maxWidth: .infinity)
}
}
Notice it pins max width but says nothing about minimum width, ideal width, or alignment.
But can every reader know that at a glance? Does every junior developer coming into the code base know the default alignment for this? Or how different views will behave differently when provided a maximum width but no minimum? What about idealWidth
?
In macOS 15, the behaviour of maxWidth: .infinity
changed in a HStack
. The change tightened HStack's horizontal hugging so infinite-width children now grab extra space.
Unless you know how you're instructing SwiftUI's layout engine, you won't grasp why your views behave as they do, and when you're masking the instructions, you're making the task far harder for yourself.
The conditional-modifier trap
Let's look at a common method for avoiding extensive indenting with a handy if
extension:
extension View {
@ViewBuilder
func `if`<T: View>(_ condition: Bool,
_ transform: (Self) -> T) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
The fun thing about such an extension is that we get to keep the functional pipe aesthetic in our code:
struct ContentView: View {
var content: [String]
var body: some View {
VStack(spacing: 32) {
Image("content-header")
.resizable()
.scaledToFit()
Text("Dashboard")
VStack(alignment: .center) {
…
}
.if(content.isEmpty) {
Text("You've got nothing!")
}
}
}
}
But what happens with animations? This code makes it look like any changes to the VStack should animate smoothly if I add or remove the items, but it won't. We're hiding the fact that there are two views with different identities. Instead of .animation(.easeInOut, value: content.isEmpty)
interpolating between them, they'll switch out.
Also, imagine that we place this innocuous if
on a view with state, like a ScrollView
:
ScrollView {
VStack(alignment: .center) {
…
}
}
.if(content.isEmpty) {
Text("You've got nothing!")
}
The state is going to be reset - your current scroll offset, any FocusState
bindings, and even in-flight gestures vanish when the branch flips.
Additionally, it's always worth considering how this might read in a code review. Will it be obvious what is happening here?
This extension isn't wholly without merit, however, try something more narrow and targeted:
struct Avatar: View {
let highlighted: Bool
var body: some View {
Circle()
.fill(highlighted ? .pink : .gray)
.overlayIf(highlighted) {
Circle().stroke(.white, lineWidth: 4)
}
}
}
extension View {
@ViewBuilder
func overlayIf<Overlay: View>(
_ condition: Bool,
alignment: Alignment = .center,
@ViewBuilder _ overlay: () -> Overlay
) -> some View {
if condition {
overlay(overlay(), alignment: alignment)
} else {
self
}
}
}
Extensions are brilliant for styling, because they don't impact the layout.
Also, anything that shims a platform check can be very useful. Imagine a button style extension that wraps different implementations on iOS or macOS using #if os(…)
.
Anything compositional is also fine because it won't affect the geometry. glassBackground()
would be benign.
If you insist on using extensions for layout, follow some rules:
- Telegraph axis & intent (
fillHorizontal(alignment:)
vs.fullFrame()
) - Stay a single layer deep. If you're branching with the helper, extract the view.
- Prefer impermanent helpers - delete them when SwiftUI gives you a native modifier.
Ultimately, my advice is to keep helpers transparent, predictable, and domain‑specific. When in doubt, let the raw .frame(...)
do the talking.
Next time you're tempted to write .fullWidth()
, paste the raw .frame first, commit, then see if the helper is still worth it.