| 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
<key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to share them</string>
<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
) {
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
}
if let data item.loadTransferable(type: .),
uiImage (data: data) {
selectedImage (uiImage: uiImage)
}
}
}
Multi-selection:
@State private var selectedItems: [PhotosPickerItem] = []
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 5,
matching: .images
) {
Text("Select Photos")
}
Advanced Filters (iOS 15+/16+)
matching: .screenshots
matching: .screenRecordings
matching: .sloMoVideos
matching: .cinematicVideos
matching: .depthEffectPhotos
matching: .bursts
matching: .any(of: [.videos, .livePhotos])
matching: .all(of: [.images, .not(.screenshots)])
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 {
SelectedPhotosGrid(items: selectedItems)
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 10,
selectionBehavior: .continuous,
matching: .images
) {
Text("Select")
}
.photosPickerStyle(.inline)
.photosPickerDisabledCapabilities([.selectionActions])
.photosPickerAccessoryVisibility(.hidden, edges: .all)
.frame(height: 300)
.ignoresSafeArea(.container, edges: .bottom)
}
}
}
Picker Styles:
| Style | Description |
|---|
.presentation | Default modal sheet |
.inline | Embedded in your view hierarchy |
.compact | Single row, minimal vertical space |
Customization modifiers:
.photosPickerAccessoryVisibility(.hidden, edges: .all)
.photosPickerAccessoryVisibility(.hidden, edges: .top)
.photosPickerAccessoryVisibility(.hidden, edges: .bottom)
.photosPickerDisabledCapabilities([.search])
.photosPickerDisabledCapabilities([.collectionNavigation])
.photosPickerDisabledCapabilities([.stagingArea])
.photosPickerDisabledCapabilities([.selectionActions])
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
config.filter = .images
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 }
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
guard let image = object as? UIImage else { return }
.main.async {
.displayImage(image)
}
}
}
}
Filter options:
config.filter = .images
config.filter = .videos
config.filter = .livePhotos
config.filter = .any(of: [.images, .videos])
config.filter = .all(of: [.images, .not(.screenshots)])
config.filter = .cinematicVideos
config.filter = .depthEffectPhotos
config.filter = .bursts
UIKit Embedded Picker (iOS 17+)
var config = PHPickerConfiguration()
config.selection = .continuous
config.mode = .compact
config.selectionLimit = 10
config.edgesWithoutContentMargins = .all
config.disabledCapabilities = [.search, .selectionActions]
let picker = PHPickerViewController(configuration: config)
picker.delegate = self
addChild(picker)
containerView.addSubview(picker.view)
picker.view.frame = containerView.bounds
picker.didMove(toParent: self)
Updating picker while displayed (iOS 17+):
picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"])
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:
PhotosPicker(
selection: $selectedItems,
matching: .images,
preferredItemEncoding: .current
) { ... }
struct HDRImage: Transferable {
let data: Data
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(importedContentType: .image) { data in
HDRImage(data: data)
}
}
}
let result = try await item.loadTransferable(type: HDRImage.self)
UIKit equivalent:
var config = PHPickerConfiguration()
config.preferredAssetRepresentationMode = .current
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:
<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:
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:
PHPhotoLibrary.shared().register(self)
func photoLibraryDidChange(_ changeInstance: PHChange) {
}
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 {
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)
}
}
func savePhotoData(_ data: Data, metadata: [String: Any]? = nil) async throws {
try await PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension()
data.write(to: tempURL)
request.addResource(with: .photo, fileURL: tempURL, options: )
}
}
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.
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
}
}
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 fetchResult fetchResult,
changes changeInstance.changeDetails(for: fetchResult) {
}
.main.async {
.fetchResult changes.fetchResultAfterChanges
.photos changes.fetchResultAfterChanges.objects(at:
(changes.fetchResultAfterChanges.count)
)
}
}
}
Cost: 30 min implementation
Anti-Patterns
Anti-Pattern 1: Requesting Full Access for Photo Picking
Wrong:
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
if status == .authorized {
showPhotoPicker()
}
Right:
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()
}
Right:
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .authorized:
showGallery()
case .limited:
showGallery()
showLimitedBanner()
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:
let data = try! selectedItem.loadTransferable(type: Data.self)
Right:
Task {
if let data = try? await selectedItem.loadTransferable(type: Data.self) {
}
}
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
.limited status
- 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
.limited status (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