Home

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:

  1. It is in the wrong place in the nest. .listRowSeparator must be on the row inside a List, .navigationTitle on the content inside a NavigationStack, .searchable where a navigation container can find it.
  2. Something below it overrode the environment. Your .font(.title) on the stack is being beaten by a .font(.body) closer to the Text.
  3. 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.