Layout, stacks and adaptive UI

Compose screens with VStack, HStack and ZStack, build grids, read available space safely, and make one layout work from iPhone SE to iPad with Dynamic Type.

Stacks, frames and alignment

struct MetricCard: View {
    let title: String
    let value: String

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(title)
                .font(.caption)
                .foregroundStyle(.secondary)
            Text(value)
                .font(.title2.bold())
                .monospacedDigit()
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
    }
}

struct Dashboard: View {
    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            MetricCard(title: "Open", value: "12")
            MetricCard(title: "Closed", value: "903")
        }
        .padding(.horizontal)
    }
}
  • frame(maxWidth: .infinity) makes a child fill the space a stack offers; without it a stack hugs its content.
  • Order matters: padding before background paints the background behind the padded area, after it paints inside.
  • ZStack layers views; use alignment: on the stack rather than offsets where possible.
  • Prefer Spacer() and Divider() over hard-coded pixel gaps so text scaling does not break the layout.

Grids and available space

struct PhotoGrid: View {
    let photos: [Photo]

    private let columns = [
        GridItem(.adaptive(minimum: 140), spacing: 8)
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 8) {
                ForEach(photos) { photo in
                    Image(photo.name)
                        .resizable()
                        .scaledToFill()
                        .frame(minHeight: 140)
                        .clipped()
                }
            }
            .padding(8)
        }
    }
}
NeedPreferAvoid
Equal columnsLazyVGrid(.flexible())GeometryReader math
Rows of tagsLayout protocol or ViewThatFitsManual width arithmetic
One measured dimensionGeometryReader inside a backgroundWrapping the whole screen
Sidebar on iPadNavigationSplitViewTwo separate screens

GeometryReader is greedy: it expands to the space offered and reports it through the closure. Put it inside a .background when you only need a measurement, so it does not change your real layout.

Safe areas, size classes and text scaling

struct ReaderView: View {
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        ScrollView {
            Text(bodyText)
                .font(.body)
                .dynamicTypeSize(...DynamicTypeSize.accessibility3)
                .padding(.horizontal, sizeClass == .regular ? 48 : 16)
        }
        .safeAreaInset(edge: .bottom) {
            Button("Next") { advance() }
                .frame(maxWidth: .infinity)
                .padding()
                .background(.bar)
        }
    }
}
💡
Test every screen at the smallest text size and at accessibility5. Fixed-height rows and single-line labels are the two things that break first, and both are trivially reproducible in the Xcode preview canvas.

FAQ

When should I use <code>NavigationSplitView</code>?
Whenever the content benefits from a persistent sidebar on a regular-width device. It collapses to a stack automatically in compact width, so you write one layout instead of branching on device type.
Why does my text get clipped with larger accessibility sizes?
A fixed frame(height:) or a single-line label cannot grow. Remove the height constraint or set lineLimit(nil) and let the row size itself.

Lists, forms and data-driven views Localization, accessibility and system integration

Last refreshed 2026-09-18.