How to build an image gallery in SwiftUI
Wrap a LazyVGrid inside a ScrollView and use AsyncImage for each cell — SwiftUI handles lazy loading and memory management automatically. Tap a cell to push a full-screen detail view via NavigationLink.
struct GalleryView: View {
let urls: [URL]
let columns = [GridItem(.adaptive(minimum: 110), spacing: 4)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 4) {
ForEach(urls, id: \.self) { url in
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
Color.secondary.opacity(0.2)
}
.frame(width: 110, height: 110)
.clipped()
}
}
}
}
}
Full implementation
The full implementation adds a tappable detail view, a phase-aware loading state with a skeleton placeholder, and an adaptive column layout that responds to dynamic type and device size. We keep the gallery's data model lightweight — just a typed GalleryItem struct — so it composes cleanly with any data source.
import SwiftUI
// MARK: - Model
struct GalleryItem: Identifiable {
let id: UUID
let url: URL
let caption: String
}
// MARK: - Thumbnail Cell
struct GalleryCell: View {
let item: GalleryItem
var body: some View {
AsyncImage(url: item.url, transaction: Transaction(animation: .easeIn)) { phase in
switch phase {
case .empty:
ZStack {
Color.secondary.opacity(0.15)
ProgressView()
}
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
ZStack {
Color.secondary.opacity(0.15)
Image(systemName: "photo.slash")
.foregroundStyle(.secondary)
}
@unknown default:
Color.clear
}
}
.frame(width: 110, height: 110)
.clipped()
.cornerRadius(8)
.accessibilityLabel(item.caption)
}
}
// MARK: - Detail View
struct GalleryDetailView: View {
let item: GalleryItem
@State private var scale: CGFloat = 1
@State private var offset: CGSize = .zero
var body: some View {
GeometryReader { geo in
AsyncImage(url: item.url, transaction: Transaction(animation: .easeIn)) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.scaleEffect(scale)
.offset(offset)
.gesture(
MagnifyGesture()
.onChanged { value in scale = max(1, value.magnification) }
.simultaneously(with:
DragGesture()
.onChanged { value in offset = value.translation }
.onEnded { _ in
withAnimation(.spring) { offset = .zero }
}
)
)
case .empty:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
default:
Image(systemName: "photo.slash")
.font(.largeTitle)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.navigationTitle(item.caption)
.navigationBarTitleDisplayMode(.inline)
.background(.black)
}
}
// MARK: - Gallery Grid
struct ImageGalleryView: View {
let items: [GalleryItem]
private let columns = [GridItem(.adaptive(minimum: 110), spacing: 4)]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 4) {
ForEach(items) { item in
NavigationLink(value: item) {
GalleryCell(item: item)
}
.buttonStyle(.plain)
}
}
.padding(4)
}
.navigationTitle("Gallery")
.navigationDestination(for: GalleryItem.self) { item in
GalleryDetailView(item: item)
}
}
}
}
// MARK: - Preview
#Preview {
let sampleItems: [GalleryItem] = (1...18).map { i in
GalleryItem(
id: UUID(),
url: URL(string: "https://picsum.photos/seed/\(i)/400/400")!,
caption: "Photo \(i)"
)
}
ImageGalleryView(items: sampleItems)
}
How it works
-
Adaptive grid columns —
GridItem(.adaptive(minimum: 110), spacing: 4)tellsLazyVGridto fit as many 110-pt columns as possible. On an iPhone SE you get 3 columns; on an iPad you get many more, with zero manual breakpoints. -
Phase-aware
AsyncImage— PassingTransaction(animation: .easeIn)toAsyncImagegives a smooth fade when the image arrives. The.failurecase renders aphoto.slashicon so broken URLs degrade gracefully rather than showing blank cells. -
Lazy loading — Because we use
LazyVGrid, cells are only rendered (and images only fetched) as they scroll into the viewport. Off-screen cells are deallocated automatically, keeping memory usage flat. -
Navigation with typed values —
NavigationStack+navigationDestination(for: GalleryItem.self)is the iOS 16+ pattern. Passing the wholeGalleryItemto the destination means the detail view always has the right data without relying on index-based look-ups. -
Pinch-to-zoom in detail —
MagnifyGesture(iOS 17 replacement for the deprecatedMagnificationGesture) combined with a simultaneousDragGesturelets users inspect images. A spring animation snaps the offset back to center when the drag ends.
Variants
Fixed 3-column grid (Instagram-style)
// Replace the adaptive column definition with three fixed columns.
// Each cell fills exactly one-third of the screen width minus gutters.
struct FixedGridGallery: View {
let items: [GalleryItem]
private var columns: [GridItem] {
let cellSize = (UIScreen.main.bounds.width - 8) / 3
return Array(repeating: GridItem(.fixed(cellSize), spacing: 2), count: 3)
}
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 2) {
ForEach(items) { item in
AsyncImage(url: item.url) { image in
image.resizable().scaledToFill()
} placeholder: {
Color.secondary.opacity(0.2)
}
.aspectRatio(1, contentMode: .fill)
.clipped()
.accessibilityLabel(item.caption)
}
}
}
}
}
Waterfall / masonry layout
LazyVGrid does not natively support variable-height masonry. For a true Pinterest-style layout, split items into two arrays (odd/even index) and render them in an HStack of two LazyVStacks. Each image uses .scaledToFit() instead of .scaledToFill() so cells grow to show the full image. Alternatively, iOS 18 introduced Layout protocol helpers that make custom masonry straightforward — consider targeting iOS 18 if masonry is a core UI requirement.
Common pitfalls
-
⚠️ iOS 17
MagnifyGesturerename —MagnificationGesturestill compiles on iOS 17 but is formally deprecated. UseMagnifyGestureintroduced in iOS 17 for the detail zoom. If you target iOS 16, keepMagnificationGestureand guard the iOS 17 path with#if swift(>=5.9). -
⚠️
AsyncImagehas no disk cache — Apple'sAsyncImageonly usesURLSession's default in-memory cache. If the user leaves and returns to the gallery, images re-download. For production, swapAsyncImagefor a cached image loader backed byURLCacheor a third-party library like Nuke. -
⚠️
.framemust come before.clipped()— Setting a fixed frame onAsyncImagethen calling.clipped()is the correct order. Reversing them (clip first, frame second) will not constrain the image correctly and you'll see overflow bleed outside cells in some edge cases. -
⚠️ Accessibility labels on image cells —
NavigationLinkwrapping an image view will default to an empty accessibility label, making the gallery unusable with VoiceOver. Always add.accessibilityLabel(item.caption)(or a descriptive string) to every cell.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement an image gallery in SwiftUI for iOS 17+. Use LazyVGrid with adaptive GridItem columns and AsyncImage with phase-based loading states (empty, success, failure). Add a detail view with MagnifyGesture for pinch-to-zoom. Make it accessible (VoiceOver labels on every cell). Add a #Preview with realistic sample data using picsum.photos URLs.
In Soarias, drop this prompt into the Build phase after your mockup screens are locked — it maps directly to a media screen component and generates a self-contained module you can drop into your NavigationStack without rewiring.
Related
FAQ
Does this work on iOS 16?
Mostly yes — LazyVGrid and AsyncImage are both available from iOS 14. The two iOS 17-specific pieces are MagnifyGesture (replace with MagnificationGesture for iOS 16) and the Transaction(animation:) initialiser on AsyncImage (omit it on iOS 16; the image will still load but without the fade-in). The navigationDestination(for:) pattern requires iOS 16+, so it's fine.
How do I support selecting multiple images, like a photo picker?
Replace NavigationLink with a tap gesture that toggles a Set<GalleryItem.ID> in a @State variable. Overlay a checkmark badge on each selected cell using .overlay(alignment: .topTrailing). For picking photos from the user's library, use PhotosPicker (PhotosUI, iOS 16+) rather than a custom gallery — it handles permissions and privacy automatically.
What is the UIKit equivalent?
In UIKit you'd build this with UICollectionView using a compositional layout (NSCollectionLayoutSection with an .orthogonalScrollingBehavior) and UICollectionViewDiffableDataSource. Cell images are loaded with URLSession and cached manually. The SwiftUI LazyVGrid + AsyncImage approach eliminates roughly 80% of that boilerplate while delivering equivalent scrolling performance for most gallery sizes.
Last reviewed: 2026-05-11 by the Soarias team.