Grids and Lazy Layouts in SwiftUI: `LazyVGrid`, `LazyHGrid`, and Custom Layouts


A scrollable Pixar movie poster grid, a Pinterest-style masonry layout for fan art, an adaptive card collection that adjusts from two to four columns as the window widens on iPad — these are all solvable with SwiftUI’s grid and layout APIs. But choosing the wrong tool for the job means either fighting the framework or leaving performance on the table.

This post covers LazyVGrid, LazyHGrid, the non-lazy Grid view, and the iOS 16+ Layout protocol for fully custom layout algorithms. We won’t cover UICollectionView bridging or compositional layouts — this is entirely UIKit-free.

Contents

The Problem

Imagine you’re building a Pixar movie browser. The natural instinct is to reach for LazyVStack:

// ❌ LazyVStack — items don't align horizontally
ScrollView {
    LazyVStack(spacing: 16) {
        ForEach(pixarFilms) { film in
            FilmCard(film: film)
                .frame(maxWidth: .infinity)
        }
    }
    .padding()
}

This works for a single-column layout, but it can’t produce multiple columns. Each card takes the full available width. You could hardcode two LazyVStacks side by side, but then you lose lazy loading, break the natural reading order, and have to manually distribute items across columns. Heights won’t match, and adding a section header that spans both columns requires gymnastics.

The grid APIs exist precisely to solve this.

LazyVGrid and LazyHGrid

LazyVGrid is a scrollable vertical grid that lays out its children in columns. LazyHGrid does the same horizontally. Both are lazy — items are only created and rendered when they scroll into the visible region.

The layout is controlled by an array of GridItem values.

GridItem Types

Three sizing modes cover most real-world needs:

// .fixed — exact pixel width, regardless of screen size
GridItem(.fixed(160))

// .flexible — fills available space, with optional min/max constraints
GridItem(.flexible(minimum: 100, maximum: 300))

// .adaptive — creates as many columns as fit within the minimum width
GridItem(.adaptive(minimum: 150, maximum: 240))

The practical difference:

  • .fixed is for grids where every column must be exactly the same width — icon grids, calendar cells.
  • .flexible is for grids where you dictate the number of columns and let SwiftUI fill the space.
  • .adaptive is for responsive layouts that should show more columns on larger screens without any code change.

Adaptive Movie Poster Grid

For a Pixar film browser that adjusts from two columns on iPhone to four on iPad, .adaptive is the right choice:

struct PixarFilmGridView: View {
    let films: [PixarFilm]

    private let columns = [
        GridItem(.adaptive(minimum: 160, maximum: 240), spacing: 12)
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 16) {
                ForEach(films) { film in
                    FilmPosterCard(film: film)
                }
            }
            .padding(.horizontal, 16)
        }
    }
}

struct FilmPosterCard: View {
    let film: PixarFilm

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            AsyncImage(url: film.posterURL) { image in
                image.resizable().aspectRatio(2/3, contentMode: .fill)
            } placeholder: {
                Rectangle().fill(Color.secondary.opacity(0.2))
                    .aspectRatio(2/3, contentMode: .fit)
            }
            .clipShape(RoundedRectangle(cornerRadius: 8))

            Text(film.title)
                .font(.subheadline).bold()
                .lineLimit(1)

            Text(String(film.year))
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }
}

A single GridItem(.adaptive(minimum: 160)) declaration handles every device and orientation automatically. No @Environment(\.horizontalSizeClass) conditionals needed.

Fixed-Column Grid

When you want exactly two columns — common for settings screens, dashboard tiles, or comparison layouts — use an array of .flexible items:

struct PixarAwardGridView: View {
    let awards: [PixarAward]

    private let columns = [
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(awards) { award in
                    AwardBadgeView(award: award)
                        .aspectRatio(1, contentMode: .fit)
                }
            }
            .padding()
        }
    }
}

Two .flexible() items split the available width equally. Add a third to get a three-column grid.

Sections and Pinned Headers

LazyVGrid supports sections with pinned headers — the header sticks to the top of the scroll view as you scroll through the section, exactly like the iOS Photos app:

struct PixarFilmsByEraView: View {
    let eras: [PixarEra]

    private let columns = [GridItem(.adaptive(minimum: 150))]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, pinnedViews: [.sectionHeaders]) {
                ForEach(eras) { era in
                    Section {
                        ForEach(era.films) { film in
                            FilmPosterCard(film: film)
                        }
                    } header: {
                        Text(era.name)
                            .font(.title2).bold()
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(.vertical, 8)
                            .background(.regularMaterial) // blur effect
                    }
                }
            }
            .padding(.horizontal)
        }
    }
}

The .sectionHeaders value in pinnedViews enables the sticky behavior. You can also pin .sectionFooters.

