Modifiers are wrappers
Why order matters, and how to work it out rather than memorise it
.padding() does not add padding to a view. It returns a new view that contains the old one and
reports itself as larger by the padding amount. Every modifier works this way, and once that is in
your head, modifier order stops being trivia.
Text("Hello").padding().background(.blue) // blue covers the padding
Text("Hello").background(.blue).padding() // blue stops at the text
Read the chain right to left to see the nesting. In the first line, background wraps
padding(Text), so it paints behind something text-plus-padding sized. In the second, padding
wraps background(Text), so the blue was already sized to the text before the padding was added
outside it.
There is no rule to memorise here. Draw the boxes and the answer falls out.
The three families
Modifiers do not all do the same kind of thing, and knowing which family you are in predicts whether order matters.
Wrapping modifiers produce a new view around the old one: padding, frame, background,
overlay, border, clipShape. These are order sensitive, always, because each one changes what
the next one is measuring.
Circle().frame(width: 100).padding(20) // 140pt total, circle is 100
Circle().padding(20).frame(width: 100) // 100pt total, circle is 60
Environment modifiers write a value into the environment for everything below:
font, foregroundStyle, environment, tint, lineLimit. These are order insensitive with
respect to each other, and behave like inherited CSS — the nearest one to the reading view wins.
VStack {
Text("Inherits")
Text("Overrides").font(.caption)
}
.font(.title)
Behavioural modifiers attach something to a view without changing its geometry: onTapGesture,
task, onChange, accessibilityLabel, disabled. Order between these rarely matters, but order
relative to a wrapping modifier often does — which is the source of the most-reported SwiftUI
“bug” there is:
Text("Tap me").onTapGesture { … }.padding() // padding is NOT tappable
Text("Tap me").padding().onTapGesture { … } // padding IS tappable
The gesture attaches to the view it is applied to. Applied before the padding, the padding is added outside the tappable region.
Warning
The same trap with a nastier failure: a tap target smaller than 44pt. Attaching a gesture
directly to a small Image gives you a hit area the size of the icon. Pad first, then attach
the gesture — or add .contentShape(Rectangle()) to declare the whole padded area hittable.
.frame() is the one people mean differently
frame is not “set the size”. It inserts a view that proposes a size to its child and then
positions the child’s chosen size within that space.
Text("A very long sentence that will not fit")
.frame(width: 100)
The Text is offered 100 points, wraps to fit, and the frame ends up 100 wide and however tall the
wrapped text needed. frame did not truncate anything — it proposed, and Text replied.
This is why .frame(maxWidth: .infinity) is how you make something fill available width: it changes
the proposal to “as much as you have”, and views that accept the proposal grow. It does nothing to a
Text with three words in it, because Text still chooses the width it needs. Add alignment: to
say where the smaller child sits in the larger frame.
Writing your own
Anything you write more than twice belongs in a ViewModifier:
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding(16)
.background(.regularMaterial, in: .rect(cornerRadius: 12))
}
}
extension View {
func cardStyle() -> some View { modifier(CardStyle()) }
}
The extension is the part that matters. modifier(CardStyle()) at every call site reads badly and
leaks the type; .cardStyle() reads like the built-ins and lets you change the implementation
later.
Tip
Return some View from these, never a concrete type. The concrete type of a modifier chain is
an implementation detail three layers deep, and writing it down freezes it into your API.
When a modifier appears to do nothing
Three causes, in the order worth checking:
- It is in the wrong place in the nest.
.listRowSeparatormust be on the row inside aList,.navigationTitleon the content inside aNavigationStack,.searchablewhere a navigation container can find it. - Something below it overrode the environment. Your
.font(.title)on the stack is being beaten by a.font(.body)closer to theText. - The view is not the size you think. Add
.border(.red)temporarily. Most “my modifier is broken” reports are a view that is already the wrong size for a reason that has nothing to do with the modifier.