Skip to content
beginner Phase 2 · SwiftUI Fundamentals

Layout System

Master VStack, HStack, ZStack, GeometryReader, custom layouts, and adaptive sizing.

50m
3 problems
Topic Progress 0%

Stacks & Frames

VStack - Vertical Stacking

VStack arranges views from top to bottom. It accepts alignment and spacing parameters.

VStack(alignment: .leading, spacing: 12) {
    Text("Title")
        .font(.title)
    Text("Subtitle")
        .font(.subheadline)
        .foregroundColor(.secondary)
    Divider()
    Text("Body content goes here with multiple lines of text.")
        .font(.body)
}

Alignment options: .leading, .center (default), .trailing. You can also use Spacer() to push views apart within a stack.

HStack - Horizontal Stacking

HStack arranges views from leading to trailing (left to right in LTR languages).

HStack(spacing: 16) {
    Image(systemName: "star.fill")
        .foregroundColor(.yellow)
    Text("Rating: 4.8")
        .font(.headline)
    Spacer()
    Text("(2,340 reviews)")
        .font(.caption)
        .foregroundColor(.secondary)
}

ZStack - Layered Stacking

ZStack layers views on top of each other, with later views appearing on top.

ZStack(alignment: .bottomTrailing) {
    Image("background")
        .resizable()
        .aspectRatio(contentMode: .fill)
    
    LinearGradient(
        colors: [.clear, .black.opacity(0.7)],
        startPoint: .center,
        endPoint: .bottom
    )
    
    VStack(alignment: .leading) {
        Text("Overlay Title")
            .font(.title)
            .foregroundColor(.white)
        Text("Description")
            .foregroundColor(.white.opacity(0.8))
    }
    .padding()
}

Frame Modifier

The .frame() modifier proposes a specific size to the view:

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

// Flexible with constraints
Text("Flexible")
    .frame(minWidth: 100, idealWidth: 200, maxWidth: .infinity, minHeight: 44)

// Width only
Text("Wide")
    .frame(maxWidth: .infinity)

.infinity tells SwiftUI to take all available space in that dimension. This is commonly used for full-width buttons or backgrounds.

Spacer

Spacer takes up all available space along the stack axis, pushing other views apart:

HStack {
    Text("Left")
    Spacer()  // pushes Text("Left") to leading edge
    Text("Right")  // pushed to trailing edge
}

VStack {
    Text("Top")
    Spacer()  // pushes Text("Top") to top
    Text("Bottom")  // pushed to bottom
}

Spacer can also be given a minimum length:

Spacer(minLength: 20)

Alignment Guides

For precise alignment control within stacks, use alignment guides:

VStack(alignment: .leading) {
    Text("Short")
    Text("A much longer text that wraps")
        .alignmentGuide(.leading) { d in d[.trailing] }
}

Nested Stacks

Real UIs use nested stacks for complex layouts:

HStack(spacing: 12) {
    Image(systemName: "person.circle.fill")
        .font(.title)
    VStack(alignment: .leading, spacing: 4) {
        Text("John Doe")
            .font(.headline)
        Text("Software Engineer")
            .font(.subheadline)
            .foregroundColor(.secondary)
    }
    Spacer()
    Image(systemName: "chevron.right")
        .foregroundColor(.secondary)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)

GeometryReader

What GeometryReader Does

GeometryReader provides access to the size and coordinate space of the view it contains. It acts as a flexible container that takes up all available space and provides its dimensions to its content closure.

GeometryReader { geometry in
    VStack {
        Text("Width: \(Int(geometry.size.width))")
        Text("Height: \(Int(geometry.size.height))")
    }
}

GeometryReader is greedy -- it expands to fill all available space. This makes it useful for reading dimensions but can cause layout issues if used carelessly.

Reading Safe Area Insets

GeometryReader provides access to safe area insets, which is critical for full-screen designs:

GeometryReader { geometry in
    let safeTop = geometry.safeAreaInsets.top
    let safeBottom = geometry.safeAreaInsets.bottom
    
    VStack {
        Text("Safe top: \(safeTop)")
        Spacer()
        Text("Safe bottom: \(safeBottom)")
    }
}

Using Geometry for Responsive Layouts

GeometryReader enables layouts that adapt to different screen sizes:

struct ResponsiveLayout: View {
    var body: some View {
        GeometryReader { geometry in
            let isWide = geometry.size.width > 600
            
            if isWide {
                HStack(spacing: 20) {
                    SidePanel()
                    MainContent()
                }
            } else {
                VStack {
                    MainContent()
                }
            }
        }
    }
}

Coordinate Spaces

GeometryReader can read positions relative to named coordinate spaces, which is useful for scroll effects and parallax:

ScrollView {
    VStack(spacing: 0) {
        ForEach(items) { item in
            ItemRow(item: item)
                .background(GeometryReader { geo in
                    Color.clear.preference(
                        key: ViewOffsetKey.self,
                        value: geo.frame(in: .named("scroll")).minY
                    )
                })
        }
    }
    .coordinateSpace(name: "scroll")
}
.onPreferenceChange(ViewOffsetKey.self) { offset in
    // React to scroll position
}

Common GeometryReader Patterns

Parallax header:

ScrollView {
    GeometryReader { geo in
        let offset = geo.frame(in: .global).minY
        Image("hero")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: geo.size.width, height: 300 + max(0, offset))
            .offset(y: offset > 0 ? offset * 0.5 : 0)
            .clipped()
    }
    .frame(height: 300)
    // ... rest of content
}

Aspect ratio container:

struct AspectRatioContainer<Content: View>: View {
    let ratio: CGFloat
    @ViewBuilder let content: () -> Content
    
