Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 016 / 100

SwiftUI stacks and layout: a practical Swift & iOS guide

VStack, HStack and ZStack.

Expona Admin5 min read

Introduction

Layout is the foundation of any user interface, and getting it right means the difference between an app that feels polished and one that frustrates users. In SwiftUI stacks and layout: a practical Swift & iOS guide, we'll explore the core building blocks you'll use daily: VStack, HStack, ZStack, Spacer, frame modifiers, and Grid. These aren't advanced topics—they're the bread and butter of iOS development, and mastering them will dramatically improve how quickly you ship pixel-perfect interfaces.

Over the past 15 days, you've learned SwiftUI fundamentals and state management. Now it's time to control how views position and size themselves on screen. Let's dig in.

VStack: organizing views vertically

VStack is your go-to container when you need to stack views in a vertical column. Every view inside a VStack sits below the previous one, respecting safe areas and alignment preferences.

Consider a user profile card. You'd typically stack the user's avatar image, their name, and their bio vertically:

swift
VStack(alignment: .leading, spacing: 12) {
    Image("avatar")
        .resizable()
        .frame(width: 64, height: 64)
        .clipShape(Circle())
    
    Text("Sarah Chen")
        .font(.headline)
    
    Text("Full-stack engineer, coffee enthusiast")
        .font(.caption)
        .foregroundColor(.gray)
}
.padding(16)

Notice the alignment parameter: .leading aligns all children to the left edge. Other options include .center, .trailing, and even custom alignments. The spacing parameter adds consistent gaps between items.

In production e-commerce apps, VStack forms the backbone of product detail screens, order confirmation flows, and settings pages. The key insight is that VStack sizes itself based on its children's size requirements—it doesn't stretch unless you tell it to via frame modifiers.

HStack: organizing views horizontally

HStack arranges views in a horizontal row from left to right. This is essential for controls, price displays, and side-by-side comparisons.

Imagine a cart item row showing quantity controls, product name, and price:

