Grids
Contents
- Intent
- Choosing the right grid type
- Column strategies
- Cell sizing and aspect ratio
- Example: adaptive icon grid
- Example: fixed 3-column media grid
- Example: sectioned grid
- Selection and interaction patterns
- Performance guardrails
- Accessibility and polish
- Pitfalls
Intent
Use grids for dense visual collections where row-based layouts waste space or make scanning harder.
Good fits:
- icon pickers
- media galleries
- template choosers
- settings tiles
- dashboards with repeatable cards
Default to LazyVGrid for vertically scrolling collections on iOS. Reach for
Grid when content is small and non-scrollable, or when you need explicit row
composition rather than a large lazy container.
Choosing the right grid type
LazyVGrid
Use for the common iPhone/iPad case: many items, vertical scrolling, and a column definition that should adapt to width.
LazyHGrid
Use when horizontal scrolling is the dominant interaction and rows are easier to define than columns.
Grid
Use when:
- the item count is small
- the content is mostly static
- row/column relationships matter more than lazy loading
- you need
GridRowcomposition instead of a repeated collection layout
For large scrolling datasets, LazyVGrid is usually the safer default.
Column strategies
Adaptive columns
Use .adaptive when you want the number of columns to respond to available
width.
let columns = [GridItem(.adaptive(minimum: 120, maximum: 240))]This is the best default for icon pickers, template choosers, and photo grids that should scale naturally across iPhone and iPad sizes.
Flexible columns
Use multiple .flexible columns when you want a predictable column count.
let columns = [
GridItem(.flexible(minimum: 100)),
GridItem(.flexible(minimum: 100)),
GridItem(.flexible(minimum: 100)),
]This works well when design requires a fixed 2-column or 3-column rhythm.
Fixed columns
Use .fixed only when the design truly requires exact widths. Fixed columns are
less resilient across device sizes and dynamic type changes.
Cell sizing and aspect ratio
Grid cells should usually define their own shape without reading parent geometry.
Preferred sizing tools:
.aspectRatio(1, contentMode: .fit)for square thumbnails.frame(maxWidth: .infinity)when content should fill the available column- internal padding instead of outer geometry math
Avoid GeometryReader inside lazy containers. It defeats lazy layout and adds
extra measurement work.
Example: adaptive icon grid
let columns = [GridItem(.adaptive(minimum: 120, maximum: 240))]
LazyVGrid(columns: columns) {
ForEach(icons) { icon in
Button {
select(icon)
} label: {
ZStack(alignment: .bottomTrailing) {
Image(icon.previewName)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(.rect(cornerRadius: 6))
if icon.isSelected {
Image(systemName: "checkmark.seal.fill")
.padding(4)
.tint(.green)
}
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
}Why this works:
- adaptive columns scale across width classes
- square-ish cells come from image aspect ratio, not geometry reads
- the whole tile remains tappable
Example: fixed 3-column media grid
LazyVGrid(
columns: [
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
],
spacing: 4
) {
ForEach(items) { item in
ThumbnailView(item: item)
.aspectRatio(1, contentMode: .fit)
.clipShape(.rect(cornerRadius: 8))
}
}Use this when the design language wants a consistent three-up gallery instead of an adaptive count.
Example: sectioned grid
Sectioned grids work well for grouped content like categories or recents.
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))]) {
ForEach(sections) { section in
Section {
ForEach(section.items) { item in
Tile(item: item)
}
} header: {
Text(section.title)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top)
}
}
}Keep headers visually lightweight. Dense grids lose scan efficiency when every section header is oversized or heavily decorated.
Selection and interaction patterns
Grid interactions should stay obvious and forgiving.
Useful defaults:
- make the entire cell tappable with
.contentShape(Rectangle()) - show selection state in one consistent corner or border treatment
- keep hover/focus/pressed states subtle but visible
- avoid stacking too many overlays on every item
For multi-select grids, prefer clear selection affordances instead of hidden state changes triggered only by long press.
Performance guardrails
- Use
LazyVGridfor large collections. - Keep overlay count low in every cell.
- Downsample large images before display.
- Avoid expensive formatting or filtering in cell bodies.
- Prefer stable item identity in
ForEach. - Precompute grouped or filtered collections before rendering.
If scrolling stutters, profile image decoding, per-cell overlays, and repeated state invalidation before changing the grid structure itself.
Accessibility and polish
- Maintain clear visual grouping and consistent spacing.
- Test with Dynamic Type even if cells are primarily visual.
- Ensure VoiceOver labels describe the item and selection state.
- Use meaningful focus order on iPad and keyboard-driven flows.
- Avoid tiny hit targets even when cells are visually dense.
A dense grid should still feel calm and legible, not like a wall of competing badges and borders.
Pitfalls
- Avoid heavy overlays in every grid cell; it can be expensive.
- Don’t nest grids inside other grids without a clear reason.
- Don’t put filtering logic inline in
ForEach. - Don’t use fixed columns where adaptive columns better match the product goal.
- Never place
GeometryReaderinside lazy containers (LazyVGrid,LazyHGrid,LazyVStack,LazyHStack). It forces eager measurement and defeats lazy loading. Use.aspectRatiofor sizing, or.onGeometryChangeif you need to read dimensions. The single-new-value overload is iOS 16+; the old/new-value action overload is iOS 18+.