civil-and-structural-engineering
How to Create a Dynamic Collection View Layout in Swiftui
Table of Contents
Introduction to Dynamic Collection Views in SwiftUI
Modern iOS apps demand fluid, adaptive interfaces that respond gracefully to different data sizes, device orientations, and screen dimensions. In UIKit, collection views provide a powerful but verbose mechanism for building grid-like layouts. SwiftUI simplifies this dramatically with its declarative API, particularly through LazyVGrid and LazyHGrid. These components let developers define dynamic, resizable grids with minimal code, making it easier to build production-ready UIs that work across iPhones, iPads, and even Mac Catalyst targets.
A dynamic collection view layout adapts its column count, row height, spacing, or sizing behavior based on the available container width, the number of items, or the content itself. This article will guide you through building such layouts in SwiftUI, covering everything from basic grid setup to advanced adaptive behaviors, performance tuning, and integration with animations. By the end, you’ll have a solid foundation for creating flexible, engaging, and performant collection views in your own apps.
Core Components: LazyVGrid and LazyHGrid
SwiftUI provides two primary grid containers: LazyVGrid (vertical scrolling grid) and LazyHGrid (horizontal scrolling grid). Both lazily load their children, meaning the views are created only as they become visible on screen – a crucial feature for handling large data sets without memory bloat. These containers are analogous to UICollectionView in UIKit, minus the boilerplate of data source and delegate protocols.
GridItem Configuration
To define a grid’s structure, you create an array of GridItem instances. Each GridItem represents a single column (in LazyVGrid) or a single row (in LazyHGrid). You control three key properties:
- Size – One of three sizing modes:
.fixed(CGFloat),.flexible(minimum:maximum:), or.adaptive(minimum:maximum:). - Spacing – Horizontal spacing between items in the same row (for vertical grids) or vertical spacing (for horizontal grids).
- Alignment – Controls how items align within a grid cell. Rarely changed from defaults.
The choice of sizing mode defines the overall layout flexibility:
- Fixed – Explicit width (or height) for every column/row. Useful when you need exact sizing, e.g., a three‑column layout of fixed 100‑point cells.
- Flexible – Each grid item can stretch to fill available space, with optional minimum and maximum bounds. This is the most common choice for dynamic, evenly‑spaced columns.
- Adaptive – The grid automatically determines how many items fit per column row based on a specified minimum size. This is the simplest way to make a dynamic layout that responds to screen width.
For example, to create a grid that automatically fits as many columns as possible, each with a minimum width of 100 points, you would write:
let columns = [
GridItem(.adaptive(minimum: 100))
]
This single line yields a layout that adapts from one column on a narrow iPhone to several columns on an iPad in landscape – exactly the behavior we want for dynamic layouts.
Building a Basic Dynamic Grid
Let’s start with a simple, fully dynamic grid using .adaptive sizing. Below is a complete view that displays an array of strings in a responsive grid:
struct AdaptiveGridView: View {
let items = Array(1...20).map { "Item \($0)" }
var body: some View {
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 80), spacing: 12)],
spacing: 12
) {
ForEach(items, id: \.self) { item in
Text(item)
.frame(minWidth: 80, maxWidth: .infinity, minHeight: 80)
.background(Color.blue.opacity(0.2))
.cornerRadius(8)
}
}
.padding()
}
}
}
Here, each item is forced to at least 80 points wide and tall. The .adaptive grid item then automatically calculates how many items can fit per row given the available width, respecting the minimum width. The result is a layout that reflows seamlessly when the device rotates or the window is resized (on iPad or Mac).
This approach works well for simple content, but for more control you often need to combine .adaptive with .flexible items, or use GeometryReader to compute custom column counts.
Using GeometryReader for Precise Dynamic Column Counts
While .adaptive handles many cases, sometimes you want to set an exact number of columns based on the container width – for example, always 2 columns on an iPhone in portrait, 3 in landscape, and 4 on iPad. The GeometryReader view provides the current size of its parent, enabling you to calculate the optimal column count dynamically.
Example: An Adaptive Column Count
The following view reads the available width and computes a column count based on a target item width:
struct DynamicColumnGridView: View {
let items = Array(1...30).map { "Item \($0)" }
let targetItemWidth: CGFloat = 120
var columns: [GridItem] {
let count = max(Int(geometryProxy.size.width / targetItemWidth), 1)
return Array(repeating: GridItem(.flexible(), spacing: 12), count: count)
}
var body: some View {
GeometryReader { geometry in
ScrollView {
let width = geometry.size.width
let count = max(Int(width / targetItemWidth), 1)
let columns = Array(repeating: GridItem(.flexible(), spacing: 12), count: count)
LazyVGrid(columns: columns, spacing: 12) {
ForEach(items, id: \.self) { item in
Text(item)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 80)
.background(Color.green.opacity(0.3))
.cornerRadius(8)
}
}
.padding()
}
}
}
}
Important: In practice, you must store the GeometryProxy value inside a variable or use it within the same closure. The above example uses a helper variable inside the closure for clarity. Always ensure you don’t wrap the entire body in GeometryReader unnecessarily – it can break the layout if used as the topmost view.
This technique gives you full control over the number of columns while keeping items flexible so they fill the width evenly. You can also add minimum and maximum constraints to the flexible items to avoid extremely stretched or squashed cells.
Adapting Row Heights Dynamically
Dynamic layouts aren’t just about columns – row heights can also vary based on content. SwiftUI doesn’t provide a direct “automatic sizing” grid like UIKit’s UICollectionViewFlowLayout with estimated sizes. However, you can achieve similar results using intrinsic content size and .fixedSize() or by embedding data-driven heights.
For instance, you can make each grid cell self‑size by omitting explicit heights and letting the internal content determine the size. SwiftUI will automatically calculate the cell height based on the tallest item in each row (for vertical grids). But if you need truly variable row heights (like a masonry layout), you need a custom approach – often using LazyVStack with custom alignment guides or wrapping the grid in a LazyVGrid but with different heights per column. That’s more advanced and beyond the scope of this article, but the key takeaway is that LazyVGrid assumes all cells in a row share the same height (determined by the tallest cell). If your design requires variable heights, consider using a LazyVStack with a custom grid layout.
Handling Large Data Sets with Lazy Loading
One of the main advantages of LazyVGrid and LazyHGrid is their lazy loading behavior. They only create views for the items that are currently visible (plus a small buffer). This makes them efficient for data sets with hundreds or even thousands of items. However, there are a few best practices to maintain smooth performance:
- Use stable identifiers – Always provide a unique
idparameter toForEach. For struct data models that conform toIdentifiable, SwiftUI can diff efficiently. - Avoid expensive computations inside the body – Keep cell views simple. If you need complex layout calculations, precompute them outside the grid.
- Leverage
.equatable()– If your cell view conforms toEquatableViewor uses the.equatable()modifier, SwiftUI can skip rendering changes when the cell’s data hasn’t changed. - Use
LazyVGridinside aScrollView– If you nest aLazyVGridinside another lazy container, you may break lazy loading. Typically, the grid itself should be the direct child of aScrollView(or be wrapped inside one).
Combining Dynamic Layouts with Animations
Animations in SwiftUI can make layout changes feel natural and polished. For example, when the number of columns changes (due to rotation or window resize), you can animate the grid items to their new positions. The easiest way is to wrap the grid in an Animation modifier or use the .animation() modifier with a value that triggers change.
Animating Column Changes
Here’s an extension of the earlier dynamic column example with an animated transition:
struct AnimatedDynamicGridView: View {
let items = Array(1...20).map { "Item \($0)" }
@State private var targetItemWidth: CGFloat = 120
var body: some View {
GeometryReader { geometry in
ScrollView {
let width = geometry.size.width
let count = max(Int(width / targetItemWidth), 1)
let columns = Array(repeating: GridItem(.flexible(), spacing: 12), count: count)
LazyVGrid(columns: columns, spacing: 12) {
ForEach(items, id: \.self) { item in
Text(item)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 80)
.background(Color.orange.opacity(0.4))
.cornerRadius(8)
.transition(.scale.combined(with: .opacity))
}
}
.padding()
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: count)
}
}
}
}
By tying the animation to the count value, SwiftUI will animate the insertion, removal, and rearrangement of items when the number of columns changes. You can also use .matchedGeometryEffect for more advanced transitions between layouts.
Practical Examples: Real‑World Dynamic Layouts
Let’s walk through two common scenarios that benefit from dynamic collection view layouts.
Photo Gallery Grid
A typical photo gallery uses a square cell grid that adapts to the screen width. You want the cells to be as large as possible while keeping a fixed number of columns (e.g., 2 on iPhone, 3 on iPhone Plus, 4 on iPad). Using GeometryReader with a conditional column count based on size classes is straightforward:
struct PhotoGalleryGrid: View {
let images: [String]
@Environment(\.horizontalSizeClass) var sizeClass
var columns: [GridItem] {
let count = sizeClass == .compact ? 2 : 4
return Array(repeating: GridItem(.flexible(), spacing: 2), count: count)
}
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(images, id: \.self) { imageName in
Image(imageName)
.resizable()
.aspectRatio(1, contentMode: .fill)
.clipped()
}
}
.padding(.horizontal, 2)
}
}
}
Dynamic Dashboard with Varied Cell Sizes
For a dashboard, you might have cells of different widths – e.g., a large graph taking up two columns, smaller stats taking one column each. You can achieve this by combining .flexible items with explicit multipliers using a custom GridItem array. For example:
let columns: [GridItem] = [
GridItem(.flexible(minimum: 80), spacing: 12), // small stat cell
GridItem(.flexible(minimum: 160), spacing: 12), // wide cell
GridItem(.flexible(minimum: 80), spacing: 12) // small stat cell
]
Then, in the grid’s ForEach, you can map different data types to different column spans by wrapping items in a container that expands to fill the grid width. This requires careful coordination because LazyVGrid doesn’t natively support multi‑column spans. A common workaround is to use .gridCellColumns() modifier (available in iOS 16+) or manually index the grid by splitting your data into sections that each fill a full row.
For iOS 16 and later, you can use the .gridCellColumns() modifier on a view inside the grid to specify how many columns that view should span:
LazyVGrid(columns: columns, spacing: 12) {
ForEach(dashboardItems, id: \.id) { item in
DashboardCell(item: item)
.gridCellColumns(item.columnSpan)
}
}
This is the cleanest way to create a true dynamic dashboard layout with mixed cell sizes.
Performance Considerations for Large Data Sets
When dealing with thousands of items, even lazy loading can lead to lags if each cell is complex. Here are advanced techniques to keep your dynamic grids smooth:
- Prefetch images and data – Use
.taskor.onAppearto load data asynchronously, not in the cell’s body. - Use
Listfor extremely large lists – SwiftUI’sListis more optimized for thousands of rows with dynamic sizing. However, for a grid layout,Listwith a custom layout is possible but harder to achieve; consider usingUICollectionViewviaUIViewRepresentableif performance becomes a real bottleneck. - Limit recomputation of layout – Avoid putting dynamic layout logic inside the cell’s body. Precompute geometries when possible.
- Use
.id()on the entire grid – If you completely change the grid structure (e.g., switching from 2 columns to 3), assigning a new.id()to the grid forced it to reload entirely, which can be faster than trying to animate a complex change.
External Resources to Deepen Your Knowledge
To further explore dynamic collection views in SwiftUI, refer to these official and community resources:
- Apple’s LazyVGrid Documentation – The official reference for all parameters and behaviors. Apple Developer Documentation: LazyVGrid
- Quick Start Guide for GridItem – Covers all three sizing strategies with interactive examples. Apple Developer Documentation: GridItem
- SwiftUI by Example – Grid Layouts – A practical tutorial from Hacking with Swift that covers adaptive and flexible grids. Hacking with Swift: Grid Layouts
- SwiftUI Lab: Lazy Grids – Deep dive into performance and customizations. SwiftUI Lab: Lazy Grids
Best Practices Summary
Creating a dynamic collection view layout in SwiftUI requires balancing flexibility with performance. Here’s a quick checklist for production‑ready grids:
- Start with
.adaptivefor the simplest dynamic column count; useGeometryReaderonly when you need precise control. - Always provide unique
idvalues or conform toIdentifiable. - Use
.animation()with a value that changes (like column count) to smooth transitions. - For mixed cell sizes, prefer
.gridCellColumns()on iOS 16+ or design a custom row‑by‑row layout. - Profile your grid with Instruments to verify lazy loading is working and cells are not rendering offscreen content.
- Test on multiple device sizes and orientations – dynamic layouts that look great on one screen may break on another.
Mastering dynamic collection views empowers you to build apps that feel native and responsive across every Apple device. With SwiftUI’s declarative tools, you can achieve what once required dozens of lines of UIKit code in just a handful of expressive, reusable components.