🎑

[PhotoPicker]SwiftUIでasync/awaitに対応させたPHPickerViewControllerを使う

2022/04/07に公開

iOS 16以上であればPhotosPickerを使おう

https://zenn.dev/zunda_pixel/articles/0be43b8b4b36b5

目標

SwiftUIでPHPickerViewControllerを綺麗に使う

問題点

NSItemProviderloadFileRepresentation(forTypeIdentifier:)loadObject(ofClass:)がasync/awaitに対応していない

やってもダメだったこと

loadFileRepresentation(loadFileRepresentation:)がcompletionHandlerを抜けるとファイルパスを削除してしまうため、loadItem(forTypeIdentifier: options) async throws -> NSSecureCoding`の使用を試みたが、なぜか一部の動画が取得できなかった為、不採用に

原因は分かりませんでした

完成コード(GitHub)

完成コードをGitHubにあげておいたので、これで全体を確認してください。

https://github.com/zunda-pixel/UsefulSwiftUI/tree/main/SamplePhotoPicker.swiftpm

loadFilePresentation, loadObjectをasync/awaitへ対応

extension NSItemProvider {
  public func loadFileRepresentation(forTypeIdentifier typeIdentifier: String) async throws -> URL {
    try await withCheckedThrowingContinuation { continuation in
      self.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
        if let error = error {
          return continuation.resume(throwing: error)
        }
        
        guard let url = url else {
          return continuation.resume(throwing: NSError())
        }
        
        let localURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
        try? FileManager.default.removeItem(at: localURL)

        do {
          try FileManager.default.copyItem(at: url, to: localURL)
        } catch {
          return continuation.resume(throwing: error)
        }
        
        continuation.resume(returning: localURL)
      }.resume()
    }
  }
  
  public func loadObject(ofClass aClass : NSItemProviderReading.Type) async throws -> NSItemProviderReading {
    try await withCheckedThrowingContinuation { continuation in
      self.loadObject(ofClass: aClass) { data, error in
        if let error = error {
          return continuation.resume(throwing: error)
        }
        
        guard let data = data else {
          return continuation.resume(throwing: NSError())
        }
        
        continuation.resume(returning: data)
      }.resume()
    }
  }
}

PHLivePhotoViewをSwiftUIで使えるように

struct LivePhoto: UIViewRepresentable {
  let livePhoto: PHLivePhoto
  
  func makeUIView(context: Context) -> PHLivePhotoView {
      let livePhotoView = PHLivePhotoView()
      livePhotoView.livePhoto = livePhoto
      return livePhotoView
  }
  
  func updateUIView(_ livePhotoView: PHLivePhotoView, context: Context) {
  }
}

小さいモデルを用意

assetIdentifier, NSItemProviderReading(UIImage, PHLivePhoto, URL)をもつモデルを用意

struct PhotoResult:Identifiable {
  let id: String
  let item: NSItemProviderReading
}

PhotoPickerを用意

import SwiftUI
import PhotosUI

struct PhotoPicker: UIViewControllerRepresentable {
  @Binding public var results: [PhotoResult]
  @Binding public var didPickPhoto: Bool

  init(results: Binding<[PhotoResult]>, didPickPhoto: Binding<Bool>) {
    self._results = results
    self._didPickPhoto = didPickPhoto
  }

  func makeUIViewController(context: Context) -> PHPickerViewController {
    var configuration = PHPickerConfiguration(photoLibrary: .shared())
    configuration.preselectedAssetIdentifiers = results.map { $0.id }
    configuration.selectionLimit = 0
    configuration.preferredAssetRepresentationMode = .current
    configuration.selection = .ordered

    let picker = PHPickerViewController(configuration: configuration)
    picker.delegate = context.coordinator
    return picker
  }

  func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
  }

  func makeCoordinator() -> Coordinator {
    return Coordinator(self)
  }

  class Coordinator: NSObject, PHPickerViewControllerDelegate, UINavigationControllerDelegate {
    var parent: PhotoPicker

    init(_ parent: PhotoPicker) {
      self.parent = parent
    }

    private func loadPhotos(results: [PHPickerResult]) async throws {
      let existingSelection = parent.results

      parent.results = []

      for result in results {
        let id = result.assetIdentifier!
        let firstItem = existingSelection.first(where: { $0.id == id })

        var item = firstItem?.item

        if item == nil {
          item = try await result.itemProvider.loadPhoto()
        }

        let newResult: PhotoResult = .init(id: id, item: item!)

        parent.results.append(newResult)
      }
    }

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
      picker.dismiss(animated: true)

      Task {
        do {
          try await loadPhotos(results: results)
          parent.didPickPhoto = true
        } catch {
          print(error)
        }
      }
    }
  }
}

PhotoViewを用意

PhotoViewは通常の画像、Live Photo、タップしたら動画(VideoPlayer)にとぶものの3つのどれかを表示することができるSwiftUI用のViewです。

import SwiftUI
import PhotosUI
import AVKit

struct PhotoView: View {
  let item: NSItemProviderReading
  @State var isPresentedVideoPlayer = false
  @State var didError = false
  @State var error: PhotoError?

  var body: some View {
    GeometryReader { geometry in
      if let item = item {
        switch item {
          case let livePhoto as PHLivePhoto:
            LivePhoto(livePhoto: livePhoto)
          case let uiImage as UIImage:
            Image(uiImage: uiImage)
              .resizable()
              .aspectRatio(contentMode: .fit)
          case let movieURL as URL:
            ZStack {
              if let uiImage = try? UIImage(movieURL: movieURL) {
                Image(uiImage: uiImage)
                  .resizable()
                  .aspectRatio(contentMode: .fit)
                Image(systemName: "play")
              } else {
                Text("Failed Load Image")
              }
            }
            .onTapGesture {
              isPresentedVideoPlayer = true
            }
            .sheet(isPresented: $isPresentedVideoPlayer) {
              let player: AVPlayer = .init(url: movieURL)
              VideoPlayer(player: player)
                .onAppear {
                  player.play()
                }
            }
          default:
            Text("Failed Load Error")
        }
      }
    }
    .alert(isPresented: $didError, error: error) {
      Text("load error")
    }
  }
}

extension NSItemProvider {
  public func loadPhoto() async throws -> NSItemProviderReading {
    if self.canLoadObject(ofClass: PHLivePhoto.self) {
      return try await self.loadObject(ofClass: PHLivePhoto.self)
    }
    
    else if self.canLoadObject(ofClass: UIImage.self) {
      return try await self.loadObject(ofClass: UIImage.self)
    }
    
    else if self.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
      let url = try await self.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier)
      return url as NSItemProviderReading
    }
    
    fatalError()
  }
}

extension UIImage {
  public convenience init(movieURL url: URL) throws {
    let asset: AVAsset = .init(url: url)
    let generator = AVAssetImageGenerator(asset: asset)
    let cgImage = try generator.copyCGImage(at: asset.duration, actualTime: nil)
    self.init(cgImage: cgImage)
  }
}

主画面(ContentView)

import SwiftUI

struct ContentView: View {
  @State var results: [PhotoResult] = []
  @State var isPresentedPhotoPicker = false
  @State var didPickPhoto = true

  var body: some View {
    VStack {
      Button(action: {
        isPresentedPhotoPicker = true
        didPickPhoto = false
      }, label: {
        Image(systemName: "photo")
      })
      .padding()
      .disabled(!didPickPhoto)
      .sheet(isPresented: $isPresentedPhotoPicker) {
        PhotoPicker(results: $results, didPickPhoto: $didPickPhoto)
      }

      ScrollView(.vertical) {
        ForEach(results) { result in
          PhotoView(item: result.item)
            .frame(height: 300)
        }
      }
    }
  }
}

つまづいた点

  • 画像を選択した順序を保持
  • loadFilePresentationをloadItemに置き換えてもダメだった
  • 写真を並行処理で読み込むと処理が終わらなくなる

参考にしたもの

公式のPHPickerViewController(UIKit)のサンプル

Discussion