How SwiftUI Lays Out Views

Overview

If you’ve built websites with CSS, you’re familiar with the box model — the idea that every element is a rectangular box with content, padding, border, and margin. SwiftUI has its own layout system that achieves similar results with a fundamentally different approach. Understanding how SwiftUI thinks about layout will make your UI work much more predictable and much less frustrating.

The Layout Negotiation Loop

Every time SwiftUI draws your interface, it runs a three-step conversation between each parent view and its children:

  1. Offer: The parent offers a proposed size to the child — essentially saying “you have this much space available.”
  2. Decide: The child looks at the proposal and decides how much space it actually wants to take.
  3. Report: The child tells the parent its actual size. The parent then uses that size to position the child.

This is the key insight: in SwiftUI, views size themselves. The parent can only suggest; the child decides. This is very different from CSS, where you typically set dimensions on elements directly.

A practical consequence: Text always takes only as much space as the text it contains. A Rectangle with no size constraints will expand to fill all available space. Understanding which views “hug their content” vs. which ones “fill available space” is the foundation of SwiftUI layout.

Layout Containers: HStack, VStack, ZStack

SwiftUI’s three stacks are the primary layout containers, and each maps roughly to a CSS layout concept:

  • VStack — arranges children vertically. Analogous to display: flex; flex-direction: column in CSS.
  • HStack — arranges children horizontally. Analogous to display: flex; flex-direction: row.
  • ZStack — layers children on top of each other. Analogous to position: absolute stacking in CSS.

All three stacks accept an optional spacing: parameter that controls the gap between children — similar to CSS gap in flexbox.

VStack(spacing: 16) {
    Text("Top")
    Text("Middle")
    Text("Bottom")
}

Padding: Space Around Content

In CSS, padding is a property that adds space between an element’s content and its border. In SwiftUI, .padding() is a modifier that works the same way — it adds space around a view’s content on the outside.

Text("Hello")
    .padding()              // default padding on all sides (~16pts)
    .padding(.horizontal, 20)  // 20pts left and right only
    .padding(.bottom, 8)       // 8pts at the bottom only

You can stack multiple padding modifiers on the same view. Each one wraps around the previous result, adding more space layer by layer.

Frame: Setting Explicit Dimensions

The .frame() modifier is the closest SwiftUI equivalent to setting width and height in CSS. It constrains (or expands) a view to specific dimensions.

Rectangle()
    .frame(width: 100, height: 50)     // fixed size

Text("Stretch me")
    .frame(maxWidth: .infinity)        // fill all available width

Text("Tall area")
    .frame(height: 200, alignment: .top) // fixed height, content aligned top

The maxWidth: .infinity pattern is especially useful — it’s the SwiftUI equivalent of width: 100% or flex: 1 in CSS. It tells the view to claim as much horizontal space as the parent offers.

Spacer: Flexible Empty Space

Spacer() is a view that expands to fill all available space along the stack’s axis. It’s the SwiftUI equivalent of a flex: 1 empty div in CSS flexbox. Use it to push other views to the edges of a container:

HStack {
    Text("Left side")
    Spacer()
    Text("Right side")
}

In this example, Spacer() pushes “Left side” to the left edge and “Right side” to the right edge — just as you’d achieve with justify-content: space-between in CSS flexbox.

Alignment

Stacks accept an alignment: parameter to control how children line up perpendicular to the stack’s axis:

VStack(alignment: .leading) { ... }    // left-align children (like text-align: left)
HStack(alignment: .bottom) { ... }    // bottom-align children

The .frame() modifier also accepts an alignment: parameter, which controls where the view’s content sits within the frame — similar to CSS align-items and justify-content.

The Modifier Order Rule

In CSS, properties like background and border are attributes of a box — they don’t depend on order. In SwiftUI, modifier order matters. Each modifier wraps the previous view in a new layer, and that layering affects the result.

Compare these two examples:

// Example A: padding INSIDE the background
Text("Hello")
    .padding()
    .background(Color.blue)   // background fills the padded area

// Example B: padding OUTSIDE the background
Text("Hello")
    .background(Color.blue)   // background hugs the text only
    .padding()                // space is added outside the blue area

In Example A, the blue background fills the padded area. In Example B, the blue background hugs the text, and the padding is transparent space around the blue box. This is the direct analog of CSS padding (inside the background) vs. margin (outside the background) — but controlled entirely by modifier order.

Putting It Together: A SwiftUI Card

Here’s how the CSS box model maps to SwiftUI in a real example:

Text("Course: NMIX 4030")
    .font(.headline)
    .padding(16)                    // like CSS padding
    .frame(maxWidth: .infinity,     // like CSS width: 100%
           alignment: .leading)
    .background(Color.white)        // the "box" background
    .cornerRadius(8)                // like CSS border-radius
    .shadow(radius: 4)              // like CSS box-shadow
    .padding(.horizontal, 20)       // like CSS margin (outside the card)

Key Takeaways

  • SwiftUI uses a parent-offers / child-decides layout model rather than direct dimension setting.
  • Text and similar views hug their content; Rectangle, Color, and Spacer fill available space.
  • VStack, HStack, and ZStack are the primary layout containers — roughly equivalent to CSS flexbox column, row, and absolute stacking.
  • .padding() adds space inside a background; .padding() applied after a background acts like CSS margin.
  • .frame(maxWidth: .infinity) is the SwiftUI equivalent of width: 100% or flex: 1.
  • Modifier order determines visual output — the same modifiers in a different order produce a different result.

Your Tasks

Work through these three exercises in a single Xcode project to build intuition for SwiftUI’s layout system.

Task 1: Content Hugging vs. Space Filling

  1. Create a new SwiftUI view. Add a Text view and a Rectangle one above the other in a VStack.
  2. Run the preview. Notice that the Rectangle fills all remaining space.
  3. Add .frame(height: 100) to the Rectangle. Now it takes a fixed size.
  4. Now add .frame(maxWidth: .infinity) to the Text. Give it a background color. Notice it now stretches to fill the full width of the screen.

Task 2: Recreate the Box Model

Using only SwiftUI modifiers, recreate the CSS box model:

  1. Start with a Text view containing a short label.
  2. Add .padding(16) to create inner spacing.
  3. Add .background(Color.white) to create the “box”.
  4. Add .cornerRadius(8) and .shadow(radius: 4) to polish it.
  5. Finally, add another .padding(.horizontal, 20) after the shadow to create outer margin.
  6. Experiment: swap the order of .padding() and .background() and observe what changes.

Task 3: Navigation Bar Layout

Build a simple top navigation bar using what you’ve learned:

  1. Create an HStack containing a title Text on the left and an SF Symbol button on the right.
  2. Use Spacer() to push the title left and the button right.
  3. Give the entire HStack a background color and padding.
  4. Wrap the whole thing in a VStack and push it to the top of the screen using Spacer() at the bottom.