PhotosPicker In Production: Load, Downsample, And Save User Media
PhotosPicker is one of those APIs that looks almost too easy in sample code. Bind a PhotosPickerItem, call loadTransferable, turn the result into an image, ship it.
That is fine for a prototype. In a production app, the selected item might be a huge HEIC, it might need to download from iCloud Photos, the user might choose another item while the first one is loading, and you probably do not want to keep a full-resolution image decoded in memory just to show a 200 point preview.
Let’s build the sturdier version: load a file representation, copy it into app storage, downsample a display image with ImageIO, and keep the UI honest while that work happens.
The examples below assume iOS 17 or newer because they use ContentUnavailableView, and Swift 5.9 or newer because the error enum uses switch expressions. If you support iOS 16, replace the unavailable error view with your own small view; if you build with an older Swift toolchain, use explicit return statements in those switch blocks.
The picker view
The view can stay small. Let SwiftUI own cancellation with .task(id:): when selectedItem changes or the view disappears, the old task is cancelled.
import PhotosUI
import SwiftUI
import UIKit
struct AvatarPickerView: View {
@StateObject private var model = AvatarPickerModel()
@State private var selectedItem: PhotosPickerItem?
var body: some View {
VStack(spacing: 20) {
if let previewImage = model.previewImage {
Image(uiImage: previewImage)
.resizable()
.scaledToFill()
.frame(width: 160, height: 160)
.clipShape(Circle())
}
PhotosPicker(
selection: $selectedItem,
matching: .images,
photoLibrary: .shared()
) {
Label("Choose Photo", systemImage: "photo")
}
.disabled(model.isImporting)
if model.isImporting {
VStack(spacing: 8) {
ProgressView("Preparing photo...")
Button("Cancel") {
selectedItem = nil
}
.buttonStyle(.borderless)
}
}
if let error = model.error {
ContentUnavailableView(
"Could Not Import Photo",
systemImage: "photo.badge.exclamationmark",
description: Text(error.recoverySuggestion ?? error.localizedDescription)
)
}
}
.padding()
.task(id: selectedItem) {
await model.importSelection(selectedItem)
}
}
}
The important type here is PhotosPickerItem. It is not the image itself. It is a placeholder object that can provide a representation of the selected asset. You ask for the representation you want with loadTransferable(type:).
The view model
The view model is main actor isolated because it owns UI state. Notice the importID check. Cancellation is cooperative, and an older task can still finish after a newer selection starts. The token prevents stale work from clearing or replacing the current state.
@MainActor
final class AvatarPickerModel: ObservableObject {
@Published var previewImage: UIImage?
@Published var isImporting = false
@Published var error: MediaImportError?
private var currentImportID: UUID?
func importSelection(_ item: PhotosPickerItem?) async {
guard let item else {
currentImportID = nil
isImporting = false
return
}
let importID = UUID()
currentImportID = importID
isImporting = true
error = nil
defer {
if currentImportID == importID {
isImporting = false
}
}
do {
let imported = try await MediaStore.importPhoto(from: item)
try Task.checkCancellation()
guard currentImportID == importID else { return }
guard let image = UIImage(contentsOfFile: imported.previewURL.path) else {
throw MediaImportError.decodeFailed
}
previewImage = image
} catch is CancellationError {
// The user picked another image, tapped cancel, or left the screen.
} catch let importError as MediaImportError {
if currentImportID == importID {
error = importError
}
} catch {
if currentImportID == importID {
self.error = .loadFailed(error.localizedDescription)
}
}
}
}
This is also where you choose your user-facing language. “Preparing photo…” is boring in a good way. A selected asset may need to be retrieved from iCloud Photos, so instant success is not guaranteed. If you need progress UI, the completion-handler loadTransferable overload returns Progress; show determinate progress when progress.isIndeterminate is false, and fall back to a spinner when the system cannot provide a useful fraction. If loading fails, say the photo could not be loaded and offer a retry path. Avoid turning every failure into a permissions lecture.
Data or file?
The shortest possible import is often this:
guard let data = try await item.loadTransferable(type: Data.self) else {
throw MediaImportError.unsupportedType
}
That is useful for small images, one-off uploads, or flows where you immediately hand the bytes to another service. The downside is right there in the type name: you have loaded the whole representation into memory.
For larger media, I prefer a file representation. Apple’s Transferable APIs include FileRepresentation for values that should move around as file URLs. The first thing I do with the received file is copy it to a URL I own, because the transfer URL is not my app’s permanent model storage.
import CoreTransferable
import UniformTypeIdentifiers
struct PickedPhotoFile: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(importedContentType: .image) { received in
let fileExtension = received.file.pathExtension.isEmpty
? "image"
: received.file.pathExtension
let copy = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.copyItem(at: received.file, to: copy)
return PickedPhotoFile(url: copy)
}
}
}
You can still use Data when it fits. The production habit is to decide deliberately. Avatars and thumbnails can usually tolerate Data. Original photos, videos, RAW files, and anything users may select repeatedly should push you toward URLs and file copies.
One more small trap: loading Image.self is nice for display, but Apple’s PhotosPickerItem documentation calls out that SwiftUI Image only supports PNG through its Transferable conformance. For app imports, ask for the bytes or the file instead of making display be the persistence format.
Store the original and a preview
Now we can import the picked file into app storage. This example keeps the original file and writes a JPEG preview. Keeping the original lets you upload, crop again later, or create a different size without asking the photo library for the asset again.
import ImageIO
import UIKit
struct ImportedPhoto {
let id: UUID
let originalURL: URL
let previewURL: URL
}
enum MediaStore {
static func importPhoto(from item: PhotosPickerItem) async throws -> ImportedPhoto {
guard let pickedFile = try await item.loadTransferable(type: PickedPhotoFile.self) else {
throw MediaImportError.unsupportedType
}
defer {
try? FileManager.default.removeItem(at: pickedFile.url)
}
try Task.checkCancellation()
let id = UUID()
let directory = try mediaDirectory()
let originalExtension = pickedFile.url.pathExtension.isEmpty
? "image"
: pickedFile.url.pathExtension.lowercased()
let originalURL = directory
.appendingPathComponent(id.uuidString)
.appendingPathExtension(originalExtension)
let previewURL = directory
.appendingPathComponent("\(id.uuidString)-preview")
.appendingPathExtension("jpg")
var didFinishImport = false
defer {
if !didFinishImport {
try? FileManager.default.removeItem(at: originalURL)
try? FileManager.default.removeItem(at: previewURL)
}
}
try FileManager.default.copyItem(at: pickedFile.url, to: originalURL)
try Task.checkCancellation()
try writeDownsampledJPEG(
from: originalURL,
to: previewURL,
maxPixelSize: 1200
)
didFinishImport = true
return ImportedPhoto(
id: id,
originalURL: originalURL,
previewURL: previewURL
)
}
private static func mediaDirectory() throws -> URL {
let root = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = root.appendingPathComponent("ImportedPhotos", isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return directory
}
}
For a real app, you would also save the ImportedPhoto metadata in SwiftData, Core Data, SQLite, or a JSON file. The file URLs alone are not enough unless something durable points back to them.
Downsample with ImageIO
The mistake to avoid is UIImage(data:) followed by resizing. That decodes the full image first. A 48 megapixel photo can be much larger in memory than its compressed file size suggests.
ImageIO can create a thumbnail directly from the image source:
private func writeDownsampledJPEG(
from sourceURL: URL,
to destinationURL: URL,
maxPixelSize: Int
) throws {
let sourceOptions = [
kCGImageSourceShouldCache: false
] as CFDictionary
guard let source = CGImageSourceCreateWithURL(sourceURL as CFURL, sourceOptions) else {
throw MediaImportError.decodeFailed
}
let thumbnailOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else {
throw MediaImportError.decodeFailed
}
let image = UIImage(cgImage: cgImage)
guard let data = image.jpegData(compressionQuality: 0.82) else {
throw MediaImportError.encodeFailed
}
try data.write(to: destinationURL, options: .atomic)
}
kCGImageSourceCreateThumbnailWithTransform matters because camera images commonly store orientation as metadata. The preview should look upright without you manually inspecting EXIF orientation.
The JPEG output is intentionally a flattened preview. It is a good fit for avatars and thumbnails, but it will not preserve alpha, animation, HDR behavior, or all source metadata. If preservation matters, choose an output format deliberately and write it with CGImageDestination.
Practical limited-library behavior
For a picker-only flow, do not ask for broad PhotoKit permission just to show PhotosPicker. The picker is already the user choosing specific items for this task.
Limited library access becomes relevant when you also use PhotoKit APIs directly, like fetching assets, observing the library, or presenting your own “recent photos” grid. In those screens, check the access-level-specific status:
import Photos
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if status == .limited {
// Show a "Manage Selected Photos" button in your own UI.
}
If you offer that button from SwiftUI, present Apple’s limited library picker from a UIKit view controller:
func presentLimitedLibraryPicker(from viewController: UIViewController) {
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController)
}
The practical rule is simple: treat PhotosPicker selections as user-provided input, then persist the files your feature needs. Treat PhotoKit library access as a separate capability with its own UI, empty states, and recovery path.
Errors users can act on
Keep your internal errors typed, then map them to calm copy:
enum MediaImportError: LocalizedError {
case unsupportedType
case decodeFailed
case encodeFailed
case loadFailed(String)
var errorDescription: String? {
switch self {
case .unsupportedType:
"Unsupported photo"
case .decodeFailed:
"The photo could not be decoded."
case .encodeFailed:
"The preview could not be saved."
case .loadFailed:
"The photo could not be loaded."
}
}
var recoverySuggestion: String? {
switch self {
case .unsupportedType:
"Choose a different image format."
case .decodeFailed, .encodeFailed:
"Try another photo, or try again after restarting the app."
case .loadFailed:
"Check your connection if the photo is stored in iCloud, then try again."
}
}
}
That gives the UI something better than error.localizedDescription while still preserving the original error for logging if you need it.
The production version of PhotosPicker is not much more code than the demo version. The difference is where you put the sharp edges: load the representation you actually need, cancel stale work, downsample before display, save your own copy, and explain failures in terms the user can recover from.
Further reading: