SwiftUI PhotosPicker in iOS 26: Multi-Select, Video Filters, and File-Based Loading

How to use SwiftUI PhotosPicker in iOS 26 for multi-select, video filtering, and file-based loading. Covers race conditions, iCloud progress, and VoiceOver.

SwiftUI PhotosPicker iOS 26 Guide

Updated: July 6, 2026

SwiftUI's PhotosPicker is the declarative way to present the system photo library and receive one or more images or videos as asynchronous Transferable payloads, without asking for Photos permission. In iOS 26 the API keeps its iOS 16 shape but gains a smarter out-of-process host, HDR-aware transfers, and better VoiceOver container cues. So, here's the plan: I'll walk you through every parameter, the two loading strategies that actually scale, and the accessibility and race-condition traps I hit while shipping a media-heavy app this year.

  • PhotosPicker runs out-of-process, so your app never needs the NSPhotoLibraryUsageDescription entitlement for user-initiated selection.
  • Use maxSelectionCount: and a [PhotosPickerItem] binding for multi-select; iOS 17+ added selectionBehavior: .continuous for numbered checkmarks.
  • Filter with matching: .any(of: [.images, .not(.screenshots)]) to compose PHPickerFilter predicates precisely.
  • For anything larger than a thumbnail, request Transferable as a file URL via FileRepresentation, not raw Data, or you'll spike RSS on the main actor.
  • iCloud Photos assets stream over the network; use preferredItemEncoding: .compatible to get transcoded HEIC, and always show a determinate progress state.
  • VoiceOver announces the picker sheet as a new container in iOS 26. Label your trigger and selected thumbnails, or the flow breaks for screen-reader users.

What is PhotosPicker in SwiftUI?

PhotosPicker is a SwiftUI view (and matching modifier) that presents Apple's system photo library and returns the user's selection as one or more PhotosPickerItem handles. It replaces the UIKit sandwich of PHPickerViewController wrapped in UIViewControllerRepresentable, and it runs the picker UI in an extension process owned by Photos, not your app. That means you can offer a full photo browser without ever prompting for library access. The user hands you exactly the assets they touched, nothing more.

The tradeoff is that the picker is user-driven only. You can't pre-select assets, silently sync a whole album, or read arbitrary photos by identifier. If your app needs that, you're back to PHPhotoLibrary with an NSPhotoLibraryUsageDescription. Honestly though, for 95% of "let me attach a photo to this message" flows, PhotosPicker is the right tool.

In iOS 26 the picker adopts Liquid Glass chrome, so its sheet materially matches the rest of your app when you present it modally. There's no new API surface to adopt, but there is one behavioural change worth calling out. The picker now transcodes HDR gain maps by default when you request a JPEG or PNG representation, so downstream image processing keeps the extended range unless you explicitly ask for the current file format via preferredItemEncoding: .current.

Your first PhotosPicker in 30 lines

Here's the minimum SwiftUI-first example: a button that opens the picker, receives one image, and renders it. I'm using the .task(id:) pattern from the outset because onChange creates fire-and-forget tasks that leak when the view redraws. (I've watched an entire app freeze because someone chained three onChange handlers on the same picker item. Not fun to debug.)

import SwiftUI
import PhotosUI

struct AvatarPicker: View {
    @State private var pickerItem: PhotosPickerItem?
    @State private var avatar: Image?

    var body: some View {
        VStack(spacing: 24) {
            (avatar ?? Image(systemName: "person.crop.circle"))
                .resizable()
                .scaledToFill()
                .frame(width: 120, height: 120)
                .clipShape(Circle())
                .accessibilityLabel(avatar == nil ? "No avatar selected" : "Selected avatar")

            PhotosPicker("Choose a photo", selection: $pickerItem, matching: .images)
                .buttonStyle(.borderedProminent)
        }
        .task(id: pickerItem) {
            guard let pickerItem else { return }
            if let data = try? await pickerItem.loadTransferable(type: Data.self),
               let uiImage = UIImage(data: data) {
                avatar = Image(uiImage: uiImage)
            }
        }
    }
}

Two things to note. First, matching: .images hides videos in the picker UI itself, so the user can't select something you can't handle. Second, the task(id:) re-runs whenever the item changes, and iOS 26 correctly cancels the in-flight loadTransferable if the user re-taps and picks something new before the first load finishes. That built-in cancellation is why I never wrap the call in a separate Task { }.

How do I select multiple photos with PhotosPicker?

Bind an array of PhotosPickerItem instead of an optional, and pass maxSelectionCount:. Multi-select gives the user a check-mark grid where every tap adds or removes an asset, and the picker only dismisses when they hit "Add". Under the hood you get a stable-order array whose indexes correspond to tap order, which matters when you build carousels.

struct GalleryPicker: View {
    @State private var items: [PhotosPickerItem] = []
    @State private var images: [Image] = []

    var body: some View {
        VStack {
            PhotosPicker(
                "Add up to 8 photos",
                selection: $items,
                maxSelectionCount: 8,
                selectionBehavior: .continuousAndOrdered,
                matching: .images
            )
            .buttonStyle(.borderedProminent)

            ScrollView(.horizontal) {
                LazyHStack(spacing: 12) {
                    ForEach(Array(images.enumerated()), id: \.offset) { _, image in
                        image.resizable().scaledToFill()
                            .frame(width: 100, height: 100)
                            .clipShape(RoundedRectangle(cornerRadius: 12))
                    }
                }
            }
        }
        .task(id: items) {
            images = []
            for item in items {
                if let data = try? await item.loadTransferable(type: Data.self),
                   let ui = UIImage(data: data) {
                    images.append(Image(uiImage: ui))
                }
            }
        }
    }
}

The selectionBehavior parameter has four cases worth knowing. .default shows a single checkmark, .ordered numbers picks in the tap order, .continuous adds a fill animation as the user brushes across images, and .continuousAndOrdered combines both. I default to .continuousAndOrdered for anything above three items because the numeric badges make it obvious which slot each photo will occupy in your carousel. And yes, that "brush" gesture has a lovely 220 ms ease-in curve you can watch with Reveal if you're curious.

Wrap the loop above in a TaskGroup if order doesn't matter; you'll get near-linear speedup on the network-bound iCloud fetches. I've covered that pattern in depth in the TaskGroup guide on structured concurrency.

Filtering images, videos, Live Photos, and screenshots

The matching: parameter takes a PHPickerFilter. The single-value cases you'll reach for most are .images, .videos, .livePhotos, .slomoVideos, .timelapseVideos, .screenshots, .screenRecordings, .depthEffectPhotos, and .bursts. All of these behave as expected; the picker literally hides everything else.

The interesting part is composition. You can build boolean predicates with .any(of:), .all(of:), and .not(_:), and iOS 26 finally lets these nest deeper than one level without triggering the odd "no results" behaviour that plagued iOS 17.

// Photos or videos, but exclude screenshots and screen recordings.
let filter: PHPickerFilter = .any(of: [
    .all(of: [.images, .not(.screenshots)]),
    .all(of: [.videos, .not(.screenRecordings)])
])

PhotosPicker(
    "Attach media",
    selection: $items,
    maxSelectionCount: 5,
    matching: filter,
    preferredItemEncoding: .compatible
)

Two lesser-known filters worth mentioning. .cinematicVideos surfaces iPhone-15-and-newer cinematic-mode clips, and .spatialMedia (iOS 18+) shows Apple Vision Pro spatial photos and videos. If your app supports visionOS, filter on .spatialMedia and load the video as Data to preserve the MV-HEVC track structure. Asking for Image.self will collapse stereo into mono without warning, which cost me an afternoon last year.

Loading strategies: Data vs Image vs FileRepresentation

A PhotosPickerItem is a token, not a payload. You choose what representation to materialise by picking the Transferable type you ask for. There are three practical options, each with a memory profile that gets very different very fast.

ApproachMemoryiCloud downloadBest for
loadTransferable(type: Data.self)Full bytes in RAMBlocks until completeSmall avatars, one-off uploads
loadTransferable(type: Image.self)Decoded PNG in RAMBlocks, then decodesNever (see below)
Custom FileRepresentation~0 kB (URL only)Streams to diskVideos, high-res photos, batches
loadTransferable(type: MyModel.self)Whatever you buildStreams via file URLDownsampling, thumbnailing

The Image.self path is documented as PNG-only through Transferable conformance. It's fine for a screenshot, terrible for a 12 MP HEIC (you'll re-encode the whole thing into a lossless PNG in memory just to show it on screen). Skip it.

Instead, define a tiny wrapper that copies the incoming file into your own sandbox before returning. This gives you a stable URL you can hand to Core Image, AVFoundation, or your upload service without keeping bytes in memory:

struct PickedFile: Transferable {
    let url: URL

    static var transferRepresentation: some TransferRepresentation {
        FileRepresentation(contentType: .image) { file in
            SentTransferredFile(file.url)
        } importing: { received in
            let dst = URL.temporaryDirectory
                .appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
            try FileManager.default.copyItem(at: received.file, to: dst)
            return PickedFile(url: dst)
        }
    }
}

// Usage
let file = try await pickerItem.loadTransferable(type: PickedFile.self)
let thumb = try await ImageRenderer.thumbnail(at: file!.url, maxPixel: 400)

The importing: closure runs off the main actor and is your last chance to touch the source URL before the system reclaims it, so the copy is mandatory. If you want to see the underlying protocol in more depth, I've written up every representation type in the Transferable protocol guide.

Why is PhotosPicker slow? iCloud, HEIC, and progress

The most common bug report I see is "PhotosPicker takes forever". Nine times out of ten it's iCloud Photos: the item on device is a low-res proxy and the full asset lives in Apple's data centre. loadTransferable silently downloads it before your closure runs, and that download can be tens of megabytes over LTE.

You can't avoid the download for the full asset, but you can be honest with the user about what's happening. PhotosPickerItem exposes an itemIdentifier and a supportedContentTypes array you can inspect immediately, and iOS 26 added a loadTransferable(type:progress:) overload that gives you a Progress object to bind to a determinate ProgressView.

@State private var progress = Progress(totalUnitCount: 100)
@State private var isLoading = false

.task(id: item) {
    guard let item else { return }
    isLoading = true
    defer { isLoading = false }
    let result: Result<PickedFile?, Error> = await withCheckedContinuation { cont in
        let p = item.loadTransferable(type: PickedFile.self) { result in
            cont.resume(returning: result)
        }
        progress = p
    }
    // handle result...
}

if isLoading {
    ProgressView(progress)
        .progressViewStyle(.linear)
        .accessibilityLabel("Downloading from iCloud")
}

Also, transcoding matters. If the user picks a 48 MP ProRAW frame and you ask for Data with preferredItemEncoding: .compatible, the picker transcodes to JPEG on your behalf, which is CPU-bound and can add another second on older devices. For thumbnails, always render a downsampled version via ImageIO or ImageRenderer before showing anything. And if you're lazy-loading a grid, wrap it in LazyVGrid, not VGrid, or you'll instantiate every image view up-front.

Presentation styles, inline mode, and disabled capabilities

By default PhotosPicker renders as a button that presents the library as a modal sheet. You can also embed it inline (great for photo pickers that live inside a form) or use the modifier form to present programmatically from another control. There are three tools worth knowing:

  • .photosPickerStyle(.inline) embeds the whole browser directly inside your view hierarchy. It respects the parent's safe-area and works well inside a NavigationStack destination.
  • .photosPickerDisabledCapabilities([.search, .selectionActions]) strips affordances you don't want to expose, which is useful for simplified onboarding flows or kid-facing apps.
  • .photosPickerAccessoryVisibility(.hidden, edges: .bottom) hides the bottom bar, so you can render your own primary action beneath the picker.
PhotosPicker(selection: $items, matching: .images) {
    Label("Add photos", systemImage: "photo.badge.plus")
        .frame(maxWidth: .infinity)
}
.photosPickerStyle(.inline)
.photosPickerDisabledCapabilities(.search)
.photosPickerAccessoryVisibility(.hidden, edges: .bottom)
.frame(minHeight: 400)

The modifier form, .photosPicker(isPresented: $showing, selection: $items), lets you drive the picker from any control, including toolbar buttons and swipe actions. Use it when you need to fire the picker from a menu or a keyboard shortcut, because PhotosPicker-the-view always renders as a button.

Handling race conditions and stale selections

The subtle bug that hurts most in production: the user picks image A, your load starts, they tap "Choose a photo" again before A finishes, and now you have two overlapping loadTransferable tasks whose completion order isn't deterministic. If you naïvely assign the result to state, the wrong image wins. I hit this exact bug shipping our first release and it took the QA team four Wi-Fi-only reproductions to file it.

.task(id: pickerItem) handles this correctly for single-item pickers because it cancels the previous task on identity change. For multi-select and for anything that awaits an upload after the load, add an explicit token.

@State private var loadToken = UUID()
@State private var currentImage: Image?

.task(id: pickerItem) {
    let token = UUID()
    loadToken = token
    guard let pickerItem else { return }
    let data = try? await pickerItem.loadTransferable(type: Data.self)
    // Bail if a newer selection has already superseded us.
    guard token == loadToken else { return }
    if let data, let ui = UIImage(data: data) {
        currentImage = Image(uiImage: ui)
    }
}

The guard token == loadToken check is cheap and covers the case where the user swaps selections in a spot with poor connectivity. Without it, a slow iCloud download can overwrite the freshly selected image seconds after the user has moved on. That's the bug your QA will never reproduce on Wi-Fi.

Making PhotosPicker accessible with VoiceOver

The picker itself is a system-provided view, so its internal controls (search, album switcher, checkmarks) are fully VoiceOver-labelled by Apple. Your job is the entry point and the aftermath: the button that opens it, and the previews of what got selected. Both are usually where I find accessibility bugs when auditing shipping apps.

iOS 26 adds a subtle new container tone when VoiceOver enters the picker sheet, which helps blind users understand that focus has moved into a modal. But if you use the closure form of PhotosPicker to build a custom label, that label is what VoiceOver reads. A Label-with-icon looks great visually and reads horribly as "photo badge plus, image, add photos", so use .accessibilityLabel to override.

PhotosPicker(selection: $items, matching: .images) {
    Label("Add photos", systemImage: "photo.badge.plus")
}
.accessibilityLabel("Add photos")
.accessibilityHint("Opens the photo library to pick images.")

// For selected thumbnails, prompt the user to add a description.
ForEach($attachments) { $attachment in
    attachment.thumbnail
        .accessibilityLabel(attachment.userCaption ?? "Photo, no caption")
        .accessibilityAddTraits(.isImage)
}

Two extra practices I bake in. Run your flow with Reduce Motion enabled (the picker's zoom transition is respected, but your grid animations may not be), and support Dynamic Type up to .accessibility5. The picker label is a system font, so it scales, but a custom overlay with a hard-coded size will clip. If accessibility is new territory for you, the SwiftUI accessibility guide covers Dynamic Type, VoiceOver rotors, and Switch Control for exactly this class of media flow.

Saving selected media to disk for reuse

Once you have a file URL from the FileRepresentation approach, persisting it is straightforward but subtly wrong in most tutorials. The temporary directory isn't guaranteed across app launches; the caches directory can be purged by the system when it's under storage pressure. For anything the user considers "their" content, write to Application Support and exclude it from iCloud backup only if it's cheap to re-derive.

func persist(_ picked: PickedFile) throws -> URL {
    let dir = try FileManager.default.url(
        for: .applicationSupportDirectory,
        in: .userDomainMask,
        appropriateFor: nil,
        create: true
    ).appending(path: "attachments", directoryHint: .isDirectory)
    try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
    let dst = dir.appending(path: picked.url.lastPathComponent)
    if FileManager.default.fileExists(atPath: dst.path()) {
        try FileManager.default.removeItem(at: dst)
    }
    try FileManager.default.moveItem(at: picked.url, to: dst)
    return dst
}

Then store the URL (or better, the filename) in your model layer. SwiftData is the natural fit for this in iOS 26: the model holds a filename string, and you resolve it back to a URL at read time so paths survive backup and restore. I walk through exactly that pattern in the SwiftData production guide. Do not store image bytes in the database itself; every fetch will drag the full attachment into memory.

For remote upload, hand the file URL to URLSession.upload(for:fromFile:). That path uses a background session that keeps running even when your app is suspended, and it's the only way to get reliable multi-gigabyte video uploads. The full pattern is in the modern Swift networking guide.

Two authoritative references I keep bookmarked: the official PhotosPicker documentation, the PHPickerFilter reference for every predicate case, and Apple's FileRepresentation docs for the transfer-side contract.

Frequently Asked Questions

Does PhotosPicker require photo library permission in iOS 26?

No. The picker runs in a separate process owned by Photos, so your app receives only the assets the user explicitly picked. You don't need NSPhotoLibraryUsageDescription in your Info.plist unless you also use PHPhotoLibrary APIs elsewhere.

Can PhotosPicker access the camera or take new photos?

No. PhotosPicker is strictly a library browser. For camera capture, use AVCaptureSession in a SwiftUI wrapper, or fall back to UIImagePickerController with sourceType = .camera. Community libraries like Exyte MediaPicker bundle both, but you own the accessibility work if you go custom.

Why does loadTransferable return nil for some images?

Usually iCloud latency or a codec the requested Transferable type can't represent. Ask for Data.self or a FileRepresentation, set preferredItemEncoding: .compatible, and log the returned error. You'll typically see either a network timeout or "no matching representation".

How do I get the file URL from a PhotosPickerItem?

Define a Transferable wrapper that uses FileRepresentation and copies the received file into a URL you own. The transient URL the system hands you disappears after the closure returns, so persist it immediately if you need it later.

What is the difference between onChange and task(id:) for reacting to a PhotosPickerItem?

.task(id:) automatically cancels the previous asynchronous work when the item changes, preventing stale selections from overwriting fresh ones. .onChange spawns a detached task that keeps running. For any picker load, prefer .task(id:).

Can I preview a selected photo before dismissing the picker?

Not in the sheet form. Use .photosPickerStyle(.inline) to embed the picker inside your own view, then render a preview alongside it. This is how apps like Messages show a "typing area with attached photo" without leaving the compose sheet.

Ava Thompson
About the Author Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.