How to Implement an Image Picker in SwiftUI
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
-
PhotosPicker(selection:matching:photoLibrary:) — This SwiftUI view wraps any label and presents the native iOS Photos picker sheet when tapped. Binding it to
$vm.pickerItemmeans SwiftUI automatically writes the user's selection back as aPhotosPickerItemtoken — a lightweight reference, not the full asset data. -
.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 asyncTaskhere so the UI never blocks while the image data is fetched from the photo library. -
item.loadTransferable(type: Image.self) — This async method, provided by the
Transferableprotocol, decodes the raw photo asset directly into a SwiftUIImage. No intermediateUIImageconversion is required. Thedefer { isLoading = false }block guarantees the spinner always dismisses, even on errors. -
@Observable view model — Using the iOS 17
@Observablemacro (replacingObservableObject) keeps state granular. SwiftUI only re-renders the views that actually read a changed property, which is especially useful in the preview area whereisLoadingandselectedImagedrive two distinct sub-views. -
Conditional clear button — The "Remove Photo" button renders only when
vm.selectedImage != nil, so the layout collapses gracefully in the initial state. CallingclearSelection()also nils outpickerItem, preventing a stale reference from re-triggeringonChange.
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
-
iOS 16 compatibility:
PhotosPicker(SwiftUI-native) shipped in iOS 16, but the two-argument.onChange(of:initial:_:)closure syntax used here requires iOS 17. If you target iOS 16, fall back to the single-argument form:.onChange(of: pickerItem) { newItem in … }— but be aware it's deprecated in 17+. -
Forgetting to handle nil on re-tap: If the user opens the picker and cancels,
pickerItemmay remain unchanged (the closure doesn't fire for a no-op). Always guard onguard let item = pickerItem else { return }inside your load function instead of force-unwrapping. -
Memory pressure with large assets:
loadTransferable(type: Image.self)loads the full-resolution photo into memory. For grids or feeds, useloadTransferable(type: Data.self)and downsample viaUIImage(data:)?.preparingThumbnail(of:)before wrapping inImage(uiImage:)to keep peak RAM low. -
No privacy usage description = crash: Your
Info.plistmust includeNSPhotoLibraryUsageDescription. Without it the app crashes on the first picker presentation in iOS 17 even thoughPhotosPickeruses a limited-access model that doesn't always prompt.
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.