Skip to content

How to use PhotosPicker in SwiftUI & PhotosUI | Modern Swift Concurrency #17

PhotosPicker (from PhotosUI, available from iOS 16) is the privacy-safe, async-native way to let users select photos in SwiftUI — and unlike the older UIImagePickerController, it uses async/await with loadTransferable to load image data, fitting naturally into modern Swift concurrency patterns.

What You'll Learn

  • How PhotosPicker and PhotosPickerItem work together and why loading the image is a separate async step
  • How didSet on a @Published property bridges the synchronous property change to an async loading task
  • How to use loadTransferable(type:) to decode a PhotosPickerItem into a UIImage
  • Why @MainActor on the view model guarantees that selectedImage is always updated on the main thread
  • How to extend the pattern to multiple image selections with [PhotosPickerItem]

Mental Model

Think of PhotosPickerItem as a ticket stub. When the user picks a photo in the system photo picker, they hand you a ticket stub — not the photo itself. The stub is lightweight and crosses the concurrency boundary safely. Loading the actual image from that stub (loadTransferable) is like taking the stub to the box office: it is an async operation that can take time (especially for iCloud photos), may fail, and produces the actual content only when complete.

This two-step design (select → load) is deliberate. The system photo picker runs in a separate process for privacy — your app never sees the photo library directly. You only get a ticket stub (PhotosPickerItem), and loadTransferable is the official, privacy-respecting gate you go through to retrieve the actual data.

Detailed Explanation

PhotosPicker is a SwiftUI view that presents the system photo picker UI. It takes a binding to a PhotosPickerItem? (for single selection) or [PhotosPickerItem] (for multi-selection), and a matching filter (.images, .videos, etc.). When the user selects a photo, the binding is updated with a new PhotosPickerItem.

A PhotosPickerItem is a lightweight, Sendable token. It does not contain image data. To get the actual image, you call loadTransferable(type:) — an async method that loads the photo data conforming to a Transferable type. The most common target type is Data, which you then convert to UIImage. This async loading step is separate from selection because photos may be stored in iCloud and need to be downloaded first.

The sample uses didSet on the @Published var imageSelection property to trigger the async loading immediately after the picker updates the selection. Because the whole class is @MainActor, the didSet is guaranteed to run on the main thread. The Task { } inside didSet creates an unstructured task to bridge the synchronous didSet into async work. This is a legitimate pattern when a property change (rather than an explicit method call) must initiate async work.

The guard let selection else { return } check at the start of setImage(from:) handles the case where the user dismisses the picker without selecting anything — imageSelection is set to nil, and we correctly skip loading.

For multiple images, the setImages(from:) method iterates over [PhotosPickerItem] serially inside a Task. Each image is loaded sequentially using try? await selection.loadTransferable(type: Data.self). Building the images array locally and then assigning selectedImages = images in one shot at the end prevents the view from re-rendering for every individual image loaded — a subtle but important performance consideration for large selections.

Code Structure

PhotoPickerBootcamp.swift contains PhotoPickerViewModel (the @MainActor view model managing single and multi-image selection and async loading) and PhotoPickerBootcamp (the SwiftUI view with two PhotosPicker buttons and conditional image display).

Complete Code

PhotoPickerBootcamp.swift

swift
import SwiftUI
import PhotosUI // required for PhotosPicker and PhotosPickerItem

// @MainActor: all @Published mutations and didSet observers run on the main thread.
@MainActor
final class PhotoPickerViewModel: ObservableObject {
    
    // The decoded UIImage, ready to display in SwiftUI.
    @Published private(set) var selectedImage: UIImage? = nil
    // PhotosPickerItem is the lightweight token returned by the system picker.
    // didSet immediately triggers async loading when the selection changes.
    @Published var imageSelection: PhotosPickerItem? = nil {
        didSet {
            setImage(from: imageSelection)
        }
    }
    
    // Array versions for multi-select mode.
    @Published private(set) var selectedImages: [UIImage] = []
    @Published var imageSelections: [PhotosPickerItem] = [] {
        didSet {
            setImages(from: imageSelections)
        }
    }

    
    // Loads image data from a single PhotosPickerItem and converts it to UIImage.
    private func setImage(from selection: PhotosPickerItem?) {
        guard let selection else { return } // user dismissed picker without selecting; nothing to do
        
        // Task{} bridges from synchronous didSet into async context.
        // @MainActor is inherited — selectedImage assignment stays on main thread.
        Task {
//            if let data = try? await selection.loadTransferable(type: Data.self) {
//                if let uiImage = UIImage(data: data) {
//                    selectedImage = uiImage
//                    return
//                }
//            }
            
            do {
                // loadTransferable downloads the photo data if it is in iCloud; can take seconds.
                let data = try await selection.loadTransferable(type: Data.self)
                
                // Both data and UIImage construction can fail — throw a descriptive error if so.
                guard let data, let uiImage = UIImage(data: data) else {
                    throw URLError(.badServerResponse)
                }
                
                // Safe to assign directly — @MainActor guarantees we're on the main thread.
                selectedImage = uiImage
            } catch {
                print(error)
            }
        }
    }
    
    // Loads all selected PhotosPickerItems and collects the resulting UIImages.
    private func setImages(from selections: [PhotosPickerItem]) {
        Task {
            var images: [UIImage] = [] // local accumulator — prevents one re-render per image
            for selection in selections {
                // `try?` silently skips images that fail to load rather than stopping the loop.
                if let data = try? await selection.loadTransferable(type: Data.self) {
                    if let uiImage = UIImage(data: data) {
                        images.append(uiImage)
                    }
                }
            }
            
            // Single assignment at the end: one view update for the full batch of images.
            selectedImages = images
        }
    }
}

struct PhotoPickerBootcamp: View {
    
    @StateObject private var viewModel = PhotoPickerViewModel()
    
    var body: some View {
        VStack(spacing: 40) {
            Text("Hello, World!")
            
            // Show the selected image once it has finished loading.
            if let image = viewModel.selectedImage {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFill()
                    .frame(width: 200, height: 200)
                    .cornerRadius(10)
            }
            
            // Single-image picker: binding to PhotosPickerItem? triggers didSet → setImage.
            PhotosPicker(selection: $viewModel.imageSelection, matching: .images) {
                Text("Open the photo picker!")
                    .foregroundColor(.red)
            }
            
            // Show the multi-image strip if any images have been loaded.
            if !viewModel.selectedImages.isEmpty {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack {
                        ForEach(viewModel.selectedImages, id: \.self) { image in
                            Image(uiImage: image)
                                .resizable()
                                .scaledToFill()
                                .frame(width: 50, height: 50)
                                .cornerRadius(10)
                        }
                    }
                }
            }
            
            // Multi-image picker: binding to [PhotosPickerItem] triggers didSet → setImages.
            PhotosPicker(selection: $viewModel.imageSelections, matching: .images) {
                Text("Open the photos picker!")
                    .foregroundColor(.red)
            }
        }
    }
}

struct PhotoPickerBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        PhotoPickerBootcamp()
    }
}

Code Walkthrough

  1. @Published var imageSelection: PhotosPickerItem? = nil { didSet { ... } }didSet fires synchronously on the main thread (because the class is @MainActor) immediately after imageSelection is updated by the picker. This is the trigger point: a property change kicks off an async load. The Task { } inside didSet is the standard way to launch async work from a synchronous property observer.

  2. loadTransferable(type: Data.self) — This is the async photo-data download. For photos stored locally it is fast, but for iCloud photos it can take several seconds. The suspension point here is where the spinner or loading indicator would ideally be shown. In a production app, set an isLoading flag before this line and clear it after.

  3. guard let data, let uiImage = UIImage(data: data) else { throw URLError(.badServerResponse) } — Two failure modes are handled here: loadTransferable returning nil (unsupported format or permission denied) and UIImage(data:) returning nil (corrupted data). Throwing a specific error allows the catch block to distinguish this from other failures.

  4. selectedImage = uiImage — No await MainActor.run needed here. Because PhotoPickerViewModel is @MainActor and the Task inherits that isolation, every assignment inside the Task body is automatically on the main actor.

  5. var images: [UIImage] = [] accumulated before assignment — In setImages, images are appended to a local array inside the loop. Only when the loop finishes is selectedImages assigned in one shot. This is the correct pattern because each individual selectedImages.append(...) inside the loop would trigger a SwiftUI view update, causing the image strip to re-render after every single image loads. One assignment at the end means one render pass.

  6. try? in setImages vs. do/catch in setImage — Single-image loading uses do/catch to give you fine-grained error handling (you can surface a specific error message). Multi-image loading uses try? to silently skip individual failures and continue loading the rest. Both strategies are valid depending on your UX requirements.

Common Mistakes

Mistake: Trying to display a PhotosPickerItem directly as an image.
PhotosPickerItem is not an image — it is a reference token. You must call loadTransferable(type:) to get actual image data. Attempting to use a PhotosPickerItem in an Image() view or assigning it to a UIImage property will not compile. The picker and the image loading are always two distinct steps.

Mistake: Forgetting that loadTransferable is async and wrapping it in a synchronous context.
loadTransferable(type:) is an async throws method. Calling it without await is a compiler error. If you are in a didSet or another synchronous context, you must wrap it in Task { }. Attempting to use a dispatch queue with DispatchQueue.global().async and a semaphore is the old, incorrect pattern — use Task and await.

Mistake: Not handling the nil result from loadTransferable.
loadTransferable(type: Data.self) can return nil even when it does not throw — this happens when the selected item cannot be loaded as the requested Transferable type (for example, a Live Photo when you request Data). Always guard against nil and provide a fallback or user-facing error. Skipping this check silently produces no image and no error message, leaving the user confused.

Key Takeaways

  • PhotosPicker gives you a PhotosPickerItem token, not an image — you always need a separate async step (loadTransferable) to get actual image data.
  • didSet + Task { } is the correct bridge from a synchronous property observer into async work; with @MainActor on the class, the task inherits main-actor isolation automatically.
  • For multi-image loading, accumulate results locally and assign in one batch to minimize view re-renders.

Last updated: June 27, 2026

Released under the MIT License.