Agent Skills: Photo Library API Reference

Reference — PHPickerViewController, PHPickerConfiguration, PhotosPicker, PhotosPickerItem, Transferable, PHPhotoLibrary, PHAsset, PHAssetCreationRequest, PHFetchResult, PHAuthorizationStatus, limited library APIs

UncategorizedID: charleswiltgen/axiom/axiom-photo-library-ref

Install this agent skill to your local

pnpm dlx add-skill https://github.com/CharlesWiltgen/Axiom/tree/HEAD/.claude-plugin/plugins/axiom/skills/axiom-photo-library-ref

Skill Files

Browse the full folder contents for axiom-photo-library-ref.

Download Skill

Loading file tree…

.claude-plugin/plugins/axiom/skills/axiom-photo-library-ref/SKILL.md

Skill Metadata

Name
axiom-photo-library-ref
Description
Reference — PHPickerViewController, PHPickerConfiguration, PhotosPicker, PhotosPickerItem, Transferable, PHPhotoLibrary, PHAsset, PHAssetCreationRequest, PHFetchResult, PHAuthorizationStatus, limited library APIs

Photo Library API Reference

Quick Reference

// SWIFTUI PHOTO PICKER (iOS 16+)
import PhotosUI

@State private var item: PhotosPickerItem?

PhotosPicker(selection: $item, matching: .images) {
    Text("Select Photo")
}
.onChange(of: item) { _, newItem in
    Task {
        if let data = try? await newItem?.loadTransferable(type: Data.self) {
            // Use image data
        }
    }
}

// UIKIT PHOTO PICKER (iOS 14+)
var config = PHPickerConfiguration()
config.selectionLimit = 1
config.filter = .images
let picker = PHPickerViewController(configuration: config)
picker.delegate = self

// SAVE TO CAMERA ROLL
try await PHPhotoLibrary.shared().performChanges {
    PHAssetCreationRequest.creationRequestForAsset(from: image)
}

// CHECK PERMISSION
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)

PHPickerViewController (iOS 14+)

System photo picker for UIKit apps. No permission required.

Configuration

import PhotosUI

var config = PHPickerConfiguration()

// Selection limit (0 = unlimited)
config.selectionLimit = 5

// Filter by asset type
config.filter = .images

// Use photo library (enables asset identifiers)
config = PHPickerConfiguration(photoLibrary: .shared())

// Preferred asset representation
config.preferredAssetRepresentationMode = .automatic  // default
// .current - original format
// .compatible - converted to compatible format

Filter Options

// Basic filters
PHPickerFilter.images
PHPickerFilter.videos
PHPickerFilter.livePhotos

// Combined filters
PHPickerFilter.any(of: [.images, .videos])

// Exclusion filters (iOS 15+)
PHPickerFilter.all(of: [.images, .not(.screenshots)])
PHPickerFilter.not(.livePhotos)

// Playback style filters (iOS 17+)
PHPickerFilter.any(of: [.cinematicVideos, .slomoVideos])

Presenting

let picker = PHPickerViewController(configuration: config)
picker.delegate = self
present(picker, animated: true)

Delegate

extension ViewController: PHPickerViewControllerDelegate {

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        picker.dismiss(animated: true)

        for result in results {
            // Get asset identifier (if using PHPickerConfiguration(photoLibrary:))
            let identifier = result.assetIdentifier

            // Load as UIImage
            result.itemProvider.loadObject(ofClass: UIImage.self) { object, error in
                guard let image = object as? UIImage else { return }
                DispatchQueue.main.async {
                    self.displayImage(image)
                }
            }

            // Load as Data
            result.itemProvider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, error in
                guard let data else { return }
                // Use data
            }

            // Load Live Photo
            result.itemProvider.loadObject(ofClass: PHLivePhoto.self) { object, error in
                guard let livePhoto = object as? PHLivePhoto else { return }
                // Use live photo
            }
        }
    }
}