swift
HStack(alignment: .center, spacing: 12) {
    Button(action: { quantity -= 1 }) {
        Image(systemName: "minus.circle")
    }
    
    Text("\(quantity)")
        .frame(minWidth: 30)
    
    Button(action: { quantity += 1 }) {
        Image(systemName: "plus.circle")
    }
    
    VStack(alignment: .leading, spacing: 4) {
        Text("Blue T-Shirt")
            .font(.body)
        Text("Size M")
            .font(.caption)
            .foregroundColor(.gray)
    }
    
    Spacer()
    
    Text("$24.99")
        .font(.headline)
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(8)

Here we nest a VStack inside the HStack—a common pattern. The Spacer() between the product info and price pushes the price to the right edge. HStack alignment defaults to .center, which vertically centers its children.

ZStack: layering views

ZStack overlays views on top of each other, useful for badges, shadows, overlays, and image filters. Views are rendered in the order you declare them, so the first view sits underneath.

A practical example: a product image with a discount badge:

swift
ZStack(alignment: .topTrailing) {
    Image("product")
        .resizable()
        .scaledToFit()
    
    if isOnSale {
        Text("-20%")
            .font(.caption)
            .fontWeight(.bold)
            .foregroundColor(.white)
            .padding(6)
            .background(Color.red)
            .clipShape(Circle())
            .padding(8)
    }
}
.frame(height: 280)

The alignment parameter controls where layered views sit relative to each other. .topTrailing positions the badge in the top-right corner. Without explicit positioning, views in a ZStack center themselves.

We've used ZStack in trading platforms to display loading spinners over charts, and in ride-hailing apps to layer driver ratings over avatar images. The alignment flexibility is powerful, but it's easy to over-layer and create visual noise—use sparingly and intentionally.

Spacer and frame: controlling space

Spacer is a view that expands to fill available space. By itself, it's invisible; its power lies in pushing other views around. Frame modifiers let you set explicit sizes, flexibility, and alignment.

A header with a title on the left and a close button on the right:

swift
HStack {
    Text("Order Details")
        .font(.headline)
    
    Spacer()
    
    Button(action: { dismiss() }) {
        Image(systemName: "xmark")
    }
}
.padding(16)

Spacer fills all horizontal space, pushing the close button to the far right. Multiple Spacers divide space equally:

swift
HStack(spacing: 0) {
    Text("Left")
    Spacer()
    Text("Center")
    Spacer()
    Text("Right")
}
// Three columns of equal width

For explicit sizing, use frame:

swift
VStack(spacing: 16) {
    TextField("Email", text: $email)
        .frame(height: 44)
    
    Button("Sign In") { login() }
        .frame(maxWidth: .infinity)
        .frame(height: 48)
        .background(Color.blue)
        .foregroundColor(.white)
        .cornerRadius(8)
}
.padding(20)

Notice .frame(maxWidth: .infinity)—this makes the button stretch to fill available width. In production CRM dashboards, we use frame constraints constantly to maintain consistent touch targets (minimum 44x44 points) and responsive layouts across devices.

Grid for table-like layouts

Starting with iOS 16, LazyVGrid and LazyHGrid provide efficient, flexible grid layouts. They're "lazy," meaning they only render visible items—essential for performance in large lists.

An e-commerce product grid:

swift
let columns = [
    GridItem(.flexible(), spacing: 12),
    GridItem(.flexible(), spacing: 12)
]

ScrollView {
    LazyVGrid(columns: columns, spacing: 12) {
        ForEach(products) { product in
            VStack(alignment: .leading, spacing: 8) {
                Image(product.image)
                    .resizable()
                    .scaledToFill()
                    .frame(height: 180)
                    .clipped()
                
                Text(product.name)
                    .font(.subheadline)
                    .lineLimit(2)
                
                Text("$\(String(format: "%.2f", product.price))")
                    .font(.headline)
                    .foregroundColor(.blue)
            }
            .background(Color(.systemGray6))
            .cornerRadius(8)
        }
    }
    .padding(12)
}

The columns parameter defines column layout. .flexible() makes each column take equal space; you can also use .fixed(120) for a specific width, or .adaptive(minimum: 150) to fit as many columns as possible. LazyVGrid handles wrapping automatically.

For vertical grids (rows), use LazyHGrid with a rows parameter instead. In travel booking apps, we use grids for flight results and hotel galleries where performance and responsive layout matter equally.

Common mistakes

Overusing fixed heights. If you hardcode frame heights throughout your app, it breaks on different devices and dynamic type settings. Prefer flexible constraints and let SwiftUI calculate sizes when possible.

Forgetting Spacer placement. New developers often reach for manual padding when a Spacer would do the job cleanly. Spacer is the idiomatic way to push views apart in a stack.

Nesting stacks unnecessarily. Three levels of VStack/HStack nesting isn't inherently wrong, but it complicates view hierarchies and makes debugging harder. Extract nested stacks into separate functions or views.

Using ZStack for sequential layouts. ZStack is for layering, not positioning. If you want views side-by-side or top-to-bottom, use HStack or VStack. Misusing ZStack creates maintenance nightmares.

Ignoring LazyVGrid performance. Regular VStack with ForEach renders all views upfront. In lists with hundreds of items, this tanks performance. Use LazyVGrid or List whenever possible.

Key takeaways

  • VStack organizes children vertically; HStack horizontally. Both support alignment and spacing parameters for fine-grained control.
  • ZStack layers views on top of each other with alignment control. Use sparingly for badges, overlays, and visual effects.
  • Spacer expands to fill available space and is the idiomatic way to push views apart. Combine with frame modifiers for explicit sizing.
  • LazyVGrid and LazyHGrid provide efficient, flexible grid layouts. Always choose them over nesting stacks for table-like data.
  • Think in terms of flexible constraints, not fixed pixels. Let SwiftUI calculate sizes based on content and device context.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles