How to build an image gallery in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: LazyVGrid, AsyncImage Updated: May 11, 2026
TL;DR

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

  1. Adaptive grid columnsGridItem(.adaptive(minimum: 110), spacing: 4) tells LazyVGrid to 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.
  2. Phase-aware AsyncImage — Passing Transaction(animation: .easeIn) to AsyncImage gives a smooth fade when the image arrives. The .failure case renders a photo.slash icon so broken URLs degrade gracefully rather than showing blank cells.
  3. 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.
  4. Navigation with typed valuesNavigationStack + navigationDestination(for: GalleryItem.self) is the iOS 16+ pattern. Passing the whole GalleryItem to the destination means the detail view always has the right data without relying on index-based look-ups.
  5. Pinch-to-zoom in detailMagnifyGesture (iOS 17 replacement for the deprecated MagnificationGesture) combined with a simultaneous DragGesture lets 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

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.