🎑
[PhotoPicker]SwiftUIでasync/awaitに対応させたPHPickerViewControllerを使う
iOS 16以上であればPhotosPickerを使おう
目標
SwiftUIでPHPickerViewControllerを綺麗に使う
問題点
NSItemProvider
のloadFileRepresentation(forTypeIdentifier:)
とloadObject(ofClass:)
がasync/awaitに対応していない
やってもダメだったこと
loadFileRepresentation(loadFileRepresentation:)
がcompletionHandlerを抜けるとファイルパスを削除してしまうため、loadItem(forTypeIdentifier: options) async throws -> NSSecureCoding`の使用を試みたが、なぜか一部の動画が取得できなかった為、不採用に
原因は分かりませんでした
完成コード(GitHub)
完成コードをGitHubにあげておいたので、これで全体を確認してください。
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に置き換えてもダメだった
- 写真を並行処理で読み込むと処理が終わらなくなる
Discussion