How to implement a photo grid in SwiftUI
Wrap a LazyVGrid with GridItem(.adaptive(minimum: 110)) inside a ScrollView and populate each cell with AsyncImage. The grid adapts to any screen width automatically — no manual column math required.
import SwiftUI
struct PhotoGridView: View {
let urls: [URL]
let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(urls, id: \.self) { url in
AsyncImage(url: url) { img in
img.resizable().scaledToFill()
} placeholder: {
Color.secondary.opacity(0.2)
}
.frame(minWidth: 0, maxWidth: .infinity)
.aspectRatio(1, contentMode: .fill)
.clipped()
}
}
}
}
}
Full implementation
The complete version adds tap-to-zoom via a fullScreenCover, a loading skeleton using redacted(reason: .placeholder), and proper VoiceOver accessibility labels on every cell. Navigation is driven by a simple @State selection binding so the grid stays decoupled from the detail view.
import SwiftUI
// MARK: - Model
struct Photo: Identifiable {
let id: UUID
let url: URL
let altText: String
}
// MARK: - Grid
struct PhotoGridView: View {
let photos: [Photo]
@State private var selected: Photo? = nil
private let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
PhotoCell(photo: photo)
.onTapGesture { selected = photo }
.accessibilityLabel(photo.altText)
.accessibilityAddTraits(.isButton)
}
}
.padding(.horizontal, 2)
}
.navigationTitle("Photos")
.navigationBarTitleDisplayMode(.inline)
}
.fullScreenCover(item: $selected) { photo in
PhotoDetailView(photo: photo)
}
}
}
// MARK: - Cell
struct PhotoCell: View {
let photo: Photo
@State private var phase: AsyncImagePhase = .empty
var body: some View {
AsyncImage(url: photo.url, transaction: .init(animation: .easeIn(duration: 0.2))) { incoming in
phase = incoming
}
return Group {
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Image(systemName: "photo")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.secondary.opacity(0.1))
default:
Color.secondary.opacity(0.15)
.redacted(reason: .placeholder)
}
}
.frame(minWidth: 0, maxWidth: .infinity)
.aspectRatio(1, contentMode: .fill)
.clipped()
}
}
// MARK: - Detail
struct PhotoDetailView: View {
let photo: Photo
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
AsyncImage(url: photo.url) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
ProgressView().tint(.white)
}
.accessibilityLabel(photo.altText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundStyle(.white)
.padding()
}
.accessibilityLabel("Close photo")
}
}
}
// MARK: - Preview
#Preview {
let samplePhotos: [Photo] = (1...18).map { i in
Photo(
id: UUID(),
url: URL(string: "https://picsum.photos/seed/\(i)/400/400")!,
altText: "Sample photo \(i)"
)
}
PhotoGridView(photos: samplePhotos)
}
How it works
-
Adaptive columns with
GridItem(.adaptive(minimum: 110))— SwiftUI calculates how many 110 pt columns fit in the available width and creates exactly that many. On an iPhone SE you get 3 columns; on an iPad you might get 8. You write zero layout math. -
Lazy loading via
LazyVGrid— Cells are only instantiated as they scroll into the viewport, so a grid of 1 000 photos doesn't allocate 1 000AsyncImagetasks up front. This keeps memory usage flat regardless of album size. -
Square cells with
.aspectRatio(1, contentMode: .fill)+.clipped()— Forcing a 1:1 aspect ratio and clipping theAsyncImagegives uniform tiles without knowing each photo's native dimensions ahead of time. -
Tap-to-detail via
fullScreenCover(item:)— Settingselectedto a non-nilPhototriggers the full-screen cover automatically. Because the binding is optional andIdentifiable, SwiftUI handles present and dismiss with no extra state machine required. -
Skeleton placeholder with
.redacted(reason: .placeholder)— WhileAsyncImagePhaseis.empty, a neutralColorblock with the redacted modifier gives a shimmer-like visual cue that content is loading, matching iOS Photos app conventions.
Variants
Fixed 3-column grid (Instagram-style)
// Replace the adaptive column definition with a fixed 3-column layout.
// Useful when you want pixel-perfect control independent of screen width.
private let columns = Array(
repeating: GridItem(.flexible(), spacing: 2),
count: 3
)
// Then in LazyVGrid:
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
PhotoCell(photo: photo)
.aspectRatio(1, contentMode: .fill)
.clipped()
.onTapGesture { selected = photo }
}
}
Photo library picker integration (PhotosUI)
To let users pick photos from their library instead of loading remote URLs, import PhotosUI and add a PhotosPicker toolbar button. Bind it to a @State var pickerItems: [PhotosPickerItem] = [] array, then call item.loadTransferable(type: Data.self) in an .onChange(of: pickerItems) modifier to convert each selection into a UIImage/Data for display. Because LazyVGrid is data-agnostic, swapping the URL model for local UIImage values requires changing only the cell's image source — the grid layout code stays identical.
Common pitfalls
-
⚠️ iOS 17 transition API: The
transaction:parameter onAsyncImagewas updated in iOS 17. If you need to support iOS 16, replace it with.animation(.easeIn, value: phase)applied to theGroupinstead — the transaction initializer doesn't exist on iOS 16. -
⚠️
scaledToFillwithout.clipped(): Forgetting.clipped()on a fill-scaled image causes photos to visually overflow into adjacent cells. Always pairscaledToFillwith.clipped()inside a grid cell, and setclipsShapeor a.contentShapeif tap targets bleed over. -
⚠️ Accessibility labels on image cells:
AsyncImagedoesn't synthesise VoiceOver labels automatically — every cell must carry an explicit.accessibilityLabel. Without it, VoiceOver reads "Image" for every tile, which is useless. Store descriptive alt text in your model and always pass it through.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a photo grid in SwiftUI for iOS 17+. Use LazyVGrid with GridItem(.adaptive(minimum: 110)). Load images with AsyncImage; show a skeleton placeholder while loading. Add tap-to-zoom using fullScreenCover(item:). Make it accessible (VoiceOver labels on every cell). Add a #Preview with realistic sample data using picsum.photos URLs.
In the Soarias Build phase, paste this prompt directly into the implementation panel — Claude Code will scaffold the full PhotoGridView, PhotoCell, and PhotoDetailView files in one pass, ready to drop into your Xcode project.
Related
FAQ
Does this work on iOS 16?
LazyVGrid and AsyncImage are both available from iOS 14, so the grid layout itself is fully backwards-compatible. The one iOS 17-specific detail is the transaction: parameter on AsyncImage — swap it for a .animation view modifier if you target iOS 16. The #Preview macro requires Xcode 15+ but can be replaced with a PreviewProvider for older toolchains.
How do I handle very large local photo libraries without lag?
Because LazyVGrid only renders visible cells, scrolling performance scales well even with thousands of items. The main bottleneck is thumbnail decoding — request downscaled thumbnails from the Photos framework using PHImageRequestOptions with a target size equal to your cell size (around 220×220 px @2x), rather than loading the full-resolution asset. This keeps memory per cell under 200 KB instead of several MB.
What is the UIKit equivalent?
In UIKit, the closest equivalent is UICollectionView with a UICollectionViewCompositionalLayout using fractional-width items inside a horizontal group. It requires significantly more boilerplate: a diffable data source, cell registration, layout configuration, and manual prefetching via UICollectionViewDataSourcePrefetching. The SwiftUI LazyVGrid approach achieves the same result in roughly one-fifth the code.
Last reviewed: 2026-05-11 by the Soarias team.