axiom-photo-library について
PhotoKit のプライバシーに配慮した機能実装を支援する、Swift 開発者向けのスキルです。写真の選択や保存、限定アクセス、ライブラリ変更の監視を扱い、適切な API の選択と権限・読み込みの問題回避に役立ちます。
主な機能
- 01PhotosPicker と PHPicker の利用ガイド
- 02限定アクセス権の処理
- 03写真のカメラロールへの保存
- 04選択画像の非同期読み込み
- 05フォトライブラリ変更の監視
活用シーン
- 01SwiftUI または UIKit アプリへの写真選択機能の追加
- 02編集画像のカメラロールへの保存
- 03限定アクセスを許可したユーザーへの対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-photo-libraryインストール後、Claude Code で/axiom-photo-libraryを入力して読み込まれたか確認します。
詳細
PhotoKit による写真ライブラリへのアクセス
プライバシーを重視した方法で、写真の選択、制限付きライブラリの処理、カメラロールへの写真の保存を行う方法を説明します。
このスキルを使うタイミング
次のような場合に使用します。
- ☑ ユーザーがライブラリから写真を選択できるようにする
- ☑ 制限付きの写真ライブラリアクセスを処理する
- ☑ 写真やビデオをカメラロールに保存する
- ☑ PHPicker と PhotosPicker のどちらを使うか選ぶ
- ☑ PhotosPickerItem から画像を読み込む
- ☑ 写真ライブラリの変更を監視する
- ☑ 適切な権限レベルを要求する
プロンプトの例
「SwiftUI でユーザーが写真を選べるようにするにはどうすればよいですか?」 「ユーザーが写真を見られないと言っています」 「写真をカメラロールに保存するにはどうすればよいですか?」 「PHPicker と PhotosPicker の違いは何ですか?」 「制限付きの写真アクセスを処理するにはどうすればよいですか?」 「ユーザーは制限付きアクセスを許可しましたが、写真を見られません」 「PhotosPickerItem から画像を読み込むにはどうすればよいですか?」
注意すべき兆候
必要以上に複雑にしている兆候です。
- ❌ UIImagePickerController を使っている(写真選択には非推奨です)
- ❌ ピッカーで十分なのにライブラリ全体へのアクセスを要求している(プライバシー侵害です)
- ❌
.limitedの認証ステータスを無視している(ユーザーが選択範囲を広げられません) - ❌ Transferable の読み込み失敗を処理していない(大きな写真でクラッシュします)
- ❌ ピッカーの結果から画像を同期的に読み込んでいる(UI がブロックされます)
- ❌ 写真を選ぶだけでよいのに PhotoKit API を使っている(過剰な設計です)
- ❌ ユーザーがアクセスを許可した後は
.authorizedになると思い込んでいる(.limitedの場合もあります)
必須の最初の手順
写真ライブラリ機能を実装する前に、次の手順を行います。
1. アプローチを選ぶ
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. 権限レベルを理解する
| レベル | 許可される操作 | リクエスト方法 |
|---|---|---|
| 権限なし | ユーザーがシステムピッカーで選択 | PHPicker/PhotosPicker(自動) |
.addOnly | カメラロールへの保存のみ | requestAuthorization(for: .addOnly) |
.limited | ユーザーが選択した項目のみ | ユーザーがシステム UI で選択 |
.authorized | ライブラリ全体へのアクセス | requestAuthorization(for: .readWrite) |
重要なポイント:PHPicker と PhotosPicker に権限は不要です。プライバシーの処理はシステムが行います。
3. Info.plist のキー
<key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to share them</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos to your library</string>
基本パターン
パターン 1:SwiftUI PhotosPicker(iOS 16 以降)
ユースケース:SwiftUI アプリでユーザーが写真を選択できるようにします。
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)
}
}
}
複数選択:
@State private var selectedItems: [PhotosPickerItem] = []
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 5,
matching: .images
) {
Text("Select Photos")
}
高度なフィルター(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]))])
コスト:実装に 15 分、権限は不要
パターン 1b:埋め込み型 PhotosPicker(iOS 17 以降)
ユースケース:シートとして表示する代わりに、ピッカーを UI にインラインで埋め込みます。
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
}
}
}
ピッカーのスタイル:
| スタイル | 説明 |
|---|---|
.presentation | デフォルトのモーダルシート |
.inline | ビュー階層に埋め込み |
.compact | 縦方向のスペースを最小限にした単一行 |
カスタマイズ用モディファイア:
// 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
プライバシーに関する注意:埋め込み型ピッカーを初めて表示すると、iOS はアプリが選択された写真にのみアクセスできることを説明するオンボーディング UI を表示します。プライバシーバッジは、ピッカーが別プロセスで動作していることを示します。
パターン 2:UIKit PHPickerViewController(iOS 14 以降)
ユースケース:UIKit アプリで写真を選択します。
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)
}
}
}
}
フィルターのオプション:
// 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 の埋め込み型ピッカー(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)
表示中のピッカーを更新する(iOS 17 以降):
// Deselect assets by their identifiers
picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"])
// Reorder assets in selection
picker.moveAsset(withIdentifier: "assetID", afterAssetWithIdentifier: "otherID")
コスト:実装に 20 分、権限は不要
パターン 2b:オプションメニューと HDR のサポート(iOS 17 以降)
ピッカーにオプションメニューが表示され、ユーザーは写真から位置情報のメタデータを削除するよう選択できます。これは PhotosPicker と PHPicker で自動的に機能します。
HDR コンテンツを保持する:
デフォルトでは、ピッカーは JPEG にトランスコードする場合があり、その結果 HDR データが失われます。元の形式で受け取るには、次のようにします。
// 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 での同等の方法:
var config = PHPickerConfiguration()
config.preferredAssetRepresentationMode = .current // Don't transcode
シネマティックモードのビデオ:ピッカーは、深度エフェクトが適用済みのレンダリング版を返します、判断ポイントを含む元のビデオを取得するには、ライブラリアクセス権を使って PhotoKit を使用します。
パターン 3:制限付きライブラリへのアクセスを処理する
ユースケース:ユーザーが制限付きアクセスを許可している場合に、写真を追加できるようにします。
自動プロンプトを抑制する(iOS 14 以降):
デフォルトでは、.limited が検出されると、iOS は「写真をさらに選択」プロンプトを表示します。独自に処理するには、次のようにします。
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
制限付きアクセスを手動で処理する:
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)
}
}
制限付きの選択内容の変更を監視する:
// Register for changes
PHPhotoLibrary.shared().register(self)
// In delegate
func photoLibraryDidChange(_ changeInstance: PHChange) {
// User may have modified their limited selection
// Refresh your photo grid
}
所要時間:実装に 30 分
パターン 4:写真をカメラロールに保存する
ユースケース:撮影または編集した写真を保存します。
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)
}
}
所要時間:実装に 15 分
パターン 5:PhotosPickerItem から画像を読み込む
ユースケース:エラー処理を行いながら、非同期の画像読み込みを適切に処理します。
問題点:デフォルトの Image Transferable がサポートするのは PNG のみです。ほとんどの写真は 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
}
}
進行状況を表示して読み込む:
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)
}
}
}
}
所要時間:実装に 20 分
パターン 6:フォトライブラリの変更を監視する
ユースケース:ギャラリー UI を写真アプリと同期した状態に保ちます。
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)
)
}
}
}
所要時間:実装に 30 分
アンチパターン
アンチパターン 1:写真の選択にフルアクセスを要求する
誤り:
// Over-requesting - picker doesn't need this!
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
if status == .authorized {
showPhotoPicker()
}
正しい方法:
// Just show the picker - no permission needed
PhotosPicker(selection: $item, matching: .images) {
Text("Select Photo")
}
重要な理由:PHPicker と PhotosPicker はプライバシーを自動的に処理します。写真を選択するだけなのにライブラリへのアクセスを要求することはプライバシー侵害であり、App Store で却下される可能性があります。
アンチパターン 2:制限付きステータスを無視する
誤り:
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if status == .authorized {
showGallery()
} else {
showPermissionDenied() // Wrong! .limited is valid
}
正しい方法:
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
}
重要な理由:iOS 14 以降のユーザーは、制限付きアクセスを許可できます。それを拒否として扱うと、ユーザーに不満を与えます。
アンチパターン 3:同期的な画像読み込み
誤り:
// Blocks UI thread
let data = try! selectedItem.loadTransferable(type: Data.self)
正しい方法:
Task {
if let data = try? await selectedItem.loadTransferable(type: Data.self) {
// Use data
}
}
重要な理由:大きな写真(RAW、パノラマ)の読み込みには数秒かかります。UI をブロックすると ANR が発生します。
アンチパターン 4:写真の選択に UIImagePickerController を使用する
誤り:
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
present(picker, animated: true)
正しい方法:
var config = PHPickerConfiguration()
config.filter = .images
let picker = PHPickerViewController(configuration: config)
present(picker, animated: true)
重要な理由:UIImagePickerController は写真の選択には非推奨です。PHPicker のほうが信頼性が高く、大きなアセットを処理でき、プライバシー保護にも優れています。
プレッシャーのかかる状況
シナリオ 1:「とにかく写真へのアクセスを機能させて」
状況:プロダクトチームが写真のインポート機能を求めています。「念のため」フォトライブラリへのフルアクセスを要求しようとしています。
プレッシャー:「ユーザーはとにかく『許可』をタップするでしょう。」
実態:iOS 14 以降、ユーザーは制限付きアクセスを許可できます。フルアクセスを要求すると、追加のプライバシープロンプトが表示されます。不必要な権限要求は App Store Review で却下される可能性があります。
正しい対応:
- PhotosPicker または PHPicker を使用します(権限は不要です)
- ギャラリーを閲覧する機能を作る場合に限り、.readWrite を要求します
- 写真を保存するだけの場合に限り、.addOnly を要求します
反論の例:「PHPicker は権限を要求せずに使えます。ユーザーは写真を直接選択できます。写真を選択するだけなのにライブラリへのアクセスを要求するのはプライバシー侵害であり、App Store Review で指摘される可能性があります。」
シナリオ 2:「写真が表示されないとユーザーから言われています」
状況:ユーザーがアクセスを許可しているにもかかわらず、「利用できる写真がない」というサポートへの問い合わせが寄せられています。
プレッシャー:「もう一度フルアクセスを要求すればいい。」
実態:ユーザーは .limited アクセスを許可し、最初に写真を 1 枚も選択しなかった可能性があります。
正しい対応:
.limitedステータスかどうかを確認しますpresentLimitedLibraryPicker()を表示し、ユーザーが写真を追加できるようにします- UI で「ここをタップして写真をさらに追加」と説明します
反論の例:「ユーザーのアクセスは制限付きです。選択範囲を広げてもらう必要があります。制限付きライブラリのピッカーを開くボタンを追加して、写真をさらに追加できるようにします。」
シナリオ 3:「写真の読み込みに時間がかかる」
状況:選択した画像が写真ピッカーに表示されるまで時間がかかると、ユーザーから不満が寄せられています。
プレッシャー:「何かキャッシュしたり、事前に読み込んだりできませんか?」
現実:大きな写真(RAW、パノラマ、Live Photos)はデコードに時間がかかります。解決策はキャッシュではなく、UX の改善です。
適切な対応:
- すぐに読み込み中のプレースホルダーを表示する
- まずサムネイルを読み込み、次にフルサイズ画像を読み込む
- 大きなファイルでは進行状況インジケーターを表示する
- UI をブロックしないよう、async/await を使用する
反論用テンプレート:「大きな写真の読み込みには時間がかかります。これは物理的な制約です。すぐにプレースホルダーを表示し、段階的に読み込みます。ピッカー UI では、サムネイルの読み込みはすでにシステムによって最適化されています。」
チェックリスト
写真ライブラリ機能をリリースする前に確認してください。
権限の方針:
- ☑ シンプルな選択には PHPicker/PhotosPicker を使用している(権限は不要)
- ☑ ギャラリー UI を作成する場合に限り .readWrite をリクエストしている
- ☑ 写真の保存のみを行う場合に限り .addOnly をリクエストしている
- ☑ Info.plist に使用目的の説明が記載されている
制限付きライブラリ:
- ☑
.limitedのステータスに対応している(拒否として扱っていない) - ☑ ユーザーが写真を追加できるよう
presentLimitedLibraryPicker()を提供している - ☑ UI で制限付きアクセスについてユーザーに説明している
画像の読み込み:
- ☑ すべての読み込みが非同期である(UI をブロックしない)
- ☑ カスタムの Transferable が JPEG/HEIF に対応している(PNG だけではない)
- ☑ 読み込み失敗時のエラー処理を実装している
- ☑ 大きなファイルでは読み込みインジケーターを表示している
写真の保存:
- ☑ フルアクセスが不要な場合は .addOnly を使用している
- ☑ アトミックな操作には performChanges を使用している
- ☑ 保存の失敗を適切に処理している
写真ライブラリの変更:
- ☑ ライブラリを表示する場合、PHPhotoLibraryChangeObserver として登録している
- ☑ 変更後、メインスレッドで UI を更新している
- ☑ deinit でオブザーバーの登録を解除している
リソース
WWDC:2020-10652, 2020-10641, 2022-10023, 2023-10107
ドキュメント:/photosui/phpickerviewcontroller, /photosui/photospicker, /photos/phphotolibrary
スキル:axiom-photo-library-ref, axiom-camera-capture