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
PhotosPickerandPhotosPickerItemwork together and why loading the image is a separate async step - How
didSeton a@Publishedproperty bridges the synchronous property change to an async loading task - How to use
loadTransferable(type:)to decode aPhotosPickerIteminto aUIImage - Why
@MainActoron the view model guarantees thatselectedImageis 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
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
@Published var imageSelection: PhotosPickerItem? = nil { didSet { ... } }—didSetfires synchronously on the main thread (because the class is@MainActor) immediately afterimageSelectionis updated by the picker. This is the trigger point: a property change kicks off an async load. TheTask { }insidedidSetis the standard way to launch async work from a synchronous property observer.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 anisLoadingflag before this line and clear it after.guard let data, let uiImage = UIImage(data: data) else { throw URLError(.badServerResponse) }— Two failure modes are handled here:loadTransferablereturningnil(unsupported format or permission denied) andUIImage(data:)returningnil(corrupted data). Throwing a specific error allows thecatchblock to distinguish this from other failures.selectedImage = uiImage— Noawait MainActor.runneeded here. BecausePhotoPickerViewModelis@MainActorand theTaskinherits that isolation, every assignment inside theTaskbody is automatically on the main actor.var images: [UIImage] = []accumulated before assignment — InsetImages, images are appended to a local array inside the loop. Only when the loop finishes isselectedImagesassigned in one shot. This is the correct pattern because each individualselectedImages.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.try?insetImagesvs.do/catchinsetImage— Single-image loading usesdo/catchto give you fine-grained error handling (you can surface a specific error message). Multi-image loading usestry?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
PhotosPickergives you aPhotosPickerItemtoken, 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@MainActoron 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