iOS with SwiftUI · SwiftUI Foundations: Views, Layout, Lists and State

Layout with Stacks, Modifiers and Adaptive Design

Arrange views with VStack, HStack and ZStack, size and space them with frames, padding and Spacer, understand why modifier order matters, and support Dynamic Type, dark mode and iPad.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A product card: an image placeholder on the left, title and price stacked on the right, and a full-width "Add to cart" button underneath, which becomes a grid of cards on iPad and keeps working at the largest text sizes.

The idea

SwiftUI layout is a negotiation: a parent proposes a size, the child chooses its size, and the parent places it.

  • VStack, HStack and ZStack arrange children vertically, horizontally and in layers; Spacer takes free space.
  • Modifiers wrap views, so order matters: .padding().background(.blue) colours the padding, .background(.blue).padding() doesn't.
  • .frame(maxWidth: .infinity) makes a view fill the available width.
  • Dynamic Type: text styles (.headline, .body) scale with the user's text-size setting; layouts must grow vertically rather than truncate.
  • Size classes (compact and regular horizontal width) tell you whether you have phone-like or iPad-like space. ViewThatFits picks the first layout that fits.
  • Semantic colours (.primary, .secondary, Color.accentColor) adapt to dark mode automatically.

The code

struct ProductCard: View {
    let product: Product
    let onAdd: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack(spacing: 16) {
                RoundedRectangle(cornerRadius: 12)
                    .fill(.tint.opacity(0.2))
                    .frame(width: 64, height: 64)
                VStack(alignment: .leading, spacing: 4) {
                    Text(product.title).font(.headline).lineLimit(2)
                    Text(product.priceLabel).foregroundStyle(.tint)
                }
                Spacer(minLength: 0)
            }
            Button("Add to cart", action: onAdd)
                .buttonStyle(.borderedProminent)
                .frame(maxWidth: .infinity)
        }
        .padding()
        .background(.background.secondary, in: .rect(cornerRadius: 16))
    }
}

struct ProductGrid: View {
    let products: [Product]
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        let columns = Array(repeating: GridItem(.flexible(), spacing: 12), count: sizeClass == .regular ? 3 : 1)
        ScrollView {
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(products) { product in
                    ProductCard(product: product, onAdd: {})
                }
            }
            .padding()
        }
    }
}

#Preview("Large text, dark") {
    ProductGrid(products: Product.samples)
        .environment(\.dynamicTypeSize, .accessibility2)
        .preferredColorScheme(.dark)
}

Try the logic

Column counts and price formatting are logic the views call:

Columns per size class and price labels
enum SizeClass { case compact, regular }

func columns(for sizeClass: SizeClass, isLandscape: Bool) -> Int {
    switch (sizeClass, isLandscape) {
    case (.compact, _): 1
    case (.regular, false): 2
    case (.regular, true): 3
    }
}

func priceLabel(cents: Int, currency: String = "EUR") -> String {
    let fraction = cents % 100
    return "\(currency) \(cents / 100).\(fraction < 10 ? "0" : "")\(fraction)"
}

for (device, size, landscape) in [("iPhone", SizeClass.compact, false), ("iPad portrait", .regular, false),
                                  ("iPad landscape", .regular, true)] {
    print("\(device): \(columns(for: size, isLandscape: landscape)) column(s)")
}
print(priceLabel(cents: 1999))
print(priceLabel(cents: 505, currency: "USD"))

Output

iPhone: 1 column(s)
iPad portrait: 2 column(s)
iPad landscape: 3 column(s)
EUR 19.99
USD 5.05

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Fixed frames for text. .frame(height: 44) around text clips it at large Dynamic Type sizes; let text define its height.
  • Wrong modifier order. A background before padding doesn't cover the padding; a tap area added before padding is smaller than the visible view.
  • Hard-coded colours. Color.white text disappears in dark mode backgrounds; use semantic colours and materials.
  • Assuming iPhone only. iPad split view gives your app a compact width too; adapt to size classes, not device models.

Key points

  • Stacks, Spacer and frames cover most layouts; parents propose, children choose their size.
  • Modifiers wrap views, so their order changes the result.
  • Support Dynamic Type and dark mode by using text styles and semantic colours.
  • Adapt layouts with size classes and ViewThatFits.

Further reading: Layout fundamentals, Human Interface Guidelines: Layout.

Practice questions

How does SwiftUI decide the size of a view?

Answer

The parent proposes a size to the child, the child chooses its own size (text takes what it needs, shapes and Color take everything proposed, frames constrain), and then the parent positions the child. Stacks divide the proposal among their children, giving flexible children what's left.

How do you make a SwiftUI layout work with Dynamic Type?

Answer

Use built-in text styles (.body, .headline) so fonts scale, avoid fixed heights and widths around text, allow multiple lines, and switch to a vertical layout when an HStack no longer fits (for example with ViewThatFits or by checking dynamicTypeSize.isAccessibilitySize). Test with large sizes in previews.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?