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
LazyVGridandLazyHGrid- The
GridView (iOS 16+) - The
LayoutProtocol (iOS 16+) - Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
.fixedis for grids where every column must be exactly the same width — icon grids, calendar cells..flexibleis for grids where you dictate the number of columns and let SwiftUI fill the space..adaptiveis 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:
Gridrenders all its children eagerly. For large data sets (hundreds of rows), preferLazyVGridor aListinstead.Gridshines 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 ()
)
}
sizeThatFitsis called to determine how much space the container needs, given a proposed size.placeSubviewsis 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:
AnyLayoutis the type-erased wrapper that allows swappingLayout-conforming algorithms at runtime. Note thatLazyVGriddoes not conform toLayout, so it cannot be wrapped inAnyLayout. Only types conforming to theLayoutprotocol — such asVStackLayout,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).
| Layout | Lazy? | Best for |
|---|---|---|
LazyVGrid | Yes | Large scrollable collections |
LazyHGrid | Yes | Horizontal carousels |
Grid | No | Small alignment-critical tables |
Custom Layout | No (all subviews created) | Masonry, circular, radial arrangements |
Apple Docs:
Layout— SwiftUI (iOS 16+)
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Scrollable collection, uniform card sizes | LazyVGrid with .adaptive GridItem |
| Horizontal carousel | LazyHGrid with .fixed or .flexible items |
| Table with aligned columns (small data set) | Grid with GridRow |
| Large table (50+ rows) | List or LazyVGrid — Grid is eager |
| Masonry / Pinterest-style variable heights | Custom Layout conformance |
| Layout that switches between modes with animation | AnyLayout wrapping the current layout type |
| Scroll snapping to individual items | Add scrollTargetLayout + scrollTargetBehavior (iOS 17+) |
Summary
LazyVGridandLazyHGriduseGridItemto define column/row sizing:.fixedfor exact widths,.flexiblefor proportional,.adaptivefor 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
Layoutprotocol (iOS 16+) lets you write any arrangement algorithm — masonry, radial, priority-based — usingsizeThatFitsandplaceSubviews. AnyLayoutenables animated transitions between different layout strategies at runtime.- Lazy layouts (
LazyVGrid/LazyHGrid) are essential for large collections;Gridand customLayoutare 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.