DEV Community

Maxwell
Maxwell

Posted on

Loading 100 Photos from PHPicker Without Freezing: Order, Progress and Cancellation in Swift

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)
}
Enter fullscreen mode Exit fullscreen mode

Two settings carry most of the weight:

  • selection = .ordered shows numbered badges and gives you results in 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 = .current asks 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
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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.heic from different sources.
  • LoadHandle exists because cancellation can arrive before loadFileRepresentation has even returned its Progress. 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The short version

  • Ask for .ordered selection and .current representation.
  • Copy the file inside the completion handler. It's gone afterwards.
  • Bridge the returned Progress into 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)

Collapse
 
fireflyteamapps profile image
Firefly Team •

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?