PHPickerResult Properties

| Property | Type | Description | |----------|------|-------------| | itemProvider | NSItemProvider | Provides selected asset data | | assetIdentifier | String? | PHAsset identifier (if using photoLibrary config) |


PhotosPicker (SwiftUI, iOS 16+)

SwiftUI view for photo selection. No permission required.

Basic Usage

import SwiftUI
import PhotosUI

// Single selection
@State private var selectedItem: PhotosPickerItem?

PhotosPicker(selection: $selectedItem, matching: .images) {
    Label("Select Photo", systemImage: "photo")
}

// Multiple selection
@State private var selectedItems: [PhotosPickerItem] = []

PhotosPicker(
    selection: $selectedItems,
    maxSelectionCount: 5,
    matching: .images
) {
    Text("Select Photos")
}

Filters

// Images only
matching: .images

// Videos only
matching: .videos

// Images and videos
matching: .any(of: [.images, .videos])

// Live Photos
matching: .livePhotos

// Exclude screenshots (iOS 15+)
matching: .all(of: [.images, .not(.screenshots)])

Selection Behavior

PhotosPicker(
    selection: $items,
    maxSelectionCount: 10,
    selectionBehavior: .ordered,  // .default, .ordered, .continuous
    matching: .images
) { ... }

| Behavior | Description | |----------|-------------| | .default | Standard multi-select | | .ordered | Selection order preserved | | .continuous | Live updates as user selects (iOS 17+) |

Embedded Picker (iOS 17+)

PhotosPicker(
    selection: $items,
    maxSelectionCount: 10,
    selectionBehavior: .continuous,
    matching: .images
) {
    Text("Select")
}
.photosPickerStyle(.inline)  // Embed in view hierarchy
.photosPickerDisabledCapabilities([.selectionActions])
.photosPickerAccessoryVisibility(.hidden, edges: .all)

| Style | Description | |-------|-------------| | .presentation | Modal sheet (default) | | .inline | Embedded in view | | .compact | Single row |

| Disabled Capability | Effect | |---------------------|--------| | .search | Hide search bar | | .collectionNavigation | Hide albums | | .stagingArea | Hide selection review | | .selectionActions | Hide Add/Cancel |

| Accessory Visibility | Description | |----------------------|-------------| | .hidden, .automatic, .visible | Per edge |

HDR Preservation (iOS 17+)

PhotosPicker(
    selection: $items,
    matching: .images,
    preferredItemEncoding: .current  // Don't transcode, preserve HDR
) { ... }

| Encoding | Description | |----------|-------------| | .automatic | System decides format | | .current | Original format, preserves HDR | | .compatible | Force compatible format |

Loading Images from PhotosPickerItem

// Load as Data (most reliable)
if let data = try? await item.loadTransferable(type: Data.self),
   let image = UIImage(data: data) {
    // Use image
}

// Custom Transferable for direct UIImage
struct ImageTransferable: Transferable {
    let image: UIImage

    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(importedContentType: .image) { data in
            guard let image = UIImage(data: data) else {
                throw TransferError.importFailed
            }
            return ImageTransferable(image: image)
        }
    }
}

// Usage
if let result = try? await item.loadTransferable(type: ImageTransferable.self) {
    let image = result.image
}

PhotosPickerItem Properties

| Property | Type | Description | |----------|------|-------------| | itemIdentifier | String | Unique identifier | | supportedContentTypes | [UTType] | Available representations |

PhotosPickerItem Methods

// Load transferable
func loadTransferable<T: Transferable>(type: T.Type) async throws -> T?

// Load with progress
func loadTransferable<T: Transferable>(
    type: T.Type,
    completionHandler: @escaping (Result<T?, Error>) -> Void
) -> Progress

PHPhotoLibrary

Access and modify the photo library.

Authorization Status

// Check current status
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)

// Request authorization
let newStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite)

PHAuthorizationStatus

