Mobile Development · SwiftUI Basics

SwiftUI Layout: The Rules That Stop the ‘Why Is It Doing That?’

A mental model for stacks, frames, alignment, and spacing.

Reading time: ~8–12 min
Level: All levels
Updated:

SwiftUI layout feels “mystical” until you learn the rules it’s following. Most weirdness comes from two things: implicit proposals (the size a parent offers) and modifier order (what you’re actually measuring). This post gives you a reliable mental model for stacks, frames, alignment, and spacing—so you can predict layout instead of poking at it.


Quickstart

When a SwiftUI view “should be centered” or “should take full width” but isn’t, don’t fight it with random .frame calls. Use this quick sequence: it’s fast, repeatable, and it usually reveals the real constraint in under 2 minutes.

1) Make constraints visible

You can’t debug what you can’t see. Add temporary borders/backgrounds and remove them after.

  • Add .border(.pink) or a translucent .background to the view and its parent
  • Check whether the parent is smaller than you think (common in List/ScrollView)
  • Look for a modifier earlier in the chain that changes size (padding, frame, fixedSize)

2) Apply the 3-rule mental model

Almost every layout surprise is explainable with one sentence.

  • Parent proposes a size to a child
  • Child chooses its size (within that proposal)
  • Parent places the child using alignment

3) Use the “full width + align” pattern

If something won’t align, it often doesn’t have enough width to align within.

  • Give it room: .frame(maxWidth: .infinity)
  • Then align: .frame(maxWidth: .infinity, alignment: .leading)
  • In stacks, set the stack’s alignment too (VStack(alignment: .leading))

4) Fix truncation/compression intentionally

Don’t “hope” the layout engine picks the right view to shrink.

  • Protect important content with .layoutPriority(1)
  • Use .fixedSize(horizontal: false, vertical: true) for multiline text height
  • Remember: Spacer() expands; it doesn’t “add padding”
Two questions that solve most bugs
  • What size did my parent propose to this view?
  • Which modifier in my chain is the first one that changes that size?

Overview

SwiftUI layout isn’t “auto magic.” It’s a negotiation between parent and child views, performed in a predictable order. Once you understand that order, stacks, frames, alignment, and spacing stop being mysterious knobs and start being tools you can reason about.

What you’ll walk away with

  • A reliable mental model: proposal → chosen size → placement
  • How VStack/HStack/ZStack actually distribute space (and why Spacer “steals” it)
  • How .frame and alignment interact (including the common “why didn’t alignment change?” bug)
  • How modifier order changes layout (padding/background/frame are not interchangeable)
  • A practical debugging workflow and a cheatsheet you can keep open while building UI

The focus here is day-to-day SwiftUI layout: building screens, cards, rows, and responsive UI that behaves well across device sizes, dynamic type, and localization. No hand-waving—just rules, patterns, and what to watch out for.

Core concepts

If you learn only one thing about SwiftUI layout, learn this: the system is not trying to “make it look right.” It’s trying to satisfy constraints in a consistent way. Here are the pieces of that system.

1) The layout pass: proposal, choice, placement

SwiftUI performs layout in a pass where each parent offers a proposed size to its children. Each child picks a size that fits its content and the proposal, and the parent places it according to alignment rules.

Step Who acts? What happens Common surprise
Proposal Parent “Here’s the space you may use” (could be exact, max, min, or unspecified) A parent proposes less space than you assumed (e.g., inside a row)
Choice Child Child chooses its size (text can wrap; images can scale; spacers can expand) Child chooses its ideal size, not “fill” size, unless told
Placement Parent Parent positions the child using alignment and spacing Alignment doesn’t apply because the view doesn’t have extra space
Why alignment “does nothing” sometimes

Alignment only matters when there is unused space inside the view’s container. If a view is sized tightly to its content, there’s nothing to align within—so alignment looks like it’s ignored. The fix is usually to create space (often with .frame(maxWidth: .infinity)).

2) Containers: stacks, spacers, and “who owns the space?”

Stacks are layout containers. They don’t “draw.” They measure children and distribute available space. The important bit: in a stack, most views are content-sized, while Spacer is space-sized.

How stacks typically behave

  • VStack/HStack size themselves to fit children (plus spacing)
  • Children without flexible behavior use their ideal size
  • Spacer() expands to consume remaining space
  • Over-constrained stacks shrink views based on priorities

The “Spacer rule”

A Spacer is not padding. It’s a flexible view that takes leftover space. Put it where you want empty space to grow.

  • One spacer pushes content to the opposite side
  • Two spacers center content (equal flexible space on both sides)
  • Multiple spacers share available space

3) Modifier order: you’re wrapping views, not “setting properties”

