```html SwiftUI: How to Build a Photo Grid (iOS 17+, 2026)

How to implement a photo grid in SwiftUI

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

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

  1. 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.
  2. 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 000 AsyncImage tasks up front. This keeps memory usage flat regardless of album size.
  3. Square cells with .aspectRatio(1, contentMode: .fill) + .clipped() — Forcing a 1:1 aspect ratio and clipping the AsyncImage gives uniform tiles without knowing each photo's native dimensions ahead of time.
  4. Tap-to-detail via fullScreenCover(item:) — Setting selected to a non-nil Photo triggers the full-screen cover automatically. Because the binding is optional and Identifiable, SwiftUI handles present and dismiss with no extra state machine required.
  5. Skeleton placeholder with .redacted(reason: .placeholder) — While AsyncImagePhase is .empty, a neutral Color block 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

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.

```