Someone selects 80 photos in the system picker, taps Add, and your app goes quiet. No spinner, no count, just a frozen sheet while the item providers grind through iCloud downloads in whatever order they finish. When the results finally arrive, photo 37 is in slot 2, two files are missing, and nobody knows why.
PHPickerViewController is the right tool for picking photos on iOS: no full library permission, the system handles the UI, and it supports multi-select. But the part after picker(_:didFinishPicking:) is all yours, and that is where batch apps tend to go wrong. This post covers the loading side: keeping the user's order, copying files before they vanish, limiting concurrency, showing honest progress, and letting people cancel.
I build BatchMark, an on-device batch photo app, and this is the stage that runs before any batch watermark or filter touches a pixel. The code below is a teaching extract, not the app's source.
Configure the picker for a batch
import PhotosUI
import UniformTypeIdentifiers
func makeBatchPicker(limit: Int) -> PHPickerViewController {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = limit // 0 means "no limit"
config.selection = .ordered // iOS 15+: numbered badges, ordered results
config.preferredAssetRepresentationMode = .current
return PHPickerViewController(configuration: config)
}
Two settings carry most of the weight:
-
selection = .orderedshows numbered badges and gives youresultsin the order the user tapped. If your batch has a meaningful sequence (a story, a product set, a before/after pair), this is the order people expect back. -
preferredAssetRepresentationMode = .currentasks for the asset as it is stored instead of letting the system pick a "compatible" representation. With the default.automatic, a HEIC may be transcoded to JPEG on the way to you, which costs time and a generation of compression you didn't ask for.
Use PHPickerConfiguration(photoLibrary: .shared()) rather than the empty initializer if you might want assetIdentifier later. It doesn't grant library access by itself; it just fills in the identifier when one is available.
The file URL dies when the closure returns
NSItemProvider.loadFileRepresentation(forTypeIdentifier:completionHandler:) hands you a URL to a temporary file. The documentation is blunt about it: the file is deleted when the completion handler returns. If you dispatch to another queue and read it later, you are racing the cleanup.
So the first rule is: copy inside the handler, synchronously, into a folder you own.
enum PickerLoadError: Error {
case noImageType
case noFile
}
func preferredImageType(for provider: NSItemProvider) -> String? {
provider.registeredTypeIdentifiers.first { identifier in
UTType(identifier)?.conforms(to: .image) == true
}
}
func makeBatchFolder() throws -> URL {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("picked-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
return folder
}
preferredImageType(for:) picks the first registered image type, which is usually the stored format (for example public.heic). Falling back to UTType.image.identifier works too, but asking for the specific type avoids a conversion.
Wrap the load in async, with real cancellation
The load method returns a Progress. That object is useful twice: it reports bytes for slow iCloud downloads, and calling cancel() on it stops the load. To connect that to Swift concurrency, keep the progress somewhere the cancellation handler can reach.
final class LoadHandle: @unchecked Sendable {
private let lock = NSLock()
private var progress: Progress?
private var cancelled = false
func attach(_ progress: Progress) {
lock.lock()
self.progress = progress
let shouldCancel = cancelled
lock.unlock()
if shouldCancel { progress.cancel() }
}
func cancel() {
lock.lock()
cancelled = true
let current = progress
lock.unlock()
current?.cancel()
}
}
func copyPickedImage(
from provider: NSItemProvider,
into folder: URL,
parentProgress: Progress
) async throws -> URL {
guard let typeIdentifier = preferredImageType(for: provider) else {
throw PickerLoadError.noImageType
}
let handle = LoadHandle()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
let progress = provider.loadFileRepresentation(
forTypeIdentifier: typeIdentifier
) { url, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let url else {
continuation.resume(throwing: PickerLoadError.noFile)
return
}
do {
let destination = folder
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(url.pathExtension)
try FileManager.default.copyItem(at: url, to: destination)
continuation.resume(returning: destination)
} catch {
continuation.resume(throwing: error)
}
}
parentProgress.addChild(progress, withPendingUnitCount: 1)
handle.attach(progress)
}
} onCancel: {
handle.cancel()
}
}
A few details worth calling out:
- The copy happens inside the handler, before it returns. That is the whole point.
- The destination name is a UUID plus the original extension. Don't trust the temporary file's name to be unique across providers; two photos can arrive as
IMG_0001.heicfrom different sources. -
LoadHandleexists because cancellation can arrive beforeloadFileRepresentationhas even returned itsProgress. The flag makes "cancel first, attach later" still cancel. - Each child progress counts as one unit of the parent. The parent then reports a fraction that moves smoothly during a slow download instead of jumping only when a file finishes.
Load a few at a time, keep the order
Firing 80 loads at once looks fast in a demo and behaves badly on a real phone: the iCloud queue thrashes, temp storage spikes, and the first results arrive no sooner. A small, fixed number of loads in flight is calmer. Four is a reasonable starting point; measure on your oldest supported device.
The trick is to keep a sliding window in a TaskGroup and write each result into its original slot.
enum PickedItem: Sendable {
case file(URL)
case failed(index: Int)
}
func loadPickedImages(
_ results: [PHPickerResult],
into folder: URL,
progress: Progress,
maxConcurrent: Int = 4
) async -> [PickedItem] {
progress.totalUnitCount = Int64(results.count)
var slots = [PickedItem?](repeating: nil, count: results.count)
// Builds the work for one slot. Returning a closure (instead of calling
// group.addTask from a nested function) keeps the group out of captures.
func operation(for index: Int) -> @Sendable () async -> (Int, PickedItem) {
let provider = results[index].itemProvider
return {
do {
let url = try await copyPickedImage(
from: provider,
into: folder,
parentProgress: progress
)
return (index, .file(url))
} catch {
return (index, .failed(index: index))
}
}
}
await withTaskGroup(of: (Int, PickedItem).self) { group in
var nextIndex = 0
while nextIndex < min(maxConcurrent, results.count) {
group.addTask(operation: operation(for: nextIndex))
nextIndex += 1
}
for await (index, item) in group {
slots[index] = item
if nextIndex < results.count, !Task.isCancelled {
group.addTask(operation: operation(for: nextIndex))
nextIndex += 1
}
}
}
return slots.enumerated().map { index, item in
item ?? .failed(index: index)
}
}
The window starts with maxConcurrent tasks, and every finished task makes room for exactly one more, so the number in flight never exceeds the limit. Results arrive in completion order, but each one carries its index, so slots ends up in the user's order. Under Swift 6 strict concurrency you'll get warnings about capturing NSItemProvider and Progress in a @Sendable closure; neither type is marked Sendable. Progress is designed to be updated from multiple threads, and each provider here is only touched by one task, so a small @unchecked Sendable wrapper is a reasonable way to say so explicitly.
Failures don't throw out of the batch. One photo that failed to download should not throw away the 79 that worked. Keep the slot, mark it failed, and show it.
Show progress people believe
On the UI side, observe fractionCompleted and show a count next to it. A bar alone feels vague; "Loading 23 of 80" tells someone the app is working and roughly how long to wait.
import Combine
@MainActor
final class PickerLoadModel: ObservableObject {
@Published private(set) var fraction: Double = 0
@Published private(set) var items: [PickedItem] = []
private var loadTask: Task<Void, Never>?
private var progressObservation: AnyCancellable?
func start(with results: [PHPickerResult]) {
let progress = Progress()
progressObservation = progress
.publisher(for: \.fractionCompleted)
.receive(on: DispatchQueue.main)
.sink { [weak self] value in self?.fraction = value }
loadTask = Task {
guard let folder = try? makeBatchFolder() else { return }
let loaded = await loadPickedImages(results, into: folder, progress: progress)
self.items = loaded
}
}
func cancel() {
loadTask?.cancel()
}
}
Progress posts KVO changes on whatever thread updated it, so the receive(on:) hop matters. For the "23 of 80" label, count completed slots as results come back, or derive it from completedUnitCount once each child finishes.
Clean up after yourself
The batch folder is in temporaryDirectory, which the system may purge, but "may" isn't a plan. Delete the folder when the batch is exported or abandoned, and sweep old picked-* folders on launch in case the app was killed mid-batch.
func removeStaleBatchFolders(olderThan age: TimeInterval = 24 * 60 * 60) {
let fm = FileManager.default
let tmp = fm.temporaryDirectory
guard let entries = try? fm.contentsOfDirectory(
at: tmp,
includingPropertiesForKeys: [.creationDateKey]
) else { return }
let cutoff = Date().addingTimeInterval(-age)
for url in entries where url.lastPathComponent.hasPrefix("picked-") {
let created = (try? url.resourceValues(forKeys: [.creationDateKey]))?.creationDate
if let created, created < cutoff {
try? fm.removeItem(at: url)
}
}
}
The short version
- Ask for
.orderedselection and.currentrepresentation. - Copy the file inside the completion handler. It's gone afterwards.
- Bridge the returned
Progressinto both your progress UI and task cancellation. - Load a few at a time with a sliding window, and write results back by index.
- Mark failures per item instead of failing the batch.
- Delete your temp folder, and sweep leftovers on launch.
None of this is glamorous, but it's the difference between a batch feature that feels solid and one where people pick 80 photos once and never try again. I'm Maxwell, and I wrote this down after rebuilding the loading step more times than I'd like to admit.
Top comments (1)
One wrinkle with preferredImageType: registeredTypeIdentifiers.first { conforms(to: .image) } can hand you public.camera-raw-image for ProRAW shots, because RAW conforms to public.image - and with .current you then copy a 25 MB DNG into the batch folder and everything downstream has to know what to do with it. Ranking the identifiers (HEIC, then JPEG, then PNG, then anything else that conforms) instead of taking the first match keeps the no-transcode win without that surprise, and it also keeps temp storage closer to what you estimated. Does BatchMark carry RAW through the watermark stage as RAW, or convert it at import?