In SwiftUI, modifiers are not configuration flags. Most modifiers return a new view that wraps the previous one. That’s why .padding().background() and .background().padding() are different: you changed what you’re measuring.

A practical way to think about it

  • padding changes the view’s size by adding inset space
  • background draws behind the current view size (it doesn’t add size)
  • frame constrains the size offered to (or claimed by) the wrapped view
  • overlay draws on top without changing size
  • offset moves rendering without changing layout size (great for animation, risky for “fixing layout”)

4) Compression, hugging, and priority (the invisible tiebreaker)

When space is tight, SwiftUI must decide what gets smaller first. That decision is influenced by view behavior and .layoutPriority(). Priority is a tiebreaker: higher priority views get to keep more of their ideal size.

A common trap: “I’ll just add another frame”

If your layout is over-constrained, stacking multiple frames often makes it harder to reason about. Prefer making one container decision explicit (width/height) and then using alignment and priority to resolve the rest.

Step-by-step

This section is a practical workflow: how to build and debug SwiftUI layout with predictable results. The goal isn’t “memorize modifiers.” The goal is to identify the constraint owner (which parent controls the proposal) and then apply the smallest change that makes the constraint explicit.

Step 1 — Add a tiny layout debugger (and stop guessing)

Start every layout diagnosis by making boundaries visible. This helper draws a border and overlays the measured size. Use it temporarily while you build a screen, then remove it.

import SwiftUI

private struct DebugLayout: ViewModifier {
    let name: String
    let color: Color

    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { proxy in
                    let size = proxy.size
                    ZStack(alignment: .topLeading) {
                        Rectangle()
                            .stroke(color.opacity(0.9), lineWidth: 1)
                        Text("\(name) \(Int(size.width))×\(Int(size.height))")
                            .font(.caption2.monospaced())
                            .padding(4)
                            .background(color.opacity(0.15))
                            .foregroundStyle(color)
                            .clipShape(RoundedRectangle(cornerRadius: 6))
                            .padding(4)
                    }
                }
            )
    }
}

extension View {
    /// Debug the layout bounds and measured size of a view.
    func debugLayout(_ name: String, color: Color = .pink) -> some View {
        modifier(DebugLayout(name: name, color: color))
    }
}
How to use it
  • Apply to the “broken” view and its parent container
  • If sizes look unexpected, walk up the tree until you find the first surprising constraint
  • Remove after you fix the issue (GeometryReader overlays are for debugging, not production UI)

Step 2 — Choose the right container for the job

Many “layout bugs” are actually “wrong container” bugs. If you’re stacking a list of rows, use a vertical container. If you need overlapping elements, use a ZStack/overlay. If you need grid behavior, use LazyVGrid or a custom layout.

Good defaults

  • Vertical screen sections: VStack + ScrollView
  • Row with leading/trailing: HStack + one Spacer()
  • Badge/overlay: .overlay(alignment: ...)
  • Multiple sizes/responsive columns: LazyVGrid

Smells (things that often backfire)

  • Using .position or huge .offset to “place” things
  • Nesting many stacks when a single grid/layout would be clearer
  • Using top-level GeometryReader as a default (it can make children expand unexpectedly)
  • Hardcoding widths that break with localization or dynamic type

Step 3 — Make space first, then align

Alignment is a placement decision. If there is no extra space, alignment has nothing to do. The most reliable pattern is: create a flexible container, then align inside it.

Alignment rules that stop surprises

  • VStack(alignment: .leading) controls how children align relative to each other
  • .frame(..., alignment: .leading) controls how the view aligns inside its own frame
  • ZStack(alignment: .topTrailing) aligns overlapping children
  • Spacer() changes distribution; it’s not an alignment setting

Step 4 — Build a “real” row: protect important views with priority

Here’s a common layout: leading icon + title/subtitle + trailing action. The tricky part is compression: when space is tight (small devices, large text, long strings), you want the action to stay tappable and the title to wrap/truncate gracefully.

import SwiftUI

struct SettingsRow: View {
    let title: String
    let subtitle: String
    let actionTitle: String

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 12) {
            Image(systemName: "gearshape.fill")
                .symbolRenderingMode(.hierarchical)
                .foregroundStyle(.secondary)
                .frame(width: 22, height: 22, alignment: .center)

            VStack(alignment: .leading, spacing: 4) {
                Text(title)
                    .font(.headline)
                    .lineLimit(2)
                    .fixedSize(horizontal: false, vertical: true)

                Text(subtitle)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
                    .fixedSize(horizontal: false, vertical: true)
            }
            .layoutPriority(1) // Prefer keeping text over shrinking it too aggressively.

            Spacer(minLength: 8)

            Button(actionTitle) {
                // action
            }
            .buttonStyle(.bordered)
            .fixedSize() // Keep the button at its ideal size (don’t let it compress).
        }
        .padding(.vertical, 10)
        .padding(.horizontal, 14)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
    }
}
Why this works
  • The row is given room (maxWidth: .infinity), so alignment can apply
  • The text column gets priority when space is tight (.layoutPriority(1))
  • The button stays usable with .fixedSize() (it won’t shrink into unreadable text)
  • Multiline text uses .fixedSize(horizontal: false, vertical: true) so it can grow in height

Step 5 — Use GeometryReader intentionally (and know what it changes)

GeometryReader is powerful, but it changes the negotiation: it tends to take all available space and then reports it. That’s great when you truly need container size (e.g., a chart or custom positioning), but it can cause accidental full-screen expansion if placed too high in the hierarchy.

Use GeometryReader when…

  • You need proportional sizing based on container width/height
  • You’re building a custom layout or visual effect
  • You’re measuring for a one-off overlay/animation

Avoid it when…

  • You’re trying to “center something” (stacks + frames do that better)
  • You can express the layout via Spacer, alignment, or grids
  • You’re inside ScrollView and don’t want unexpected stretching

Step 6 — When stacks aren’t enough: a tiny custom Flow layout

Sometimes you want tags/chips that wrap to the next line. Nested stacks can’t do wrapping. On newer OS versions, SwiftUI gives you the Layout protocol—use it to express the rule directly. (Even if you don’t ship this exact layout, reading it teaches you how SwiftUI thinks about measurement and placement.)

import SwiftUI

/// A simple "flow" layout: items go left-to-right, wrapping to new lines as needed.
struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity

        var lineWidth: CGFloat = 0
        var lineHeight: CGFloat = 0
        var totalHeight: CGFloat = 0
        var maxLineWidth: CGFloat = 0

        for view in subviews {
            let size = view.sizeThatFits(.unspecified)

            if lineWidth + size.width > maxWidth {
                totalHeight += lineHeight + spacing
                maxLineWidth = max(maxLineWidth, lineWidth)
                lineWidth = 0
                lineHeight = 0
            }

            lineWidth += (lineWidth == 0 ? 0 : spacing) + size.width
            lineHeight = max(lineHeight, size.height)
        }

        totalHeight += lineHeight
        maxLineWidth = max(maxLineWidth, lineWidth)

        return CGSize(width: min(maxLineWidth, maxWidth), height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        var x = bounds.minX
        var y = bounds.minY
        var lineHeight: CGFloat = 0

        for view in subviews {
            let size = view.sizeThatFits(.unspecified)

            if x + size.width > bounds.maxX {
                x = bounds.minX
                y += lineHeight + spacing
                lineHeight = 0
            }

            view.place(
                at: CGPoint(x: x, y: y),
                proposal: ProposedViewSize(width: size.width, height: size.height)
            )

            x += size.width + spacing
            lineHeight = max(lineHeight, size.height)
        }
    }
}

Notice how this layout follows the same rule set: given a proposed width, it computes the size that fits, then places children. That’s the layout engine in miniature.

Common mistakes

These are the patterns behind most “why is it doing that?” moments. Each one includes the fix that tends to stick long-term, not just a one-off patch.

Mistake 1 — Expecting alignment to work without space

If the view is content-sized, there’s nothing to align within.

  • Symptom: .frame(alignment: .leading) appears to do nothing
  • Fix: create space first: .frame(maxWidth: .infinity, alignment: .leading)
  • Fix: set stack alignment too: VStack(alignment: .leading)

Mistake 2 — Treating Spacer like padding

Spacer absorbs leftover space and can dominate a stack if you forget it’s flexible.

  • Symptom: content gets pushed away “randomly”
  • Fix: place Spacer() where you want growth, not where you want a fixed gap
  • Fix: use spacing or padding for fixed gaps

Mistake 3 — Mixing up modifier order (padding/background/frame)

You changed what gets measured, so the result changed.

  • Symptom: background doesn’t cover padding (or covers too much)
  • Fix: decide what you want to measure:
  • Rule: padding then background makes the background include padding

Mistake 4 — “Fixing layout” with offset/position

offset changes rendering, not layout size; it can break hit-testing expectations and future changes.

  • Symptom: taps feel off, or content overlaps strangely
  • Fix: use alignment, padding, or proper containers
  • Use offset for: animation and temporary visual movement, not structure

Mistake 5 — Overusing GeometryReader

It often expands to all available space and changes how children get proposed sizes.

  • Symptom: view suddenly becomes huge / full screen
  • Fix: move GeometryReader down the tree or constrain it with a frame
  • Fix: prefer stack/grid constraints when possible

Mistake 6 — Not controlling compression under localization & Dynamic Type

