```html SwiftUI: How to Implement Image Picker (iOS 17+, 2026)

How to Implement an Image Picker in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: PhotosPicker Updated: May 12, 2026
TL;DR

Import PhotosUI, bind a PhotosPickerItem? state variable to PhotosPicker, then load the selection with loadTransferable(type: Image.self) inside a .onChange task.

import PhotosUI
import SwiftUI

struct QuickPickerView: View {
    @State private var pickerItem: PhotosPickerItem?
    @State private var selectedImage: Image?

    var body: some View {
        VStack(spacing: 16) {
            selectedImage?
                .resizable()
                .scaledToFit()
                .frame(height: 200)

            PhotosPicker("Select Photo", selection: $pickerItem, matching: .images)
        }
        .onChange(of: pickerItem) { _, newItem in
            Task {
                selectedImage = try? await newItem?.loadTransferable(type: Image.self)
            }
        }
    }
}

Full implementation

The implementation below shows a production-ready image picker that supports a single photo selection with a loading state, an error boundary, and an accessible clear button. It uses PhotosUI's PhotosPicker view — a native SwiftUI component backed by the system Photos sheet — avoiding any UIViewControllerRepresentable bridge. The loadTransferable(type:) method is async-safe and decodes the picked asset directly into a SwiftUI Image.

import PhotosUI
import SwiftUI

// MARK: - View Model

@Observable
final class ImagePickerViewModel {
    var pickerItem: PhotosPickerItem?
    var selectedImage: Image?
    var isLoading = false
    var errorMessage: String?

    func loadImage() async {
        guard let item = pickerItem else { return }
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            if let image = try await item.loadTransferable(type: Image.self) {
                selectedImage = image
            } else {
                errorMessage = "Could not load the selected photo."
            }
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func clearSelection() {
        pickerItem = nil
        selectedImage = nil
        errorMessage = nil
    }
}

// MARK: - Main View

struct ImagePickerView: View {
    @State private var vm = ImagePickerViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {

                // Preview area
                ZStack {
                    RoundedRectangle(cornerRadius: 16)
                        .fill(Color(.secondarySystemBackground))
                        .frame(maxWidth: .infinity)
                        .frame(height: 280)

                    if vm.isLoading {
                        ProgressView("Loading…")
                            .accessibilityLabel("Loading selected photo")
                    } else if let image = vm.selectedImage {
                        image
                            .resizable()
                            .scaledToFill()
                            .frame(maxWidth: .infinity)
                            .frame(height: 280)
                            .clipShape(RoundedRectangle(cornerRadius: 16))
                            .accessibilityLabel("Selected photo")
                    } else {
                        VStack(spacing: 8) {
                            Image(systemName: "photo.on.rectangle.angled")
                                .font(.system(size: 44))
                                .foregroundStyle(.secondary)
                            Text("No photo selected")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                        .accessibilityElement(children: .combine)
                        .accessibilityLabel("Photo placeholder. Tap the button below to choose a photo.")
                    }
                }

                // Error banner
                if let error = vm.errorMessage {
                    Label(error, systemImage: "exclamationmark.triangle.fill")
                        .font(.caption)
                        .foregroundStyle(.red)
                        .padding(.horizontal)
                        .accessibilityLabel("Error: \(error)")
                }

                // Picker button
                PhotosPicker(
                    selection: $vm.pickerItem,
                    matching: .images,
                    photoLibrary: .shared()
                ) {
                    Label("Choose Photo", systemImage: "photo.badge.plus")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.accentColor)
                        .foregroundStyle(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 14))
                }
                .accessibilityLabel("Open photo picker")

                // Clear button
                if vm.selectedImage != nil {
                    Button(role: .destructive) {
                        vm.clearSelection()
                    } label: {
                        Label("Remove Photo", systemImage: "xmark.circle")
                            .frame(maxWidth: .infinity)
                    }
                    .accessibilityLabel("Remove selected photo")
                }

                Spacer()
            }
            .padding()
            .navigationTitle("Image Picker")
            .onChange(of: vm.pickerItem) { _, _ in
                Task { await vm.loadImage() }
            }
        }
    }
}

// MARK: - Preview

#Preview {
    ImagePickerView()
}

How it works

  1. PhotosPicker(selection:matching:photoLibrary:) — This SwiftUI view wraps any label and presents the native iOS Photos picker sheet when tapped. Binding it to $vm.pickerItem means SwiftUI automatically writes the user's selection back as a PhotosPickerItem token — a lightweight reference, not the full asset data.
  2. .onChange(of: vm.pickerItem) — iOS 17's two-parameter onChange (old value, new value) fires as soon as the binding changes. We kick off an async Task here so the UI never blocks while the image data is fetched from the photo library.
  3. item.loadTransferable(type: Image.self) — This async method, provided by the Transferable protocol, decodes the raw photo asset directly into a SwiftUI Image. No intermediate UIImage conversion is required. The defer { isLoading = false } block guarantees the spinner always dismisses, even on errors.
  4. @Observable view model — Using the iOS 17 @Observable macro (replacing ObservableObject) keeps state granular. SwiftUI only re-renders the views that actually read a changed property, which is especially useful in the preview area where isLoading and selectedImage drive two distinct sub-views.
  5. Conditional clear button — The "Remove Photo" button renders only when vm.selectedImage != nil, so the layout collapses gracefully in the initial state. Calling clearSelection() also nils out pickerItem, preventing a stale reference from re-triggering onChange.

Variants

Multi-image selection

Change the binding from a single PhotosPickerItem? to an array and set a maxSelectionCount to let users pick multiple photos at once.

struct MultiImagePickerView: View {
    @State private var pickerItems: [PhotosPickerItem] = []
    @State private var images: [Image] = []

    var body: some View {
        VStack {
            ScrollView(.horizontal) {
                HStack(spacing: 12) {
                    ForEach(images.indices, id: \.self) { i in
                        images[i]
                            .resizable()
                            .scaledToFill()
                            .frame(width: 100, height: 100)
                            .clipShape(RoundedRectangle(cornerRadius: 10))
                    }
                }
                .padding()
            }

            PhotosPicker(
                selection: $pickerItems,
                maxSelectionCount: 5,
                matching: .images
            ) {
                Label("Select up to 5 photos", systemImage: "photo.stack")
            }
        }
        .onChange(of: pickerItems) { _, newItems in
            Task {
                images = []
                for item in newItems {
                    if let img = try? await item.loadTransferable(type: Image.self) {
                        images.append(img)
                    }
                }
            }
        }
    }
}

Filter by media type

Pass a compound PHPickerFilter to the matching: parameter to restrict what the picker shows. For example, matching: .any(of: [.images, .livePhotos]) allows both stills and Live Photos, while matching: .screenshots narrows to screenshots only. To accept videos, use matching: .videos and load with loadTransferable(type: Movie.self) — note that Movie requires you to define a custom Transferable conformance wrapping a URL.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement an image picker in SwiftUI for iOS 17+.
Use PhotosPicker from the PhotosUI framework.
Support single-image selection with a loading state and error handling.
Make it accessible (VoiceOver labels on the picker button, image preview, and clear button).
Add a #Preview with realistic sample data.

In Soarias's Build phase, drop this prompt into a feature branch for your media-upload screen — Claude Code will scaffold the picker, wire it to your @Observable model, and generate a preview stub you can verify instantly in Xcode Canvas before moving to the Test phase.

Related

FAQ

Does this work on iOS 16?

PhotosPicker itself is available from iOS 16, but the two-argument .onChange(of:_:) syntax is iOS 17 only. If you need iOS 16 support, swap the handler for the deprecated single-argument closure: .onChange(of: vm.pickerItem) { item in … }. Everything else in this guide — loadTransferable, @Observable, and the #Preview macro — requires iOS 17 / Xcode 15 at minimum.

Can I load the photo as Data or UIImage instead of Image?

Yes. loadTransferable is generic over any Transferable-conforming type. Pass type: Data.self to get raw bytes (useful for uploads), or convert via UIImage(data: data).flatMap { Image(uiImage: $0) } when you need to downsample before display. SwiftUI.Image does not expose pixel data, so for any image processing pipeline use Data or UIImage as your intermediate.

What's the UIKit equivalent?

In UIKit you'd use PHPickerViewController (introduced iOS 14) with a PHPickerViewControllerDelegate. In SwiftUI projects targeting iOS 16 or later, PhotosPicker replaces the need for a UIViewControllerRepresentable wrapper entirely. Avoid the older UIImagePickerController — it's deprecated as of iOS 14 and will be removed in a future SDK.

Last reviewed: 2026-05-12 by the Soarias team.

```