| Status | Description | |--------|-------------| | .notDetermined | User hasn't been asked | | .restricted | Parental controls limit access | | .denied | User denied access | | .authorized | Full access granted | | .limited | Access to user-selected photos only (iOS 14+) |

Access Levels

// Read and write
PHPhotoLibrary.requestAuthorization(for: .readWrite)

// Add only (save photos, no reading)
PHPhotoLibrary.requestAuthorization(for: .addOnly)

Limited Library Picker

// Present picker to expand limited selection
@MainActor
func presentLimitedLibraryPicker() {
    guard let viewController = UIApplication.shared.keyWindow?.rootViewController else { return }
    PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController)
}

// With completion handler
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) { identifiers in
    // identifiers: asset IDs user added
}

Performing Changes

// Async changes
try await PHPhotoLibrary.shared().performChanges {
    // Create, update, or delete assets
}

// With completion handler
PHPhotoLibrary.shared().performChanges({
    // Changes
}) { success, error in
    // Handle result
}

Change Observer

class PhotoObserver: NSObject, PHPhotoLibraryChangeObserver {

    override init() {
        super.init()
        PHPhotoLibrary.shared().register(self)
    }

    deinit {
        PHPhotoLibrary.shared().unregisterChangeObserver(self)
    }

    func photoLibraryDidChange(_ changeInstance: PHChange) {
        // Handle changes
        guard let changes = changeInstance.changeDetails(for: fetchResult) else { return }

        DispatchQueue.main.async {
            // Update UI with new fetch result
            let newResult = changes.fetchResultAfterChanges
        }
    }
}

PHAsset

Represents an asset in the photo library.

Fetching Assets

// All photos
let allPhotos = PHAsset.fetchAssets(with: .image, options: nil)

// With options
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
options.fetchLimit = 100
options.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue)

let recentPhotos = PHAsset.fetchAssets(with: options)

// By identifier
let assets = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil)

Asset Properties

| Property | Type | Description | |----------|------|-------------| | localIdentifier | String | Unique ID | | mediaType | PHAssetMediaType | .image, .video, .audio | | mediaSubtypes | PHAssetMediaSubtype | .photoLive, .photoPanorama, etc. | | pixelWidth | Int | Width in pixels | | pixelHeight | Int | Height in pixels | | creationDate | Date? | When taken | | modificationDate | Date? | Last modified | | location | CLLocation? | GPS location | | duration | TimeInterval | Video duration | | isFavorite | Bool | Marked as favorite | | isHidden | Bool | In hidden album |

PHAssetMediaType

| Type | Value | |------|-------| | .unknown | 0 | | .image | 1 | | .video | 2 | | .audio | 3 |

PHAssetMediaSubtype

| Subtype | Description | |---------|-------------| | .photoPanorama | Panoramic photo | | .photoHDR | HDR photo | | .photoScreenshot | Screenshot | | .photoLive | Live Photo | | .photoDepthEffect | Portrait mode | | .videoStreamed | Streamed video | | .videoHighFrameRate | Slo-mo video | | .videoTimelapse | Timelapse | | .videoCinematic | Cinematic mode |


PHAssetCreationRequest

Create new assets in the photo library.

Creating from UIImage

try await PHPhotoLibrary.shared().performChanges {
    PHAssetCreationRequest.creationRequestForAsset(from: image)
}

Creating from File URL

try await PHPhotoLibrary.shared().performChanges {
    PHAssetCreationRequest.creationRequestForAssetFromImage(atFileURL: imageURL)
}

// For video
try await PHPhotoLibrary.shared().performChanges {
    PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: videoURL)
}

Creating with Resources

try await PHPhotoLibrary.shared().performChanges {
    let request = PHAssetCreationRequest.forAsset()

    // Add photo resource
    let options = PHAssetResourceCreationOptions()
    options.shouldMoveFile = true  // Move instead of copy

    request.addResource(with: .photo, fileURL: photoURL, options: options)

    // Set creation date
    request.creationDate = Date()

    // Set location
    request.location = CLLocation(latitude: 37.7749, longitude: -122.4194)
}