    var body: some View {
        GeometryReader { geo in
            content()
                .frame(
                    width: geo.size.width,
                    height: geo.size.width * ratio
                )
        }
    }
}

Limitations

  • GeometryReader takes up all available space and cannot be used to measure a view's natural size
  • It does not trigger layout updates when the content inside changes size
  • For measuring natural size, consider using Background with GeometryReader or .backgroundPreferenceValue()

Custom Layouts

The Layout Protocol

SwiftUI provides the Layout protocol for creating entirely custom layout containers. This is useful when stacks, grids, and other built-in layouts do not meet your needs.

struct CircularLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        proposal.replacingUnspecifiedDimensions()
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let radius = min(bounds.width, bounds.height) / 2
        let angleStep = 2 * .pi / CGFloat(subviews.count)
        
        for (index, subview) in subviews.enumerated() {
            let angle = angleStep * CGFloat(index) - .pi / 2
            let point = CGPoint(
                x: bounds.midX + radius * cos(angle),
                y: bounds.midY + radius * sin(angle)
            )
            subview.place(at: point, proposal: .unspecified)
        }
    }
}

// Usage
CircularLayout {
    ForEach(0..<8) { i in
        Circle()
            .fill(Color.blue)
            .frame(width: 40, height: 40)
    }
}
.frame(width: 300, height: 300)

Proposed and Remaining Size

The layout protocol uses a size negotiation system:

  1. The parent proposes a size to the layout
  2. The layout asks each subview what size it needs (sizeThatFits)
  3. The layout places subviews within the bounds it was given
struct FlowLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let result = layout(proposal: proposal, subviews: subviews)
        return result.size
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let result = layout(proposal: proposal, subviews: subviews)
        for (index, position) in result.positions.enumerated() {
            subviews[index].place(at: CGPoint(
                x: bounds.minX + position.x,
                y: bounds.minY + position.y
            ), proposal: .unspecified)
        }
    }
    
    private func layout(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
        let maxWidth = proposal.replacingUnspecifiedDimensions().width
n        var positions: [CGPoint] = []
        var currentX: CGFloat = 0
        var currentY: CGFloat = 0
        var lineHeight: CGFloat = 0
        
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if currentX + size.width > maxWidth {
                currentX = 0
                currentY += lineHeight
                lineHeight = 0
            }
            positions.append(CGPoint(x: currentX, y: currentY))
            lineHeight = max(lineHeight, size.height)
            currentX += size.width
        }
        return (positions, CGSize(width: maxWidth, height: currentY + lineHeight))
    }
}

Layout with Animations

Custom layouts can animate changes to their subviews:

struct WaveLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        proposal.replacingUnspecifiedDimensions()
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        for (index, subview) in subviews.enumerated() {
            let x = bounds.minX + (bounds.width / CGFloat(subviews.count + 1)) * CGFloat(index + 1)
            let yOffset = sin(Double(index) * 0.8) * 20
            subview.place(at: CGPoint(x: x, y: bounds.midY + yOffset), proposal: .unspecified)
        }
    }
    
    func updateCache(_ cache: inout (), subviews: Subviews) {
        // Update animation state here
    }
}

When to Use Custom Layouts

  • Grid with custom spacing: Not uniform like LazyVGrid
  • Circular or radial arrangements: Items around a center point
  • Flow/wrap layouts: Items that wrap to the next row
  • Overlapping layouts: Items with intentional overlap
  • Animation-heavy layouts: Custom placement animations

For most common layouts, prefer built-in options (VStack, HStack, ZStack, Grid, LazyVGrid). Custom layouts are for cases where built-ins cannot express your desired behavior.

Quiz

1. What does GeometryReader do?

Question 1 options

2. Why does GeometryReader take up all available space?

Question 2 options

3. What method must you implement in the Layout protocol?

Question 3 options

4. What does Spacer() do inside a VStack?

Question 4 options

5. How does SwiftUI size negotiation work?

Question 5 options

Flashcards

Question

What are the three stack types and their axes?

Answer

VStack (vertical), HStack (horizontal), ZStack (depth/layered).

Question

What does .infinity do in a frame modifier?

Answer

It tells SwiftUI to take all available space in that dimension, creating a full-width or full-height view.

Question

What is GeometryReader's layout behavior?

Answer

It is greedy -- expands to fill all available space and reports its dimensions to the content closure.

Question

What two methods does the Layout protocol require?

Answer

sizeThatFits(proposal:subviews:cache:) to report needed size, and placeSubviews(in:proposal:subviews:cache:) to position children.

Question

When should you use ZStack vs VStack?

Answer

ZStack layers views on top of each other (overlapping). VStack stacks views vertically without overlap.

Revision Notes

Key Takeaways

  • 1. Stacks are the primary layout mechanism; use Spacer to push views apart
  • 2. GeometryReader reads container dimensions but always expands to fill available space
  • 3. The Layout protocol enables fully custom layout behaviors
  • 4. SwiftUI uses a propose-and-report size negotiation system
  • 5. Nested stacks handle most real-world layout needs

Interview Tips

  • Explain the size negotiation process: propose -> report -> place
  • Know why GeometryReader is greedy and how to constrain it
  • Be ready to discuss when to use custom Layout vs built-in stacks/grids
  • Understand how Spacer works within different stack orientations

Cheat Sheet

VStack: vertical, HStack: horizontal, ZStack: layered.

Spacer() fills remaining space along the stack axis.

.frame() proposes fixed/flexible dimensions; .infinity takes all available space.

GeometryReader reads size and safe area insets; it is greedy and expands to fill space.

Layout protocol requires sizeThatFits and placeSubviews for custom layouts.

Size negotiation: parent proposes -> child reports -> parent places.