The Grid View (iOS 16+)

LazyVGrid is great for uniform content collections, but it cannot align content across rows. If column 1 of row 3 needs to align with column 1 of row 5, LazyVGrid can’t do that — each row is independent.

iOS 16 introduced Grid and GridRow for exactly this purpose. Grid is not lazy — all children are created upfront — but it performs true two-dimensional alignment.

@available(iOS 16.0, *)
struct PixarFilmTableView: View {
    let films: [PixarFilm]

    var body: some View {
        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
            // Header row
            GridRow {
                Text("Film").fontWeight(.bold)
                Text("Year").fontWeight(.bold)
                Text("Director").fontWeight(.bold)
                Text("Rating").fontWeight(.bold)
            }
            .foregroundStyle(.secondary)

            Divider()
                .gridCellUnsizedAxes(.horizontal) // span full width

            ForEach(films) { film in
                GridRow {
                    Text(film.title)
                    Text(String(film.year))
                    Text(film.director)
                    HStack(spacing: 2) {
                        Image(systemName: "star.fill")
                            .foregroundStyle(.yellow)
                            .font(.caption)
                        Text(String(format: "%.1f", film.rating))
                    }
                }
            }
        }
        .padding()
    }
}

.gridCellUnsizedAxes(.horizontal) tells Divider to span the full grid width rather than being constrained to one column. This is a Grid-specific modifier — it has no effect outside a Grid context.

Column Spanning

A cell can span multiple columns with .gridCellColumns(_:):

@available(iOS 16.0, *)
GridRow {
    Text("Pixar Animation Studios")
        .font(.caption)
        .foregroundStyle(.tertiary)
        .gridCellColumns(4) // span all 4 columns
}

Note: Grid renders all its children eagerly. For large data sets (hundreds of rows), prefer LazyVGrid or a List instead. Grid shines for small, alignment-critical tables — think 5–30 rows.

The Layout Protocol (iOS 16+)

When neither LazyVGrid nor Grid fits your needs, the iOS 16 Layout protocol lets you implement a fully custom layout algorithm. You provide the math; SwiftUI handles the view lifecycle, animations, and transitions.

Two methods are required:

@available(iOS 16.0, *)
protocol Layout: Animatable {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    )
}
  • sizeThatFits is called to determine how much space the container needs, given a proposed size.
  • placeSubviews is called to assign final positions to each child.

Building a Waterfall (Masonry) Layout

Pinterest-style masonry layouts — where items fill the shortest column — require tracking column heights and can’t be expressed with any built-in grid type. The Layout protocol handles this cleanly:

@available(iOS 16.0, *)
struct WaterfallLayout: Layout {
    var columns: Int = 2
    var spacing: CGFloat = 12

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let width = proposal.width ?? 300
        let columnWidth = (width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
        var columnHeights = [CGFloat](repeating: 0, count: columns)

        for subview in subviews {
            let shortestColumn = columnHeights.indices.min(by: {
                columnHeights[$0] < columnHeights[$1]
            }) ?? 0
            let childSize = subview.sizeThatFits(
                ProposedViewSize(width: columnWidth, height: nil)
            )
            columnHeights[shortestColumn] += childSize.height + spacing
        }

        let totalHeight = columnHeights.max() ?? 0
        return CGSize(width: width, height: totalHeight)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let columnWidth = (bounds.width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
        var columnHeights = [CGFloat](repeating: bounds.minY, count: columns)

        for subview in subviews {
            let shortestColumn = columnHeights.indices.min(by: {
                columnHeights[$0] < columnHeights[$1]
            }) ?? 0

            let x = bounds.minX + CGFloat(shortestColumn) * (columnWidth + spacing)
            let y = columnHeights[shortestColumn]

            let childSize = subview.sizeThatFits(
                ProposedViewSize(width: columnWidth, height: nil)
            )

            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .topLeading,
                proposal: ProposedViewSize(width: columnWidth, height: childSize.height)
            )

            columnHeights[shortestColumn] += childSize.height + spacing
        }
    }
}

Usage is identical to any other SwiftUI container:

@available(iOS 16.0, *)
struct PixarFanArtGallery: View {
    let artworks: [FanArtwork]

    var body: some View {
        ScrollView {
            WaterfallLayout(columns: 2, spacing: 12) {
                ForEach(artworks) { artwork in
                    FanArtCard(artwork: artwork)
                }
            }
            .padding()
        }
    }
}

Because Layout conforms to Animatable, layout changes (like switching from 2 to 3 columns) animate automatically when wrapped in withAnimation.

Per-Subview Configuration with LayoutValueKey

The LayoutValueKey protocol lets individual subviews pass custom data to the parent layout — like a priority value that controls placement order:

@available(iOS 16.0, *)
struct FilmPriorityKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

extension View {
    func filmPriority(_ priority: Int) -> some View {
        layoutValue(key: FilmPriorityKey.self, value: priority)
    }
}

// Inside WaterfallLayout.placeSubviews:
// let priority = subview[FilmPriorityKey.self]
// Use priority to influence placement logic

Advanced Usage

Animated Layout Transitions

Switching between layout strategies with animation is a first-class feature of the Layout protocol. AnyLayout allows holding a layout type as a value:

@available(iOS 16.0, *)
struct AdaptiveFilmGallery: View {
    @State private var useWaterfall = false

    private var columns: [GridItem] {
        [GridItem(.adaptive(minimum: 150))]
    }

    var layout: AnyLayout {
        useWaterfall
            ? AnyLayout(WaterfallLayout(columns: 2))
            : AnyLayout(VStackLayout(spacing: 16))
    }

    var body: some View {
        ScrollView {
            layout {
                ForEach(pixarFilms) { film in
                    FilmPosterCard(film: film)
                }
            }
        }
        .toolbar {
            Button(useWaterfall ? "List" : "Waterfall") {
                withAnimation(.spring()) { useWaterfall.toggle() }
            }
        }
    }
}

Note: AnyLayout is the type-erased wrapper that allows swapping Layout-conforming algorithms at runtime. Note that LazyVGrid does not conform to Layout, so it cannot be wrapped in AnyLayout. Only types conforming to the Layout protocol — such as VStackLayout, HStackLayout, GridLayout, or your own custom layouts — can be used here.

scrollTargetLayout and scrollTargetBehavior

iOS 17 added scroll snapping APIs that pair well with grids. Combine scrollTargetLayout with scrollTargetBehavior(.viewAligned) to snap the scroll to individual cards:

@available(iOS 17.0, *)
struct SnappingFilmGrid: View {
    let films: [PixarFilm]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHGrid(rows: [GridItem(.fixed(200))], spacing: 12) {
                ForEach(films) { film in
                    FilmPosterCard(film: film)
                        .frame(width: 140)
                        .containerRelativeFrame(.horizontal) // iOS 17
                }
            }
            .scrollTargetLayout() // iOS 17 — declares children as scroll targets
        }
        .scrollTargetBehavior(.viewAligned) // iOS 17 — snap to target views
        .contentMargins(.horizontal, 20, for: .scrollContent)
    }
}

Performance Considerations

LazyVGrid and LazyHGrid only instantiate views that are near the visible scroll area — typically within one screen height above and below. This makes them suitable for collections with thousands of items.

Grid is fully eager. Every GridRow is created immediately, regardless of scroll position. For table-style data with 50+ rows visible at once, this matters.

The Layout protocol is also eager — all subviews are created before sizeThatFits is called, because the layout algorithm needs to query their sizes. If you need laziness with a custom layout algorithm, you’ll need to combine Layout with a data windowing technique (only passing the visible slice of your data array).

LayoutLazy?Best for
LazyVGridYesLarge scrollable collections
LazyHGridYesHorizontal carousels
GridNoSmall alignment-critical tables
Custom LayoutNo (all subviews created)Masonry, circular, radial arrangements

Apple Docs: Layout — SwiftUI (iOS 16+)

When to Use (and When Not To)

ScenarioRecommendation
Scrollable collection, uniform card sizesLazyVGrid with .adaptive GridItem
Horizontal carouselLazyHGrid with .fixed or .flexible items
Table with aligned columns (small data set)Grid with GridRow
Large table (50+ rows)List or LazyVGridGrid is eager
Masonry / Pinterest-style variable heightsCustom Layout conformance
Layout that switches between modes with animationAnyLayout wrapping the current layout type
Scroll snapping to individual itemsAdd scrollTargetLayout + scrollTargetBehavior (iOS 17+)

Summary

  • LazyVGrid and LazyHGrid use GridItem to define column/row sizing: .fixed for exact widths, .flexible for proportional, .adaptive for responsive multi-column layouts.
  • Grid + GridRow (iOS 16+) provides true two-dimensional alignment for table-style layouts, at the cost of eagerly loading all rows.
  • The Layout protocol (iOS 16+) lets you write any arrangement algorithm — masonry, radial, priority-based — using sizeThatFits and placeSubviews.
  • AnyLayout enables animated transitions between different layout strategies at runtime.
  • Lazy layouts (LazyVGrid/LazyHGrid) are essential for large collections; Grid and custom Layout are eager, so limit their use to smaller data sets.

Grid layouts are closely related to overall SwiftUI rendering performance — once you’re building complex adaptive grids, understanding when views re-render is critical. See SwiftUI Performance: Identifying and Fixing Unnecessary View Redraws for the profiling workflow.