All articles
iOS7 min read

How to Actually Understand SwiftUI Layout (Not Just Copy-Paste)

Learn how VStack, HStack, alignment, and spacing work together to build layouts confidently

K
Karan Pal
Author
Image Generated by AI
Image Generated by AI

📦 The Three Stacks: VStack, HStack, ZStack

SwiftUI layout boils down to three containers that do exactly what their names suggest. But the devil’s in the details.

VStack = vertical stack. Items stack top to bottom:

VStack {
    Text("First")
    Text("Second")
    Text("Third")
}

Three text views, one above the other. Simple.

HStack = horizontal stack. Items go left to right:

HStack {
    Text("Left")
    Text("Middle")
    Text("Right")
}

Three text views in a row.

ZStack = depth stack. Items layer on top of each other:

ZStack {
    Circle()
        .fill(.blue)
        .frame(width: 100, height: 100)

    Text("On Top")
        .foregroundColor(.white)
}

Circle in back, text in front. Order matters — first item goes furthest back, last item goes on top.

Here’s where beginners get confused: stacks don’t automatically fill available space. They size themselves to fit their content:

VStack {
    Text("Small")
}
.background(.red)

The red background only wraps the text, not the whole screen. If you want full width:

VStack {
    Text("Full Width")
}
.frame(maxWidth: .infinity)
.background(.red)

Now the VStack expands horizontally.

You can nest stacks infinitely:

VStack {
    HStack {
        Text("Top Left")
        Spacer()
        Text("Top Right")
    }

    Spacer()

    HStack {
        Text("Bottom Left")
        Spacer()
        Text("Bottom Right")
    }
}
.padding()

VStack contains two HStacks with Spacers between and inside them. This creates a four-corner layout.

Default spacing between stack items is 8pt (on iOS). You can override:

VStack(spacing: 20) {
    Text("More")
    Text("Space")
}

VStack(spacing: 0) {
    Text("No")
    Text("Space")
}

Or remove spacing entirely and control it manually with padding on individual items.

Quick gotcha: empty stacks have zero size. This fails silently:

VStack {
    // nothing here
}
.background(.red)

You won’t see anything. No error, no crash, just nothing rendered.

Real example — a profile header:

HStack(spacing: 12) {
    Image(systemName: "person.circle.fill")
        .font(.system(size: 60))
        .foregroundColor(.blue)

    VStack(alignment: .leading, spacing: 4) {
        Text("Sarah Kim")
            .font(.headline)
        Text("Product Designer")
            .font(.subheadline)
            .foregroundColor(.secondary)
        Text("San Francisco, CA")
            .font(.caption)
            .foregroundColor(.secondary)
    }

    Spacer()
}
.padding()

HStack for horizontal layout, nested VStack for vertical text info, Spacer pushes everything left. That’s the pattern.

📍 Alignment That Actually Makes Sense

Every stack has an alignment parameter that controls how items line up perpendicular to the stack direction.

For VStack, alignment is horizontal (.leading, .center, .trailing):

VStack(alignment: .leading) {
    Text("Short")
    Text("Much longer text here")
}

Both texts align to their leading edge (left in LTR languages). Default is .center.

For HStack, alignment is vertical (.top, .center, .bottom):

HStack(alignment: .top) {
    Image(systemName: "star.fill")
    VStack(alignment: .leading) {
        Text("Title")
        Text("Subtitle")
    }
}

The star icon and text stack align to the top. Without it, they’d center vertically which might look weird.

ZStack has both dimensions (.topLeading, .center, .bottomTrailing, etc.):

ZStack(alignment: .topTrailing) {
    Rectangle()
        .fill(.blue)
        .frame(width: 200, height: 200)

    Text("Badge")
        .padding(8)
        .background(.red)
        .clipShape(Circle())
}

The badge appears in the top-right corner.

Here’s the confusing part — alignment only affects items that don’t specify their own size. If you force a frame, alignment might not do what you expect:

VStack(alignment: .leading) {
    Text("Aligned")
    Text("Also aligned")
    Text("Not aligned")
        .frame(width: 300) // this overrides alignment
}