English fits; German and large text don’t. Layout needs priorities.

  • Symptom: text truncates the wrong label, buttons shrink too much
  • Fix: use .layoutPriority and .fixedSize intentionally
  • Fix: test with long strings + accessibility sizes early
The fastest diagnostic move

When you don’t understand a view’s size, remove modifiers one by one from the outside in. The first modifier that changes the measured bounds is usually the real cause.

FAQ

Why doesn’t .frame(alignment: ...) move my content?

Because alignment only matters when there’s extra space inside the frame. If the view is sized to its content, there’s no unused area to place content “to the leading edge.” Give the view room with .frame(maxWidth: .infinity, alignment: .leading) (or constrain the parent to be larger).

Why does my VStack sometimes take full width and sometimes hug content?

A VStack sizes itself based on its children and the proposal from its parent. In many containers, the parent proposes the full available width, so the stack can expand. In others (like certain rows, overlays, or nested stacks), the proposal is tighter, so the stack hugs content. If you need consistent behavior, make it explicit with .frame(maxWidth: .infinity) or by sizing the parent container.

When should I use Spacer() vs padding?

Use padding for a fixed gap around or between elements. Use Spacer() when you want a gap that grows/shrinks with available space (e.g., push a button to the trailing edge). If you use a Spacer when you meant a fixed gap, your layout will look “random” across device sizes.

What’s the difference between stack alignment and frame alignment?

Stack alignment (e.g., VStack(alignment: .leading)) controls how children line up relative to each other within that stack. Frame alignment (e.g., .frame(width: 200, alignment: .leading)) controls where the view’s content sits inside the frame you gave it. They solve different problems—often you need both for predictable results.

Why does GeometryReader make things expand?

GeometryReader tends to adopt all the space its parent offers, then reports that size. If you place it high in the view tree, it can become the “space owner” and cause children to behave as if they have much more room. Constrain it with a frame, move it lower, or use stack/grid constraints when you can.

How do I stop text from truncating in a row?

Decide what should win when space is tight. Give the important text higher .layoutPriority() and make controls like buttons .fixedSize() so they stay tappable. For multiline text that should grow vertically, use .fixedSize(horizontal: false, vertical: true) and a reasonable .lineLimit.

Cheatsheet

Keep this open while you build. It’s the “what actually changes size?” map plus a quick checklist for debugging SwiftUI layout.

The 10-second layout checklist

  • What size is the parent proposing?
  • Is my view content-sized, or did I make it flexible (maxWidth: .infinity)?
  • Do I have a Spacer that’s consuming space I expected to belong to content?
  • Am I relying on alignment without having extra space?
  • Is text/compression controlled by layoutPriority and fixedSize?
  • Did modifier order change what’s being measured (padding vs background vs frame)?
Thing What it affects Common use Common trap
padding Size (adds space) Breathing room around content Putting background before padding when you wanted it to include padding
frame Size constraints + alignment within the frame Make view flexible or set fixed bounds Expecting alignment to work without extra space
Spacer Distribution of extra space Push content to edges, center items Using it like “padding” and getting unpredictable gaps
layoutPriority Compression tiebreaker Protect important text Setting it everywhere; it’s most useful in tight horizontal layouts
fixedSize Whether a view is allowed to compress Keep a button readable; allow multiline text height Forcing a huge view that no longer adapts to small screens
background/overlay Drawing (not size) Cards, borders, visual affordances Assuming it changes the measured bounds
offset Rendering position (not layout) Animation, subtle motion Using it to “fix” layout; hit areas and spacing may be wrong
One rule to remember forever

If you can explain your layout in terms of “proposal → choice → placement,” you can debug it. If you can’t, add borders and walk up the view tree until you can.

Wrap-up

SwiftUI layout gets dramatically simpler when you stop thinking in terms of “centering” and “spacing” as magic and start thinking in terms of who owns the proposal and what size each view chooses. Once you adopt that mental model, frames, alignment, and spacers become predictable tools instead of surprises.

Next actions (15 minutes)

  • Pick one screen in your app that feels “fragile” and add temporary borders to reveal constraints
  • Replace any “mystery offsets” with alignment + frames
  • Test one tricky row with large Dynamic Type and a long localized string
  • Save the cheatsheet and reuse the row pattern from the step-by-step section

If you want to go further, the related posts below connect well: architecture choices affect where layout code lives, profiling helps you spot expensive view trees, and robust deep links often require careful UI state and layout handling.

Quiz

Quick self-check (demo). This quiz is auto-generated for mobile / development / swiftui.

1) In SwiftUI layout, what happens first?
2) Why does alignment sometimes look like it’s ignored?
3) What is the primary purpose of Spacer() in an HStack/VStack?
4) Which pair is most directly used to control “who shrinks first” when space is tight?