[SwiftUI] PhotosPickerを使って複数画像を選択する方法

# はじめに

`PhotosPicker`を使って複数の写真を選択する方法をサンプルコードを参考に調べてみました。

https://developer.apple.com/documentation/photokit/implementing_an_inline_photos_picker

PhotosPickerの基本的な使い方は以下の記事でまとめました。

https://qiita.com/yoshitaka/items/d99d89b3e19216fb4c2a

# 環境
Xcode 15.4
iOS17 ~

# 内容

[サンプルコード](https://developer.apple.com/documentation/photokit/implementing_an_inline_photos_picker
)をそのまま動かすとこのように複数の画像を選択できるライブラリがインラインで表示されます

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/2d67674d-7c97-4eb5-1cf4-07a4515048e7.png" width="200"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/a74f821d-caf9-31c3-e817-dacd1bd1c6be.png" width="200">

```swift: View
import SwiftUI
import PhotosUI

struct InlineMultiPhotosPickerView: View {
    @StateObject private var viewModel = InlineMultiPhotosPickerViewModel()

    var body: some View {
        NavigationStack {
            VStack {
                ImageList(viewModel: viewModel)

                PhotosPicker(
                    selection: $viewModel.selection,
                    selectionBehavior: .continuousAndOrdered,
                    matching: .images,
                    preferredItemEncoding: .current,
                    photoLibrary: .shared()
                ) {
                    Text("写真を選択")
                }

                // 半分の高さのPhotosピッカーを構成
                .photosPickerStyle(.inline)

                // インラインのユースケースでキャンセルボタンを無効にする
                .photosPickerDisabledCapabilities(.selectionActions)

                // ピッカーUIのすべての端にあるアクセサリー（ツールバーなど）を非表示にする
                .photosPickerAccessoryVisibility(.hidden, edges: .all)
                .ignoresSafeArea()
                .frame(height: 200)
            }
            .navigationTitle("画像の説明")
            .ignoresSafeArea(.keyboard)
        }
    }
}

struct ImageList: View {
    @ObservedObject var viewModel: InlineMultiPhotosPickerViewModel

    var body: some View {
        if viewModel.attachments.isEmpty {
            Spacer()
            Image(systemName: "text.below.photo")
                .font(.system(size: 150))
                .opacity(0.2)
            Spacer()
        } else {
            List(viewModel.attachments) { imageAttachment in
                ImageAttachmentView(imageAttachment: imageAttachment)
            }
            .listStyle(.plain)
        }
    }
}

struct ImageAttachmentView: View {
    @ObservedObject var imageAttachment: InlineMultiPhotosPickerViewModel.ImageAttachment

    var body: some View {
        HStack {
            TextField("画像の説明", text: $imageAttachment.imageDescription)
            Spacer()

            switch imageAttachment.imageStatus {
            case .finished(let image):
                image.resizable().aspectRatio(contentMode: .fit).frame(height: 100)
            case .failed:
                Image(systemName: "exclamationmark.triangle.fill")
            default:
                ProgressView()
            }
        }.task {
            await imageAttachment.loadImage()
        }
    }
}
```

```Swift: ViewModel
import SwiftUI
import PhotosUI

@MainActor final class InlineMultiPhotosPickerViewModel: ObservableObject {
    @MainActor final class ImageAttachment: ObservableObject, Identifiable {

        /// 選択された写真の読み込み進捗を示すステータス
        enum Status {
            case loading
            case finished(Image)
            case failed(Error)

            var isFailed: Bool {
                return switch self {
                case .failed: true
                default: false
                }
            }
        }

        /// 写真の読み込みに失敗した理由を示すエラー
        enum LoadingError: Error {
            case contentTypeNotSupported
        }

        /// ピッカーで選択された写真への参照
        private let pickerItem: PhotosPickerItem

        /// 写真の読み込み進捗
        @Published var imageStatus: Status?

        /// 写真のテキスト説明
        @Published var imageDescription: String = ""

        /// 写真の識別子
        nonisolated var id: String {
            pickerItem.identifier
        }

        /// 指定されたピッカーアイテムの画像添付を作成する
        init(_ pickerItem: PhotosPickerItem) {
            self.pickerItem = pickerItem
        }

        /// ピッカーアイテムが特徴とする写真を読み込む
        func loadImage() async {
            guard imageStatus == nil || imageStatus?.isFailed == true else {
                return
            }
            imageStatus = .loading
            do {
                if let data = try await pickerItem.loadTransferable(type: Data.self),
                   let uiImage = UIImage(data: data) {
                    imageStatus = .finished(Image(uiImage: uiImage))
                } else {
                    throw LoadingError.contentTypeNotSupported
                }
            } catch {
                imageStatus = .failed(error)
            }
        }
    }

    /// ピッカーで選択された写真のアイテムの配列
    ///
    /// セット時、このメソッドは現在の選択の画像添付を更新する
    @Published var selection = [PhotosPickerItem]() {
        didSet {
            // 現在のピッカー選択に応じて添付を更新する
            let newAttachments = selection.map { item in
                // 既存の添付が存在する場合はアクセスし、そうでない場合は新しい添付を作成する
                attachmentByIdentifier[item.identifier] ?? ImageAttachment(item)
            }
            // スコープ内で読み込まれた新しい添付に対して保存された添付配列を更新する
            let newAttachmentByIdentifier = newAttachments.reduce(into: [:]) { partialResult, attachment in
                partialResult[attachment.id] = attachment
            }
            // 非同期アクセスをサポートするために、既存の配列を更新するのではなく、新しい配列をインスタンスプロパティに割り当てる
            attachments = newAttachments
            attachmentByIdentifier = newAttachmentByIdentifier
        }
    }

    /// ピッカーで選択された写真の画像添付の配列
    @Published var attachments = [ImageAttachment]()

    /// パフォーマンスのために以前に読み込まれた添付を保存する辞書
    private var attachmentByIdentifier = [String: ImageAttachment]()
}

/// ピッカーアイテムにフォトライブラリがない場合の状況を処理する拡張
private extension PhotosPickerItem {
    var identifier: String {
        guard let identifier = itemIdentifier else {
            fatalError("Photosピッカーにフォトライブラリがありません。")
        }
        return identifier
    }
}
```

`PhotosPicker`に渡した`selection`の値が変わるタイミングで、表示させる写真の情報を持った`attachments`を更新しています。

`PhotosPicker`の実装とは関係ない部分ですが、`@Published`の値にdidSet使うの便利そうだなと思いました。

```swift
@Published var selection = [PhotosPickerItem]() {
        didSet {
            // 現在のピッカー選択に応じて添付を更新する
            let newAttachments = selection.map { item in
                // 既存の添付が存在する場合はアクセスし、そうでない場合は新しい添付を作成する
                attachmentByIdentifier[item.identifier] ?? ImageAttachment(item)
            }
            ...
            
            // 非同期アクセスをサポートするために、既存の配列を更新するのではなく、新しい配列をインスタンスプロパティに割り当てる
            attachments = newAttachments
            ...
        }
    }
```


## ちょっとしたUI変更その1
インラインだけど、ツールバーなどを表示してみると（たぶん使うことはない）

<img src="" width="200"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/0f18be58-5632-10cd-9e53-8deb6d6f0405.png" width="200">


```diff_swift
                PhotosPicker(
                    selection: $viewModel.selection,
                    selectionBehavior: .continuousAndOrdered,
                    matching: .images,
                    preferredItemEncoding: .current,
                    photoLibrary: .shared()
                ) {
                    Text("写真を選択")
                }

                // 半分の高さのPhotosピッカーを構成
                .photosPickerStyle(.inline)

                // インラインのユースケースでキャンセルボタンを無効にする
                .photosPickerDisabledCapabilities(.selectionActions)

                // ピッカーUIのすべての端にあるアクセサリー（ツールバーなど）を非表示にする
-               .photosPickerAccessoryVisibility(.hidden, edges: .all)
                .ignoresSafeArea()
-               .frame(height: 200)
+               .frame(height: 300)
```

## ちょっとしたUI変更その2
ライブラリの表示をインラインからコンパクトにしてみると

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/669da291-a028-f6fb-a9e9-20a1fca8f883.png" width="200">

```diff_swift
                PhotosPicker(
                    selection: $viewModel.selection,
                    selectionBehavior: .continuousAndOrdered,
                    matching: .images,
                    preferredItemEncoding: .current,
                    photoLibrary: .shared()
                ) {
                    Text("写真を選択")
                }

                // 半分の高さのPhotosピッカーを構成
-               .photosPickerStyle(.inline)
+               .photosPickerStyle(.compact)

                // インラインのユースケースでキャンセルボタンを無効にする
                .photosPickerDisabledCapabilities(.selectionActions)
                
                .ignoresSafeArea()
                .frame(height: 300)
```


主に以下3つのモディファイアを使うことで`PhotosPicker`のUIを変更することができます。

#### [`.photosPickerStyle(_:)`](https://developer.apple.com/documentation/swiftui/view/photospickerstyle(_:))

- `.inline`: アプリのUIに埋め込まれた形で表示
- `.presentation`: デフォルト。フルスクリーンモーダルとして表示
- `.compact`: 単一行の横スクロール可能なピッカーとして表示


#### [.photosPickerDisabledCapabilities(_:)](https://developer.apple.com/documentation/swiftui/view/photospickerdisabledcapabilities(_:))

- `.selectionActions`: 選択アクションを無効化
- `.search`: 検索機能を無効化
- `.collectionNavigation`: コレクション間のナビゲーションを無効化
- `.stagingArea`: 選択した項目を一時的に保持する領域を無効化


#### [.photosPickerAccessoryVisibility(_:edges:)](https://developer.apple.com/documentation/swiftui/view/photospickeraccessoryvisibility(_:edges:))

ナビゲーションバーを表示したままツールバーを非表示にするなど、細かな調整が可能

# おわりに

画像を複数選択するUIが簡単に作れるのはとても便利だなと思いました。特にインラインの表示は普段使っているアプリでもまだ見たことがないので、今後どのように使われるのか気になるところです。

# 参考

https://developer.apple.com/documentation/photokit/implementing_an_inline_photos_picker