mcpField
P
photos-camera-media

photos-camera-media

github.com/dpearson2699開発1.2k456 回インストール13 週間前に更新
リポジトリ

photos-camera-media について

iOS の写真、カメラ、メディア機能を開発する Swift 開発者向けのガイドです。選択や撮影、権限処理、非同期読み込み、大きな画像の縮小表示を扱います。

主な機能

  1. 01SwiftUI の写真選択とメディア絞り込み
  2. 02写真とカメラの権限処理
  3. 03AVCaptureSession によるカメラ撮影
  4. 04非同期画像読み込みとダウンサンプリング
  5. 05メディア開発のチェックリスト

活用シーン

  1. 01単数または複数の写真選択の追加
  2. 02カメラプレビューと撮影画面の構築
  3. 03メディア権限の要求と処理
  4. 04リストでの大きなユーザー写真の表示

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill photos-camera-media

インストール後、Claude Code で/photos-camera-mediaを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
av-playback.md
camera-capture.md
image-loading-caching.md
photospicker-patterns.md

詳細

写真、カメラ、メディア

iOS 26 以降と Swift 6.2 を対象にした、写真の選択、カメラでの撮影、画像の読み込み、メディア権限に関する最新のパターンです。特に記載がない限り、iOS 16 以降との下位互換性があります。

ピッカーのレシピ全般については references/photospicker-patterns.md、AVCaptureSession のパターンについては references/camera-capture.md を参照してください。

目次

PhotosPicker(SwiftUI、iOS 16 以降)

PhotosPicker は、SwiftUI で UIImagePickerController を置き換えるネイティブの手段です。プロセス外で動作するため、写真を閲覧する際にフォトライブラリの権限は不要で、メディアタイプで絞り込みながら、単一選択または複数選択に対応できます。

単一選択
import SwiftUI
import PhotosUI

struct SinglePhotoPicker: View {
    @State private var selectedItem: PhotosPickerItem?
    @State private var selectedImage: Image?

    var body: some View {
        VStack {
            if let selectedImage {
                selectedImage
                    .resizable()
                    .scaledToFit()
                    .frame(maxHeight: 300)
            }

            PhotosPicker("Select Photo", selection: $selectedItem, matching: .images)
        }
        .onChange(of: selectedItem) { _, newItem in
            Task {
                if let data = try? await newItem?.loadTransferable(type: Data.self),
                   let uiImage = UIImage(data: data) {
                    selectedImage = Image(uiImage: uiImage)
                }
            }
        }
    }
}
複数選択
struct MultiPhotoPicker: View {
    @State private var selectedItems: [PhotosPickerItem] = []
    @State private var selectedImages: [Image] = []

    var body: some View {
        VStack {
            ScrollView(.horizontal) {
                HStack {
                    ForEach(selectedImages.indices, id: \.self) { index in
                        selectedImages[index]
                            .resizable()
                            .scaledToFill()
                            .frame(width: 100, height: 100)
                            .clipShape(RoundedRectangle(cornerRadius: 8))
                    }
                }
            }

            PhotosPicker(
                "Select Photos",
                selection: $selectedItems,
                maxSelectionCount: 5,
                matching: .images
            )
        }
        .onChange(of: selectedItems) { _, newItems in
            Task {
                selectedImages = []
                for item in newItems {
                    if let data = try? await item.loadTransferable(type: Data.self),
                       let uiImage = UIImage(data: data) {
                        selectedImages.append(Image(uiImage: uiImage))
                    }
                }
            }
        }
    }
}
メディアタイプによる絞り込み

PHPickerFilter の複合条件で、選択可能なメディアを絞り込みます。

// Images only
PhotosPicker(selection: $items, matching: .images)

// Videos only
PhotosPicker(selection: $items, matching: .videos)

// Live Photos only
PhotosPicker(selection: $items, matching: .livePhotos)

// Screenshots only
PhotosPicker(selection: $items, matching: .screenshots)

// Images and videos combined
PhotosPicker(selection: $items, matching: .any(of: [.images, .videos]))

// Images excluding screenshots
PhotosPicker(selection: $items, matching: .all(of: [.images, .not(.screenshots)]))
Transferable を使った選択項目の読み込み

PhotosPickerItem は、loadTransferable(type:) を使ってコンテンツを非同期で読み込みます。自動デコード用に Transferable 型を定義します。

struct PickedImage: Transferable {
    let data: Data
    let image: Image

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

enum TransferError: Error {
    case importFailed
}

// Usage
if let picked = try? await item.loadTransferable(type: PickedImage.self) {
    selectedImage = picked.image
}

メインスレッドをブロックしないよう、必ず Task 内で読み込みます。nil が返される場合やエラーがスローされる場合にも対応してください。ユーザーがデコードできない形式を選択する可能性があります。

プライバシーと権限

フォトライブラリのアクセスレベル

iOS では、フォトライブラリに対して 2 種類のアクセスレベルが提供されています。アプリが .readWrite アクセスを要求すると、システムは自動的に限定フォトライブラリのピッカーを表示し、ユーザーは共有する写真を選択できます。

アクセスレベル説明Info.plist キー
追加のみ写真を読み込まずにライブラリへ書き込むNSPhotoLibraryAddUsageDescription
読み書き全件または一部の読み取りアクセスと書き込みNSPhotoLibraryUsageDescription

PhotosPicker での閲覧には権限は不要です。プロセス外で動作し、選択された項目へのアクセスのみを許可します。フォトライブラリ全体の読み込み(カスタムギャラリーなど)や写真の保存が必要な場合に限り、明示的に権限を要求してください。

フォトライブラリの権限の確認と要求
import Photos

func requestPhotoLibraryAccess() async -> PHAuthorizationStatus {
    let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)

    switch status {
    case .notDetermined:
        return await PHPhotoLibrary.requestAuthorization(for: .readWrite)
    case .authorized, .limited:
        return status
    case .denied, .restricted:
        return status
    @unknown default:
        return status
    }
}
カメラの権限

Info.plist に NSCameraUsageDescription を追加します。キャプチャセッションを設定する前に、アクセス権を確認して要求してください。

import AVFoundation

func requestCameraAccess() async -> Bool {
    let status = AVCaptureDevice.authorizationStatus(for: .video)

    switch status {
    case .notDetermined:
        return await AVCaptureDevice.requestAccess(for: .video)
    case .authorized:
        return true
    case .denied, .restricted:
        return false
    @unknown default:
        return false
    }
}
拒否された権限への対応

ユーザーがアクセスを拒否した場合は、「設定」へ誘導してください。繰り返し許可を求めたり、機能を何も案内せずに非表示にしたりしないでください。

struct PermissionDeniedView: View {
    let message: String
    @Environment(\.openURL) private var openURL

    var body: some View {
        ContentUnavailableView {
            Label("Access Denied", systemImage: "lock.shield")
        } description: {
            Text(message)
        } actions: {
            Button("Open Settings") {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    openURL(url)
                }
            }
        }
    }
}
必須の Info.plist キー
キー必要となる場面
NSPhotoLibraryUsageDescriptionフォトライブラリから写真を読み込む場合
NSPhotoLibraryAddUsageDescriptionフォトライブラリに写真やビデオを保存する場合
NSCameraUsageDescriptionカメラにアクセスする場合
NSMicrophoneUsageDescription音声を録音する場合(音声付きビデオ)

必須キーがない場合、権限ダイアログが表示されるタイミングで実行時クラッシュが発生します。

カメラ撮影の基本

カメラセッションは専用の @Observable モデルで管理します。Representable ビューはプレビューの表示のみを担当します。パターン全般については references/camera-capture.md を参照してください。

最小限のカメラマネージャー
import AVFoundation

@available(iOS 17.0, *)
@Observable
@MainActor
final class CameraManager {
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var currentDevice: AVCaptureDevice?

    var isRunning = false
    var capturedImage: Data?

    func configure() async {
        guard await requestCameraAccess() else { return }

        session.beginConfiguration()
        session.sessionPreset = .photo

        // Add camera input
        guard let device = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                    for: .video,
                                                    position: .back) else { return }
        currentDevice = device

        guard let input = try? AVCaptureDeviceInput(device: device),
              session.canAddInput(input) else { return }
        session.addInput(input)

        // Add photo output
        guard session.canAddOutput(photoOutput) else { return }
        session.addOutput(photoOutput)

        session.commitConfiguration()
    }

    func start() {
        guard !session.isRunning else { return }
        Task.detached { [session] in
            session.startRunning()
        }
        isRunning = true
    }

    func stop() {
        guard session.isRunning else { return }
        Task.detached { [session] in
            session.stopRunning()
        }
        isRunning = false
    }

    private func requestCameraAccess() async -> Bool {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        if status == .notDetermined {
            return await AVCaptureDevice.requestAccess(for: .video)
        }
        return status == .authorized
    }
}

AVCaptureSession の開始と停止はバックグラウンドキューで行ってください。startRunning() と stopRunning() は同期処理であり、呼び出し元のスレッドをブロックします。

SwiftUI でのカメラプレビュー

AVCaptureVideoPreviewLayer を UIViewRepresentable でラップします。自動リサイズのために layerClass をオーバーライドしてください。

import SwiftUI
import AVFoundation

struct CameraPreview: UIViewRepresentable {
    let session: AVCaptureSession

    func makeUIView(context: Context) -> PreviewView {
        let view = PreviewView()
        view.previewLayer.session = session
        view.previewLayer.videoGravity = .resizeAspectFill
        return view
    }

    func updateUIView(_ uiView: PreviewView, context: Context) {
        if uiView.previewLayer.session !== session {
            uiView.previewLayer.session = session
        }
    }
}

final class PreviewView: UIView {
    override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
    var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
ビューでのカメラの使用
struct CameraScreen: View {
    @State private var cameraManager = CameraManager()

    var body: some View {
        ZStack(alignment: .bottom) {
            CameraPreview(session: cameraManager.session)
                .ignoresSafeArea()

            Button {
                // Capture photo -- see references/camera-capture.md
            } label: {
                Circle()
                    .fill(.white)
                    .frame(width: 72, height: 72)
                    .overlay(Circle().stroke(.gray, lineWidth: 3))
            }
            .padding(.bottom, 32)
        }
        .task {
            await cameraManager.configure()
            cameraManager.start()
        }
        .onDisappear {
            cameraManager.stop()
        }
    }
}

onDisappear では必ず stop() を呼び出してください。キャプチャセッションが動作中だとカメラを占有し、バッテリーを消耗します。

画像の読み込みと表示

リモート画像に対する AsyncImage
AsyncImage(url: imageURL) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image
            .resizable()
            .scaledToFill()
    case .failure:
        Image(systemName: "photo")
            .foregroundStyle(.secondary)
    @unknown default:
        EmptyView()
    }
}
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))

AsyncImage はビューの再描画をまたいで画像をキャッシュしません。画像を多数扱う本番アプリでは、専用の画像読み込みライブラリを使うか、URLCache ベースのキャッシュを実装してください。

大きな画像のダウンサンプリング

メモリ使用量の急増を防ぐため、ライブラリからフル解像度の写真を読み込み、表示サイズの CGImage に変換します。48MP の写真は、非圧縮の状態で 200 MB を超えることがあります。

import ImageIO
import UIKit

func downsample(data: Data, to pointSize: CGSize, scale: CGFloat = UITraitCollection.current.displayScale) -> UIImage? {
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale

    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
    ]

    guard let source = CGImageSourceCreateWithData(data as CFData, nil),
          let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}

リスト、グリッド、サムネイルにユーザーが選択した写真を表示する場合は、必ずこの方法を使用してください。UIImage を作成する前に、PhotosPickerItem の生の Data をそのままダウンサンプラーに渡します。

画像のレンダリングモード
// Original: display the image as-is with its original colors
Image("photo")
    .renderingMode(.original)

// Template: treat the image as a mask, colored by foregroundStyle
Image(systemName: "heart.fill")
    .renderingMode(.template)
    .foregroundStyle(.red)

写真やアートワークには .original を使用してください。現在のティントカラーを適用するアイコンには .template を使用してください。

よくある間違い

禁止: 写真の選択に UIImagePickerController を使用する。 推奨: PhotosPicker(SwiftUI)または PHPickerViewController(UIKit)を使用する。 理由: UIImagePickerController は機能が限られたレガシー API です。PhotosPicker は別プロセスで動作し、複数選択に対応しており、ライブラリを参照するための権限も不要です。

禁止: ユーザーに写真を選択してもらうだけなのに、フォトライブラリへの完全なアクセスを要求する。 推奨: 権限が不要な PhotosPicker を使用するか、.readWrite を要求して、制限付きアクセスをシステムに処理させる。 理由: 写真を選んで使用する大半のワークフローでは、完全なアクセスは不要です。システムの制限付きライブラリピッカーはユーザーのプライバシーを尊重しながら、選択された項目へのアクセスを許可します。

禁止: サムネイルのためにフル解像度の画像をメモリに読み込む。 推奨: CGImageSource と kCGImageSourceThumbnailMaxPixelSize を使用してダウンサンプリングする。48MP の画像は、非圧縮の状態で 200 MB を超えます。

禁止: PhotosPickerItem のデータを読み込む際にメインスレッドをブロックする。 推奨: Task 内で async loadTransferable(type:) を使用する。

禁止: ビューが表示されなくなったときに AVCaptureSession を停止し忘れる。 推奨: onDisappear または dismantleUIView で session.stopRunning() を呼び出す。

禁止: カメラへのアクセスが許可されていると確認せずに決めつける。 推奨: AVCaptureDevice.authorizationStatus(for: .video) を確認し、.denied/.restricted に対処する。

禁止: メインスレッドで session.startRunning() を呼び出す。 推奨: Task.detached または専用のシリアルキューを使用して、バックグラウンドスレッドに処理を移す。 理由: startRunning() は同期的なブロッキング呼び出しであり、ハードウェアの初期化中に数百ミリ秒かかることがあります。

禁止: UIViewRepresentable 内で AVCaptureSession を作成する。 推奨: セッションは別の @Observable モデルで管理する。

レビューチェックリスト

  • 非推奨の UIImagePickerController ではなく PhotosPicker を使用している
  • カメラ/フォトライブラリのプライバシー説明を Info.plist に記載している
  • 画像/動画の非同期読み込み中の状態を処理している
  • 表示前に大きな画像を CGImageSource でダウンサンプリングしている
  • カメラセッションをバックグラウンドスレッドで開始し、onDisappear で停止している
  • 権限が拒否された場合に Settings へのディープリンクで対処している
  • AVCaptureSession をモデルで管理し、UIViewRepresentable 内では作成していない
  • 並行処理の境界を越えて渡す際、メディアアセットの型とピッカーの結果が Sendable である

参考資料

  • references/photospicker-patterns.md — ピッカーのパターン、メディアの読み込み、HEIC の処理
  • references/camera-capture.md — AVCaptureSession、写真/動画の撮影、QR スキャン
  • references/image-loading-caching.md — AsyncImage、キャッシュ、ダウンサンプリング
  • references/av-playback.md — AVPlayer、ストリーミング、オーディオセッション、バックグラウンドオーディオ

コメント