Deferred Photo Proxy (iOS 17+)

Save camera proxy photos for background processing:

// From AVCaptureDeferredPhotoProxy callback
try await PHPhotoLibrary.shared().performChanges {
    let request = PHAssetCreationRequest.forAsset()

    // Use .photoProxy to trigger deferred processing
    request.addResource(with: .photoProxy, data: proxyData, options: nil)
}

| Resource Type | Description | |---------------|-------------| | .photo | Standard photo | | .video | Video file | | .photoProxy | Deferred processing proxy (iOS 17+) | | .adjustmentData | Edit adjustments |

Getting Created Asset

try await PHPhotoLibrary.shared().performChanges {
    let request = PHAssetCreationRequest.forAsset()
    request.addResource(with: .photo, fileURL: url, options: nil)

    // Get placeholder for later fetching
    let placeholder = request.placeholderForCreatedAsset
    // placeholder.localIdentifier available after changes complete
}

Custom Albums

// Create a custom album
func getOrCreateAlbum(named title: String) async throws -> PHAssetCollection {
    // Check if album already exists
    let fetchOptions = PHFetchOptions()
    fetchOptions.predicate = NSPredicate(format: "title = %@", title)
    let existing = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: fetchOptions)
    if let album = existing.firstObject { return album }

    // Create new album
    var placeholder: PHObjectPlaceholder?
    try await PHPhotoLibrary.shared().performChanges {
        let request = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: title)
        placeholder = request.placeholderForCreatedAssetCollection
    }
    guard let id = placeholder?.localIdentifier,
          let album = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [id], options: nil).firstObject
    else { throw PhotoError.albumCreationFailed }
    return album
}

// Save photo to custom album
func saveToAlbum(_ image: UIImage, album: PHAssetCollection) async throws {
    try await PHPhotoLibrary.shared().performChanges {
        let assetRequest = PHAssetCreationRequest.creationRequestForAsset(from: image)
        guard let placeholder = assetRequest.placeholderForCreatedAsset,
              let albumRequest = PHAssetCollectionChangeRequest(for: album) else { return }
        albumRequest.addAssets([placeholder] as NSFastEnumeration)
    }
}

PHFetchResult

Ordered list of assets from a fetch.

Properties

| Property | Type | Description | |----------|------|-------------| | count | Int | Number of items | | firstObject | T? | First item | | lastObject | T? | Last item |

Methods

// Access by index
let asset = fetchResult.object(at: 0)
let asset = fetchResult[0]

// Get multiple
let assets = fetchResult.objects(at: IndexSet(0..<10))

// Iteration
fetchResult.enumerateObjects { asset, index, stop in
    // Process asset
    if shouldStop {
        stop.pointee = true
    }
}

// Check contains
let contains = fetchResult.contains(asset)
let index = fetchResult.index(of: asset)

PHImageManager

Request images from assets.

Request Image

let manager = PHImageManager.default()

let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.resizeMode = .exact
options.isNetworkAccessAllowed = true  // For iCloud photos

let targetSize = CGSize(width: 300, height: 300)

manager.requestImage(
    for: asset,
    targetSize: targetSize,
    contentMode: .aspectFill,
    options: options
) { image, info in
    guard let image else { return }

    // Check if this is the final image
    let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
    if !isDegraded {
        // Final high-quality image
    }
}

PHImageRequestOptions

| Property | Type | Description | |----------|------|-------------| | deliveryMode | PHImageRequestOptionsDeliveryMode | Quality preference | | resizeMode | PHImageRequestOptionsResizeMode | Resize behavior | | isNetworkAccessAllowed | Bool | Allow iCloud download | | isSynchronous | Bool | Synchronous request | | progressHandler | Block | Download progress | | allowSecondaryDegradedImage | Bool | Extra callback during deferred processing (iOS 17+) |

Secondary Degraded Image (iOS 17+)

For photos undergoing deferred processing, get an intermediate quality image:

let options = PHImageRequestOptions()
options.allowSecondaryDegradedImage = true

// Callback order:
// 1. Low quality (immediate, isDegraded = true)
// 2. Medium quality (new, isDegraded = true) -- while processing
// 3. Final quality (isDegraded = false)

Delivery Modes

| Mode | Description | |------|-------------| | .opportunistic | Fast thumbnail, then high quality | | .highQualityFormat | Only high quality | | .fastFormat | Only fast/degraded |

Request Video

manager.requestAVAsset(forVideo: asset, options: nil) { avAsset, audioMix, info in
    guard let avAsset else { return }
    // Use AVAsset for playback
}

// Or export to file
manager.requestExportSession(
    forVideo: asset,
    options: nil,
    exportPreset: AVAssetExportPresetHighestQuality
) { session, info in
    session?.outputURL = outputURL
    session?.outputFileType = .mp4
    session?.exportAsynchronously { ... }
}

PHChange

Represents changes to the photo library.

Getting Change Details

func photoLibraryDidChange(_ changeInstance: PHChange) {
    guard let changes = changeInstance.changeDetails(for: fetchResult) else { return }

    // Check what changed
    let hasIncrementalChanges = changes.hasIncrementalChanges
    let insertedIndexes = changes.insertedIndexes
    let removedIndexes = changes.removedIndexes
    let changedIndexes = changes.changedIndexes

    // Get new fetch result
    let newResult = changes.fetchResultAfterChanges

    // Update collection view
    DispatchQueue.main.async {
        if hasIncrementalChanges {
            collectionView.performBatchUpdates {
                if let removed = removedIndexes {
                    collectionView.deleteItems(at: removed.map { IndexPath(item: $0, section: 0) })
                }
                if let inserted = insertedIndexes {
                    collectionView.insertItems(at: inserted.map { IndexPath(item: $0, section: 0) })
                }
                if let changed = changedIndexes {
                    collectionView.reloadItems(at: changed.map { IndexPath(item: $0, section: 0) })
                }
            }
        } else {
            collectionView.reloadData()
        }
    }
}

Common Code Patterns

Complete Photo Gallery View

import SwiftUI
import Photos

@MainActor
class PhotoGalleryViewModel: ObservableObject {
    @Published var assets: [PHAsset] = []
    @Published var authorizationStatus: PHAuthorizationStatus = .notDetermined

    func requestAccess() async {
        authorizationStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite)

        if authorizationStatus == .authorized || authorizationStatus == .limited {
            fetchAssets()
        }
    }

    func fetchAssets() {
        let options = PHFetchOptions()
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        options.fetchLimit = 100

        let result = PHAsset.fetchAssets(with: .image, options: options)
        assets = result.objects(at: IndexSet(0..<result.count))
    }

    func expandLimitedAccess(from viewController: UIViewController) {
        PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController)
    }
}

struct PhotoGalleryView: View {
    @StateObject private var viewModel = PhotoGalleryViewModel()

    var body: some View {
        Group {
            switch viewModel.authorizationStatus {
            case .authorized, .limited:
                PhotoGridView(assets: viewModel.assets)
            case .denied, .restricted:
                PermissionDeniedView()
            case .notDetermined:
                RequestAccessView {
                    Task { await viewModel.requestAccess() }
                }
            @unknown default:
                EmptyView()
            }
        }
        .task {
            await viewModel.requestAccess()
        }
    }
}

Resources

Docs: /photosui/phpickerviewcontroller, /photosui/photospicker, /photos/phphotolibrary, /photos/phasset, /photos/phimagemanager

Skills: axiom-photo-library, axiom-camera-capture

Photo Library API Reference Skill | Agent Skills