--- name: axiom-photo-library description: PHPicker, PhotosPicker, photo selection, limited library access, presentLimitedLibraryPicker, save to camera roll, PHPhotoLibrary, PHAssetCreationRequest, Transferable, PhotosPickerItem, photo permissions license: MIT compatibility: iOS 14+, iPadOS 14+, macOS 13+ metadata: version: "1.0.0" last-updated: "2026-01-03" --- # Photo Library Access with PhotoKit Guides you through photo picking, limited library handling, and saving photos to the camera roll using privacy-forward patterns. ## When to Use This Skill Use when you need to: - ☑ Let users select photos from their library - ☑ Handle limited photo library access - ☑ Save photos/videos to the camera roll - ☑ Choose between PHPicker and PhotosPicker - ☑ Load images from PhotosPickerItem - ☑ Observe photo library changes - ☑ Request appropriate permission level ## Example Prompts "How do I let users pick photos in SwiftUI?" "User says they can't see their photos" "How do I save a photo to the camera roll?" "What's the difference between PHPicker and PhotosPicker?" "How do I handle limited photo access?" "User granted limited access but can't see photos" "How do I load an image from PhotosPickerItem?" ## Red Flags Signs you're making this harder than it needs to be: - ❌ Using UIImagePickerController (deprecated for photo selection) - ❌ Requesting full library access when picker suffices (privacy violation) - ❌ Ignoring `.limited` authorization status (users can't expand selection) - ❌ Not handling Transferable loading failures (crashes on large photos) - ❌ Synchronously loading images from picker results (blocks UI) - ❌ Using PhotoKit APIs when you only need to pick photos (over-engineering) - ❌ Assuming `.authorized` after user grants access (could be `.limited`) ## Mandatory First Steps Before implementing photo library features: ### 1. Choose Your Approach ``` What do you need? ┌─ User picks photos (no library browsing)? │ ├─ SwiftUI app → PhotosPicker (iOS 16+) │ └─ UIKit app → PHPickerViewController (iOS 14+) │ └─ NO library permission needed! Picker handles it. │ ├─ Display user's full photo library (gallery UI)? │ └─ Requires PHPhotoLibrary authorization │ └─ Request .readWrite for browsing │ └─ Handle .limited status with presentLimitedLibraryPicker │ ├─ Save photos to camera roll? │ └─ Requires PHPhotoLibrary authorization │ └─ Request .addOnly (minimal) or .readWrite │ └─ Just capture with camera? └─ Don't use PhotoKit - see camera-capture skill ``` ### 2. Understand Permission Levels | Level | What It Allows | Request Method | |-------|---------------|----------------| | No permission | User picks via system picker | PHPicker/PhotosPicker (automatic) | | `.addOnly` | Save to camera roll only | `requestAuthorization(for: .addOnly)` | | `.limited` | User-selected subset only | User chooses in system UI | | `.authorized` | Full library access | `requestAuthorization(for: .readWrite)` | **Key insight**: PHPicker and PhotosPicker require NO permission. The system handles privacy. ### 3. Info.plist Keys ```xml NSPhotoLibraryUsageDescription Access your photos to share them NSPhotoLibraryAddUsageDescription Save photos to your library ``` ## Core Patterns ### Pattern 1: SwiftUI PhotosPicker (iOS 16+) **Use case**: Let users select photos in a SwiftUI app. ```swift import SwiftUI import PhotosUI struct ContentView: View { @State private var selectedItem: PhotosPickerItem? @State private var selectedImage: Image? var body: some View { VStack { PhotosPicker( selection: $selectedItem, matching: .images // Filter to images only ) { Label("Select Photo", systemImage: "photo") } if let image = selectedImage { image .resizable() .scaledToFit() } } .onChange(of: selectedItem) { _, newItem in Task { await loadImage(from: newItem) } } } private func loadImage(from item: PhotosPickerItem?) async { guard let item else { selectedImage = nil return } // Load as Data first (more reliable than Image) if let data = try? await item.loadTransferable(type: Data.self), let uiImage = UIImage(data: data) { selectedImage = Image(uiImage: uiImage) } } } ``` **Multi-selection**: ```swift @State private var selectedItems: [PhotosPickerItem] = [] PhotosPicker( selection: $selectedItems, maxSelectionCount: 5, matching: .images ) { Text("Select Photos") } ``` #### Advanced Filters (iOS 15+/16+) ```swift // Screenshots only matching: .screenshots // Screen recordings only matching: .screenRecordings // Slo-mo videos matching: .sloMoVideos // Cinematic videos (iOS 16+) matching: .cinematicVideos // Depth effect photos matching: .depthEffectPhotos // Bursts matching: .bursts // Compound filters with .any, .all, .not // Videos AND Live Photos matching: .any(of: [.videos, .livePhotos]) // All images EXCEPT screenshots matching: .all(of: [.images, .not(.screenshots)]) // All images EXCEPT screenshots AND panoramas matching: .all(of: [.images, .not(.any(of: [.screenshots, .panoramas]))]) ``` **Cost**: 15 min implementation, no permissions required ### Pattern 1b: Embedded PhotosPicker (iOS 17+) **Use case**: Embed picker inline in your UI instead of presenting as sheet. ```swift import SwiftUI import PhotosUI struct EmbeddedPickerView: View { @State private var selectedItems: [PhotosPickerItem] = [] var body: some View { VStack { // Your content above picker SelectedPhotosGrid(items: selectedItems) // Embedded picker fills available space PhotosPicker( selection: $selectedItems, maxSelectionCount: 10, selectionBehavior: .continuous, // Live updates as user taps matching: .images ) { // Label is ignored for inline style Text("Select") } .photosPickerStyle(.inline) // Embed instead of present .photosPickerDisabledCapabilities([.selectionActions]) // Hide Add/Cancel buttons .photosPickerAccessoryVisibility(.hidden, edges: .all) // Hide nav/toolbar .frame(height: 300) // Control picker height .ignoresSafeArea(.container, edges: .bottom) // Extend to bottom edge } } } ``` **Picker Styles**: | Style | Description | |-------|-------------| | `.presentation` | Default modal sheet | | `.inline` | Embedded in your view hierarchy | | `.compact` | Single row, minimal vertical space | **Customization modifiers**: ```swift // Hide navigation/toolbar accessories .photosPickerAccessoryVisibility(.hidden, edges: .all) .photosPickerAccessoryVisibility(.hidden, edges: .top) // Just navigation bar .photosPickerAccessoryVisibility(.hidden, edges: .bottom) // Just toolbar // Disable capabilities (hides UI for them) .photosPickerDisabledCapabilities([.search]) // Hide search .photosPickerDisabledCapabilities([.collectionNavigation]) // Hide albums .photosPickerDisabledCapabilities([.stagingArea]) // Hide selection review .photosPickerDisabledCapabilities([.selectionActions]) // Hide Add/Cancel // Continuous selection for live updates selectionBehavior: .continuous ``` **Privacy note**: First time an embedded picker appears, iOS shows an onboarding UI explaining your app can only access selected photos. A privacy badge indicates the picker is out-of-process. ### Pattern 2: UIKit PHPickerViewController (iOS 14+) **Use case**: Photo selection in UIKit apps. ```swift import PhotosUI class PhotoPickerViewController: UIViewController, PHPickerViewControllerDelegate { func showPicker() { var config = PHPickerConfiguration() config.selectionLimit = 1 // 0 = unlimited config.filter = .images // or .videos, .any(of: [.images, .videos]) let picker = PHPickerViewController(configuration: config) picker.delegate = self present(picker, animated: true) } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let result = results.first else { return } // Load image asynchronously result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in guard let image = object as? UIImage else { return } DispatchQueue.main.async { self?.displayImage(image) } } } } ``` **Filter options**: ```swift // Images only config.filter = .images // Videos only config.filter = .videos // Live Photos only config.filter = .livePhotos // Images and videos config.filter = .any(of: [.images, .videos]) // Exclude screenshots (iOS 15+) config.filter = .all(of: [.images, .not(.screenshots)]) // iOS 16+ filters config.filter = .cinematicVideos config.filter = .depthEffectPhotos config.filter = .bursts ``` #### UIKit Embedded Picker (iOS 17+) ```swift // Configure for embedded use var config = PHPickerConfiguration() config.selection = .continuous // Live updates instead of waiting for Add button config.mode = .compact // Single row layout (optional) config.selectionLimit = 10 // Hide accessories config.edgesWithoutContentMargins = .all // No margins around picker // Disable capabilities config.disabledCapabilities = [.search, .selectionActions] let picker = PHPickerViewController(configuration: config) picker.delegate = self // Add as child view controller (required for embedded) addChild(picker) containerView.addSubview(picker.view) picker.view.frame = containerView.bounds picker.didMove(toParent: self) ``` **Updating picker while displayed (iOS 17+)**: ```swift // Deselect assets by their identifiers picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"]) // Reorder assets in selection picker.moveAsset(withIdentifier: "assetID", afterAssetWithIdentifier: "otherID") ``` **Cost**: 20 min implementation, no permissions required ### Pattern 2b: Options Menu & HDR Support (iOS 17+) The picker now shows an Options menu letting users choose to strip location metadata from photos. This works automatically with PhotosPicker and PHPicker. **Preserving HDR content**: By default, picker may transcode to JPEG, losing HDR data. To receive original format: ```swift // SwiftUI - Use .current encoding to preserve HDR PhotosPicker( selection: $selectedItems, matching: .images, preferredItemEncoding: .current // Don't transcode ) { ... } // Loading with original format preservation struct HDRImage: Transferable { let data: Data static var transferRepresentation: some TransferRepresentation { DataRepresentation(importedContentType: .image) { data in HDRImage(data: data) } } } // Request .image content type (generic) not .jpeg (specific) let result = try await item.loadTransferable(type: HDRImage.self) ``` **UIKit equivalent**: ```swift var config = PHPickerConfiguration() config.preferredAssetRepresentationMode = .current // Don't transcode ``` **Cinematic mode videos**: Picker returns rendered version with depth effects baked in. To get original with decision points, use PhotoKit with library access instead. ### Pattern 3: Handling Limited Library Access **Use case**: User granted limited access; let them add more photos. **Suppressing automatic prompt** (iOS 14+): By default, iOS shows "Select More Photos" prompt when `.limited` is detected. To handle it yourself: ```xml PHPhotoLibraryPreventAutomaticLimitedAccessAlert ``` **Manual limited access handling**: ```swift import Photos class PhotoLibraryManager { func checkAndRequestAccess() async -> PHAuthorizationStatus { let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) switch status { case .notDetermined: return await PHPhotoLibrary.requestAuthorization(for: .readWrite) case .limited: // User granted limited access - show UI to expand await presentLimitedLibraryPicker() return .limited case .authorized: return .authorized case .denied, .restricted: return status @unknown default: return status } } @MainActor func presentLimitedLibraryPicker() { guard let windowScene = UIApplication.shared.connectedScenes .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController else { return } PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: rootVC) } } ``` **Observe limited selection changes**: ```swift // Register for changes PHPhotoLibrary.shared().register(self) // In delegate func photoLibraryDidChange(_ changeInstance: PHChange) { // User may have modified their limited selection // Refresh your photo grid } ``` **Cost**: 30 min implementation ### Pattern 4: Saving Photos to Camera Roll **Use case**: Save captured or edited photos. ```swift import Photos func saveImageToLibrary(_ image: UIImage) async throws { // Request add-only permission (minimal access) let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) guard status == .authorized || status == .limited else { throw PhotoError.permissionDenied } try await PHPhotoLibrary.shared().performChanges { PHAssetCreationRequest.creationRequestForAsset(from: image) } } // With metadata preservation func savePhotoData(_ data: Data, metadata: [String: Any]? = nil) async throws { try await PHPhotoLibrary.shared().performChanges { let request = PHAssetCreationRequest.forAsset() // Write data to temp file for addResource let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension("jpg") try? data.write(to: tempURL) request.addResource(with: .photo, fileURL: tempURL, options: nil) } } ``` **Cost**: 15 min implementation ### Pattern 5: Loading Images from PhotosPickerItem **Use case**: Properly handle async image loading with error handling. **The problem**: Default `Image` Transferable only supports PNG. Most photos are JPEG/HEIF. ```swift // Custom Transferable for any image format struct TransferableImage: 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 TransferableImage(image: image) } } enum TransferError: Error { case importFailed } } // Usage func loadImage(from item: PhotosPickerItem) async -> UIImage? { do { let result = try await item.loadTransferable(type: TransferableImage.self) return result?.image } catch { print("Failed to load image: \(error)") return nil } } ``` **Loading with progress**: ```swift func loadImageWithProgress(from item: PhotosPickerItem) async -> UIImage? { let progress = Progress() return await withCheckedContinuation { continuation in _ = item.loadTransferable(type: TransferableImage.self) { result in switch result { case .success(let transferable): continuation.resume(returning: transferable?.image) case .failure: continuation.resume(returning: nil) } } } } ``` **Cost**: 20 min implementation ### Pattern 6: Observing Photo Library Changes **Use case**: Keep your gallery UI in sync with Photos app. ```swift import Photos class PhotoGalleryViewModel: NSObject, ObservableObject, PHPhotoLibraryChangeObserver { @Published var photos: [PHAsset] = [] private var fetchResult: PHFetchResult? override init() { super.init() PHPhotoLibrary.shared().register(self) fetchPhotos() } deinit { PHPhotoLibrary.shared().unregisterChangeObserver(self) } func fetchPhotos() { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] fetchResult = PHAsset.fetchAssets(with: .image, options: options) photos = fetchResult?.objects(at: IndexSet(0..<(fetchResult?.count ?? 0))) ?? [] } func photoLibraryDidChange(_ changeInstance: PHChange) { guard let fetchResult = fetchResult, let changes = changeInstance.changeDetails(for: fetchResult) else { return } DispatchQueue.main.async { self.fetchResult = changes.fetchResultAfterChanges self.photos = changes.fetchResultAfterChanges.objects(at: IndexSet(0..