SwiftUI Grid Layout: LazyVGrid and LazyHGrid Production Patterns
Production patterns for responsive SwiftUI grids, including GridItem sizing, stable identity, pinned headers, pagination, and performance measurement.
SwiftUI grids are a natural fit for photo libraries, dashboards, product catalogs, and other data-heavy screens. The layout code is short; the production decisions are not. Column sizing, identity, image loading, parent-state changes, and pagination determine whether the screen remains responsive as its dataset grows.
This guide focuses on the parts that need deliberate engineering: choosing the right grid, configuring GridItem, preserving stable identity, building responsive layouts, and measuring performance on the devices you support.
Choose the Container Before Tuning the Cells
LazyVGrid arranges children in columns and grows vertically. LazyHGrid arranges children in rows and grows horizontally. Both create children as SwiftUI needs them for display rather than creating the entire collection immediately.
That does not make a lazy grid automatically better than Grid. Apple notes that a regular Grid creates all children immediately but provides stronger cell spacing and alignment behavior. Use a lazy grid when the screen scrolls through enough content that eager child creation is a measured cost. For a small settings matrix or comparison table, Grid may be simpler and more predictable.
The common scrolling patterns are:
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
content
}
}
ScrollView(.horizontal) {
LazyHGrid(rows: rows, spacing: 12) {
content
}
}
.frame(height: 188)
A horizontal grid embedded in a vertical screen usually needs a deliberate height. Without a useful constraint, the parent has insufficient information to size the horizontal region consistently.
Understand GridItem Sizing
Every lazy grid starts with an array of GridItem values. The three sizing modes solve different problems.
Fixed
GridItem(.fixed(120))
A fixed item stays 120 points wide. It is useful for calendar cells, icon matrices, or layouts where exact alignment matters more than adapting to the container. Fixed sizes still need testing with Dynamic Type and localized content.
Flexible
GridItem(.flexible(minimum: 100, maximum: 260))
A flexible item shares available space while respecting its bounds. Use multiple flexible items when the design requires a known column count:
private let columns = [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
]
Adaptive
GridItem(.adaptive(minimum: 150, maximum: 220), spacing: 12)
An adaptive item asks SwiftUI to fit as many columns as the available width allows. This works well for resizable iPad and Mac windows because it responds to the container rather than only to a device class.
Do not combine adaptive and fixed items casually. Lazy-grid width calculation can be surprising when different sizing rules compete. If the layout has a fixed leading column and flexible detail columns, test the narrowest and widest supported containers.
Responsive Columns Should Follow Available Space
Size classes distinguish broad interface categories, but they do not describe every width produced by iPad multitasking, Stage Manager, or resizable Mac windows. Prefer adaptive columns when a minimum readable card width is the real requirement:
private let columns = [
GridItem(.adaptive(minimum: 168, maximum: 240), spacing: 16)
]
Use size classes when the design genuinely changes modes rather than merely changing column count:
struct ResultsGrid: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let items: [Result]
private var columns: [GridItem] {
let count = horizontalSizeClass == .compact ? 2 : 4
return Array(
repeating: GridItem(.flexible(), spacing: 12),
count: count
)
}
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(items) { item in
ResultCard(item: item)
}
}
}
}
If content size category, localization, or window resizing can make a card unreadable, switch to fewer columns rather than compressing the content indefinitely.
Keep Spacing Ownership Clear
The spacing passed to LazyVGrid controls spacing between rows. Spacing on each GridItem contributes to spacing between columns. Keep outer padding at the grid boundary and avoid duplicating it inside every card:
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(items) { item in
ItemCard(item: item)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
This makes edge spacing predictable and leaves each cell responsible for its own internal content padding.
Preserve Stable Identity
Identity is central to correct updates, animations, focus, and scroll behavior. Use a model identifier that remains stable for the lifetime of the item:
struct Result: Identifiable {
let id: UUID
let title: String
}
ForEach(items) { item in
ResultCard(item: item)
}
Avoid generating a new UUID inside a computed id, and avoid using an array index when items can be inserted, removed, or reordered. Index identity describes a position, not the underlying record. When positions change, SwiftUI can associate the wrong view state with an item or rebuild more cells than expected.
Keep Expensive Work Out of the Render Path
Lazy creation reduces the number of live child views; it does not make expensive cell bodies free. Avoid synchronous disk reads, decoding, image resizing, database work, or model inference in body and cell initializers.
Prepare display data before it reaches the cell. Load images asynchronously through a cache that supports cancellation and size-aware decoding. Keep ownership of long-lived loading state above transient cells when scrolling away and back should not restart the work.
Be cautious with blanket .equatable() usage. It helps only when equality accurately represents everything that should trigger a visual update. Measure invalidations before adding it.
Use Pinned Headers for Grouped Data
Pinned headers preserve context in long grouped collections:
ScrollView {
LazyVGrid(
columns: columns,
spacing: 12,
pinnedViews: [.sectionHeaders]
) {
ForEach(groups) { group in
Section {
ForEach(group.items) { item in
ItemCard(item: item)
}
} header: {
SectionHeader(title: group.title)
}
}
}
}
Give the header an opaque or material background. A transparent pinned header allows scrolling cells to remain visible underneath, reducing readability.
Add Pagination Without Duplicate Requests
An onAppear trigger near the end of the collection is a practical pagination mechanism, but it must be guarded. Cells can appear more than once during navigation, layout changes, or state updates.
ForEach(items) { item in
ItemCard(item: item)
.task {
if item.id == items.suffix(5).first?.id {
await model.loadNextPageIfNeeded()
}
}
}
The model should reject a request when one is already in progress, stop after the final page, and deduplicate records by stable ID. Display loading and retry states outside the normal record identity so a failed request does not look like an empty dataset.
Empty, Loading, and Error States Are Part of the Layout
Use redacted placeholder cards when maintaining the expected geometry helps users understand that content is loading:
LazyVGrid(columns: columns, spacing: 16) {
ForEach(placeholders) { placeholder in
ItemCard(item: placeholder)
.redacted(reason: .placeholder)
.allowsHitTesting(false)
}
}
An empty-state message generally belongs outside the grid so it can use the full container width. The same applies to a prominent error and retry action.
Profile the Whole Update Chain
When scrolling stutters, the cell is not always responsible. A frequently changing parent value can invalidate the entire grid. Image decoding, observation scope, database fetches, geometry feedback loops, and animation modifiers are common sources of work.
Use Instruments and Xcode's SwiftUI performance tools on representative hardware and realistic data. Test:
- initial load and fast scrolling;
- insertion, deletion, filtering, and reordering;
- large accessibility text sizes;
- iPad split-screen and window resizing;
- slow image or database loading;
- memory behavior after repeated navigation.
There is no meaningful universal item-count threshold at which a lazy grid becomes faster. Cell complexity, image cost, update frequency, and device hardware matter more than a fixed number.
Architecture That Scales
A maintainable grid screen separates responsibilities:
- the model or view model owns fetching, filtering, pagination, and prepared display values;
- the grid view owns layout configuration and presentation state;
- the cell owns the visual representation of one stable item;
- services own image loading, persistence, and other expensive operations.
That boundary makes layout changes testable and prevents network, database, or inference work from leaking into SwiftUI's render path. For the broader view hierarchy and state-management decisions, see the SwiftUI production architecture guide.
Build Grids That Hold Up Under Real Data
The strongest SwiftUI grid is not the one with the most modifiers. It is the one whose sizing follows available space, whose identities remain stable, and whose expensive work happens outside rendering. Choose Grid, LazyVGrid, or LazyHGrid from the screen's measured requirements, then validate the result with real content and real devices.