The third text has a fixed width so VStack can’t control its alignment.

Custom alignment with .alignmentGuide() gets complex fast, but there's one useful case - aligning baselines:

HStack(alignment: .firstTextBaseline) {
    Text("Small")
        .font(.caption)
    Text("LARGE")
        .font(.largeTitle)
}

Text baselines align instead of centers, so it looks typographically correct.

You can also use .lastTextBaseline if you have multi-line text.

Practical example — a settings row:

HStack(alignment: .center) {
    Image(systemName: "bell.fill")
        .foregroundColor(.red)
        .font(.title2)

    VStack(alignment: .leading, spacing: 2) {
        Text("Notifications")
            .font(.body)
        Text("Get alerts for new messages")
            .font(.caption)
            .foregroundColor(.secondary)
    }

    Spacer()

    Toggle("", isOn: $notificationsEnabled)
}
.padding()

HStack centers everything vertically. VStack inside aligns text to leading edge. Toggle stays on the right thanks to Spacer.

↔️ Spacing vs Padding (They’re Different)

This trips up everyone at first. Spacing is between items. Padding is around items.

Spacing — gap between children in a stack:

VStack(spacing: 16) {
    Text("Item 1")
    Text("Item 2")
}

16pt gap between the two texts.

Padding — space around a view:

Text("Padded")
    .padding()

Default is 16pt on all sides (iOS). You can specify:

Text("Custom")
    .padding(.horizontal, 20)
    .padding(.vertical, 8)

Or individual edges:

Text("Specific")
    .padding(.top, 20)
    .padding(.leading, 12)

Order matters with padding and background:

// padding THEN background
Text("Hello")
    .padding()
    .background(.blue)
// blue background includes padding

// background THEN padding
Text("Hello")
    .background(.blue)
    .padding()
// blue background tight to text, padding outside

First example: padding pushes the background away from text. Second: background hugs text, padding adds transparent space outside.

This is crucial for buttons:

Button("Tap Me") {
    // action
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(.blue)
.foregroundColor(.white)
.cornerRadius(8)

Padding first, then background. Background fills the padded area.

You can also use negative padding (carefully):

Text("Overlap")
    .padding(-8)

Moves the view outward, potentially overlapping neighbors. Useful for fine-tuning but easy to misuse.

Spacing can be overridden per-item:

VStack(spacing: 8) {
    Text("Normal spacing")
    Text("Extra spacing below")
        .padding(.bottom, 20)
    Text("This one's further")
}

The padding on the second text adds to the stack spacing.

Real example — a card layout:

VStack(alignment: .leading, spacing: 12) {
    Text("Article Title")
        .font(.headline)

    Text("This is a preview of the article content that goes here...")
        .font(.subheadline)
        .foregroundColor(.secondary)
        .lineLimit(3)

    HStack {
        Text("5 min read")
            .font(.caption)
            .foregroundColor(.secondary)
        Spacer()
        Button("Read") {
            // action
        }
        .font(.caption)
    }
}
.padding() // space inside card
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.padding(.horizontal) // space around card

Inner padding keeps content away from card edges. Outer padding keeps card away from screen edges. Spacing in VStack keeps sections separated.

📐 Frames: When to Use Them (and When Not To)

Frames control view size but they’re not always the answer.

Basic frame:

Text("Fixed Size")
    .frame(width: 200, height: 100)

200pt wide, 100pt tall. Text centers inside by default.

But usually you want flexibility:

Text("Flexible")
    .frame(minWidth: 100, maxWidth: .infinity)

At least 100pt wide, expands to fill available space. This is how you make things full-width:

Button("Full Width Button") {
    // action
}
.frame(maxWidth: .infinity)
.padding()
.background(.blue)
.foregroundColor(.white)
.cornerRadius(8)

You can set minimums and maximums independently:

Text("Constrained")
    .frame(minWidth: 100, maxWidth: 300, minHeight: 50, maxHeight: 200)

Won’t shrink below 100x50, won’t grow beyond 300x200.

Alignment within frames:

Text("Top Left")
    .frame(width: 200, height: 100, alignment: .topLeading)
    .background(.gray)

Text appears in top-left corner of the gray box instead of centered.

Here’s when NOT to use frames — don’t use them for spacing:

// bad
Text("Spaced")
    .frame(height: 50)

// good
Text("Spaced")
    .padding(.vertical, 8)

Frames add empty space that can mess with layout. Padding is explicit about spacing.

Fixed vs flexible — know the difference:

// fixed - always 200pt wide
.frame(width: 200)

// flexible - up to 200pt wide
.frame(maxWidth: 200)

// full width
.frame(maxWidth: .infinity)

Common pattern — cards that expand horizontally but have fixed vertical height:

HStack {
    Image(systemName: "photo")
        .frame(width: 60, height: 60)
        .background(.gray)

    VStack(alignment: .leading) {
        Text("Title")
        Text("Subtitle")
    }
    .frame(maxWidth: .infinity, alignment: .leading)

    Image(systemName: "chevron.right")
}
.frame(height: 80)
.padding(.horizontal)

Image fixed at 60x60, text stack expands to fill, whole row fixed at 80pt tall.

🎯 Spacer() — The Layout Secret Weapon

Spacer is the most underrated layout tool in SwiftUI. It’s an invisible view that expands to fill available space.

Push content to edges:

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

Spacer pushes texts to opposite sides.

Multiple spacers distribute space:

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

Two spacers = three equal sections.

Spacers work in VStacks too:

VStack {
    Text("Top")
    Spacer()
    Text("Bottom")
}

Content pushed to top and bottom edges.

You can set minimum space:

HStack {
    Text("Close")
    Spacer(minLength: 50)
    Text("Together")
}

At least 50pt between them, more if space available.

Combining Spacer with alignment creates powerful layouts:

VStack(alignment: .leading) {
    Text("Title")
        .font(.largeTitle)
    Text("Subtitle")
        .font(.subheadline)

    Spacer()

    HStack {
        Button("Cancel") { }
        Spacer()
        Button("Save") { }
    }
}
.padding()

Content at top, buttons at bottom, Cancel and Save on opposite sides.

Spacer vs frame — they look similar but behave different:

// with Spacer
HStack {
    Text("Text")
    Spacer()
}

// with frame
HStack {
    Text("Text")
        .frame(maxWidth: .infinity, alignment: .leading)
}

Spacer pushes text left but text size doesn’t change. Frame makes text view expand but text stays leading-aligned within. End result looks the same but the underlying structure differs.

Use Spacer when you want flexible empty space. Use frame when you need the view itself to expand.

Real example — a navigation bar:

HStack {
    Button(action: {}) {
        Image(systemName: "arrow.left")
    }

    Spacer()

    Text("Settings")
        .font(.headline)

    Spacer()

    Button(action: {}) {
        Image(systemName: "ellipsis")
    }
}
.padding()

Back button on left, title centered (thanks to two spacers), menu button on right. Classic iOS pattern.

Another pattern — list rows with trailing accessories:

HStack {
    Image(systemName: "folder.fill")
        .foregroundColor(.blue)

    Text("Documents")

    Spacer()

    Text("42")
        .foregroundColor(.secondary)

    Image(systemName: "chevron.right")
        .foregroundColor(.secondary)
}

Icon and label on left, count and chevron on right, Spacer handles everything in between.

You can also use Spacer() inside ZStack but it’s less common:

ZStack(alignment: .topLeading) {
    Color.blue

    VStack {
        HStack {
            Text("Badge")
            Spacer()
        }
        Spacer()
    }
    .padding()
}

Forces the badge to top-left even in a ZStack.

📺 Want to see these layout techniques in action? Check out the Swift Pal YouTube channel at https://youtube.com/@swift-pal for video tutorials on SwiftUI layout and iOS development.

The key to SwiftUI layout isn’t memorizing every modifier — it’s understanding how stacks, alignment, spacing, padding, frames, and spacers interact. Once that clicks, you stop guessing and start knowing why your layout looks the way it does.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading