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


目標

SwiftUIでPHPickerViewControllerを綺麗に使う

問題点

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

やってもダメだったこと

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

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

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, NSItemProvider, NSItemProviderReading(UIImage, PHLivePhoto, URL)をもつモデルを用意

struct PhotoResult {
  let id: String
  let provider: NSItemProvider
  var item: NSItemProviderReading?
}

PhotoPickerを用意

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
    }
    
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
      picker.dismiss(animated: true)
      
      let existingSelection = parent.results
      
      var newResults: [PhotoResult] = []
      
      for result in results {
        let id = result.assetIdentifier!
        let firstItem = existingSelection.first(where: { $0.id == id })
        let provider = firstItem?.provider ?? result.itemProvider
        let result: PhotoResult = .init(id: id, provider: provider, item: firstItem?.item)
        newResults.append(result)
      }
      
      parent.results = newResults
      parent.didPickPhoto = true
    }
  }
}

PhotoViewを用意

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

struct PhotoView: View {
  let provider: NSItemProvider

  @Binding var item: NSItemProviderReading?
  @State var tappedImage: UIImage?
  @State var isPresentedVideoPlayer = false
  
  var body: some View {
    GeometryReader { geometry in
      if let item = item {
        if let livePhoto = item as? PHLivePhoto {
          LivePhoto(livePhoto: livePhoto)
        }
        else if let uiimage = item as? UIImage {
          Image(uiImage: uiimage)
            .resizable()
            .aspectRatio(contentMode: .fit)
        } else if let url = item as? URL {
          ZStack {
            if let uiImage = try? UIImage(movieURL: url) {
              Image(uiImage: uiImage)
                .resizable()
                .aspectRatio(contentMode: .fit)
            }

            Image(systemName: "play")
          }
          .onTapGesture {
            isPresentedVideoPlayer.toggle()
          }
          .sheet(isPresented: $isPresentedVideoPlayer) {
            let player: AVPlayer = .init(url: url)
            VideoPlayer(player: player)
              .onAppear {
                player.play()
              }
          }
        }
      }
    }
      .onAppear {
        Task {
          if item == nil {
            do {
              item = try await provider.loadPhoto()
            } catch {
              print(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)
  }
}

主画面

struct ContentView: View {
  @State var results: [PhotoResult] = []
  @State var isPresentedPhotoPicker = false
  @State var didPickPhoto = true
  
  var body: some View {
    VStack {
      Button(action: {
        isPresentedPhotoPicker = true
      }, label: {
        Image(systemName: "photo")
      })
      .padding()
      .disabled(!didPickPhoto)
      .sheet(isPresented: $isPresentedPhotoPicker) {
        PhotoPicker(results: $results, didPickPhoto: $didPickPhoto)
      }
      
      ScrollView(.vertical) {
        ForEach(0..<results.count, id: \.self) { i in
          PhotoView(provider: results[i].provider, item: .init(get: { results[i].item }, set: { results[i].item = $0 }))
            .frame(height: 300)
        }
      }
    }
  }
}

つまづいた点

  • 画像を選択した順序を保持
  • loadFilePresentationをloadItemに置き換えてもダメだった

参考にしたもの

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