PHPicker, PhotosPicker, photo selection, limited library access, presentLimitedLibraryPicker, save to camera roll, PHPhotoLibrary, PHAssetCreationRequest, Transferable, PhotosPickerItem, photo permissions
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
skills listSkill Instructions
name: axiom-photo-library description: PHPicker, PhotosPicker, photo selection, limited library access, presentLimitedLibraryPicker, save to camera roll, PHPhotoLibrary, PHAssetCreationRequest, Transferable, PhotosPickerItem, photo permissions skill_type: discipline version: 1.0.0 last_updated: 2026-01-03 apple_platforms: iOS 14+, iPadOS 14+, macOS 13+
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
.limitedauthorization 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
.authorizedafter 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
<!-- Required for any PhotoKit access -->
<key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to share them</string>
<!-- Required if saving photos -->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos to your library</string>
Core Patterns
Pattern 1: SwiftUI PhotosPicker (iOS 16+)
Use case: Let users select photos in a SwiftUI app.
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:
@State private var selectedItems: [PhotosPickerItem] = []
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 5,
matching: .images
) {
Text("Select Photos")
}
Advanced Filters (iOS 15+/16+)
// 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.
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:
// 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.
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:
// 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+)
// 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+):
// 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:
// 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:
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:
<!-- Info.plist - Add this to handle limited access UI yourself -->
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
Manual limited access handling:
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:
// 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.
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.
// 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:
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.
import Photos
class PhotoGalleryViewModel: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
@Published var photos: [PHAsset] = []
private var fetchResult: PHFetchResult<PHAsset>?
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..<changes.fetchResultAfterChanges.count)
)
}
}
}
Cost: 30 min implementation
Anti-Patterns
Anti-Pattern 1: Requesting Full Access for Photo Picking
Wrong:
// Over-requesting - picker doesn't need this!
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
if status == .authorized {
showPhotoPicker()
}
Right:
// Just show the picker - no permission needed
PhotosPicker(selection: $item, matching: .images) {
Text("Select Photo")
}
Why it matters: PHPicker and PhotosPicker handle privacy automatically. Requesting library access when you only need to pick photos is a privacy violation and may cause App Store rejection.
Anti-Pattern 2: Ignoring Limited Status
Wrong:
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if status == .authorized {
showGallery()
} else {
showPermissionDenied() // Wrong! .limited is valid
}
Right:
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .authorized:
showGallery()
case .limited:
showGallery() // Works with limited selection
showLimitedBanner() // Explain to user
case .denied, .restricted:
showPermissionDenied()
case .notDetermined:
requestAccess()
@unknown default:
break
}
Why it matters: iOS 14+ users can grant limited access. Treating it as denied frustrates users.
Anti-Pattern 3: Synchronous Image Loading
Wrong:
// Blocks UI thread
let data = try! selectedItem.loadTransferable(type: Data.self)
Right:
Task {
if let data = try? await selectedItem.loadTransferable(type: Data.self) {
// Use data
}
}
Why it matters: Large photos (RAW, panoramas) take seconds to load. Blocking UI causes ANR.
Anti-Pattern 4: Using UIImagePickerController for Photo Selection
Wrong:
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
present(picker, animated: true)
Right:
var config = PHPickerConfiguration()
config.filter = .images
let picker = PHPickerViewController(configuration: config)
present(picker, animated: true)
Why it matters: UIImagePickerController is deprecated for photo selection. PHPicker is more reliable, handles large assets, and provides better privacy.
Pressure Scenarios
Scenario 1: "Just Get Photo Access Working"
Context: Product wants photo import feature. You're considering requesting full library access "to be safe."
Pressure: "Users will just tap Allow anyway."
Reality: Since iOS 14, users can grant limited access. Full access request triggers additional privacy prompt. App Store Review may reject unnecessary permission requests.
Correct action:
- Use PhotosPicker or PHPicker (no permission needed)
- Only request .readWrite if building a gallery browser
- Only request .addOnly if just saving photos
Push-back template: "PHPicker works without any permission request - users can select photos directly. Requesting library access when we only need picking is a privacy violation that App Store Review may flag."
Scenario 2: "Users Say They Can't See Their Photos"
Context: Support tickets about "no photos available" even though user granted access.
Pressure: "Just ask for full access again."
Reality: User likely granted .limited access and selected 0 photos initially.
Correct action:
- Check for
.limitedstatus - Show
presentLimitedLibraryPicker()to let user add photos - Explain in UI: "Tap here to add more photos"
Push-back template: "The user has limited access - they need to expand their selection. I'll add a button that opens the limited library picker so they can add more photos."
Scenario 3: "Photo Loads Taking Forever"
Context: Users complain photo picker is slow to display selected images.
Pressure: "Can you cache or preload somehow?"
Reality: Large photos (RAW, panoramas, Live Photos) are slow to decode. Solution is UX, not caching.
Correct action:
- Show loading placeholder immediately
- Load thumbnail first, full image second
- Show progress indicator for large files
- Use async/await to avoid blocking
Push-back template: "Large photos take time to load - that's physics. I'll show a placeholder immediately and load progressively. For the picker UI, thumbnail loading is already optimized by the system."
Checklist
Before shipping photo library features:
Permission Strategy:
- ☑ Using PHPicker/PhotosPicker for simple selection (no permission needed)
- ☑ Only requesting .readWrite if building gallery UI
- ☑ Only requesting .addOnly if only saving photos
- ☑ Info.plist usage descriptions present
Limited Library:
- ☑ Handling
.limitedstatus (not treating as denied) - ☑ Offering
presentLimitedLibraryPicker()for users to add photos - ☑ UI explains limited access to users
Image Loading:
- ☑ All loading is async (no UI blocking)
- ☑ Custom Transferable handles JPEG/HEIF (not just PNG)
- ☑ Error handling for failed loads
- ☑ Loading indicator for large files
Saving Photos:
- ☑ Using .addOnly when full access not needed
- ☑ Using performChanges for atomic operations
- ☑ Handling save failures gracefully
Photo Library Changes:
- ☑ Registered as PHPhotoLibraryChangeObserver if displaying library
- ☑ Updating UI on main thread after changes
- ☑ Unregistering observer in deinit
Resources
WWDC: 2020-10652, 2020-10641, 2022-10023, 2023-10107
Docs: /photosui/phpickerviewcontroller, /photosui/photospicker, /photos/phphotolibrary
Skills: axiom-photo-library-ref, axiom-camera-capture
More by CharlesWiltgen
View allUse when porting OpenGL/DirectX to Metal - translation layer vs native rewrite decisions, migration planning, anti-patterns
Use when UI is slow, scrolling lags, animations stutter, or when asking 'why is my SwiftUI view slow', 'how do I optimize List performance', 'my app drops frames', 'view body is called too often', 'List is laggy' - SwiftUI performance optimization with Instruments 26 and WWDC 2025 patterns
Use for Core Location troubleshooting - no location updates, background location broken, authorization denied, geofence not triggering
Reference — Comprehensive SwiftUI navigation guide covering NavigationStack (iOS 16+), NavigationSplitView (iOS 16+), NavigationPath, deep linking, state restoration, Tab+Navigation integration (iOS 18+), Liquid Glass navigation (iOS 26+), and coordinator patterns