【iOS】画像選択系UIを作る上で必要な知識

こんにちは、モバイルチームの松元(@daikimat)です。

iOSアプリを作る上で画像選択のUIを作るということはよくあることと思います。そんな時に使える汎用的なライブラリを作成しました。この記事では今回作成したライブラリの紹介と、画像選択UIを実装する上で必要なPhotoKit / Photos frameworkの基礎的な知識やハマりポイントを紹介します。

ライブラリを作った背景

昨年、モバイルでのあるユーザーシナリオを検証するためプロトタイプを作り一部のユーザー様とベータテストを行ないました。 その中で「画像をサクサク撮って添付したい、既にフォトライブラリに保存されている画像もサクサク添付したい」といったシナリオがあり、カメラ撮影とフォトライブラリの画像の選択を手軽に行えるiMessageのUIを参考に実装しました。 そのプロトタイプはリリースには至りませんでしたが、この画像選択UI部分は汎用的ということで昨年行われたハッカソンで汎用化 + ライブラリ化し社内限定で使えるようにしていました。 せっかくなので今回社外にも公開し、実装時にたまった知見も共有することにしました。ちなみに参考にしたiMessageの画像添付UIはiOS12からiMessage Appと一体化して今は無きUIとなりました。

AttachmentInput

画像選択UIライブラリは ↓で公開しています。
github.com

UIResponderinputViewに当ててキーボードとして使うことを想定しています。
iOS11のiMessageのUIにそっくりですが、iMessageと違う点でいうと画像や動画の圧縮やファイル名、ファイルサイズの取得にも対応しています。
他にもやりたいことはissuesに追加しています。至らない点が多々あると思います。英文がおかしいとかリファクタリングでも良いのでPRをお待ちしています。

AttachmentInput使用イメージ
AttachmentInput ダサい名前だが略せばAI、今流行り

Tips集

ここからがタイトルに書いた本題ですが、上記ライブラリを作るにあたってPhotos frameworkに関して情報少ないなー、辛かった気がするなーという点をこちらに列挙しておきますのでどなたかのお役に立てれば幸いです。

画像と動画のみの一覧を取得する

画像を取得するにはPhotoKitのPhotos frameworkを使って画像や動画などを表すPHAssetを取得することになります。 PHAssetの種類であるPHAssetMediaTypeによるとimagevideoの他にaudiounknownタイプがあって、ここでは不要なのでPHFetchOptionsで取得しないように指定します。他にもアセットの取得数の上限や、ソート方法など指定することができます。

let fetchOptions = PHFetchOptions()
fetchOptions.fetchLimit = 100 // 一度に取得するアセットの数
fetchOptions.predicate = NSPredicate(format: "mediaType == %d || mediaType == %d",
                                      PHAssetMediaType.image.rawValue,
                                      PHAssetMediaType.video.rawValue) // imageとvideoのみ取得
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] // 作成日時で降順でソート
let fetchResult = PHAsset.fetchAssets(with: fetchOptions)

AttachmentInputでの使用例

端末に保存されている画像の変更を反映する

端末に画像が追加/削除されたり変更が加えられた場合、それらの変更を検知したい場合はPHPhotoLibraryChangeObserverを用いることで取得できます。

class ReceiverClass: NSObject {
    var fetchResult: PHFetchResult<PHAsset>? = nil
    func yourInitialization() {
        PHPhotoLibrary.shared().register(self)
    }
    func fetch() {
        self.fetchResult = PHAsset.fetchAssets(with: PHFetchOptions())
    }
}
extension ReceiverClass: PHPhotoLibraryChangeObserver {
    func photoLibraryDidChange(_ changeInstance: PHChange) {
        if let fetchResult = self.fetchResult, let changeDetails = changeInstance.changeDetails(for:fetchResult) {
            let newFetchResul = changeDetails.fetchResultAfterChanges
        }
    }
}

AttachmentInputでの使用例 registerPHPhotoLibraryChangeObserver

iCloudに保存されている画像を取得する

iCloudに保存されている画像はPHImageRequestOptionsisNetworkAccessAllowedtrueにすると取得できます。

let imageRequestOptions = PHImageRequestOptions()
imageRequestOptions.isSynchronous = false
imageRequestOptions.isNetworkAccessAllowed = true
imageRequestOptions.progressHandler = { (progress, error, stop, info) in
    if let error = error {
        // エラー
        return
    }
    // progress 0.0 ~ 1.0 でダウンロードの進捗を確認できます。
}
imageManager.requestImageData(for: phasset, options: imageRequestOptions){ (imageData, _, _, _) in
    if let imageData = imageData {
        // 画像取得
    }
}

AttachmentInputでの使用例

PHAssetからファイル名、ファイルサイズ、ファイルの保存されているパスを取得する

ファイル名を取得するには PHAssetResource.assetResources(for: phAsset)PHAssetResourceを取得することで.originalFilenameから取得することが可能です。
非公式の方法ですが、resource.value(forKey: "fileSize")でファイルサイズ、resource.value(forKey: "fileURL")でファイルパスを取得できます。
PHAssetResource.assetResources(for: phAsset)は処理が重いため必要なassetに対してのみ非同期で行うことをお勧めします。

※ valueで情報を取得する方法はPHAssetResourceの公開仕様ではないので、今後も使えるかは保証できません。
PHAsset#requestContentEditingInput(with:completionHandler:)でも情報を取得することもできますが、 画像編集のセッションを開始するため情報を取得するだけにしてはメモリも食うし扱いにくいです。

DispatchQueue.global().async {
    let resources = PHAssetResource.assetResources(for: phAsset)
    if let resource = resources.first {
        let fileName = resource.originalFilename
        let unsignedInt64 = resource.value(forKey: "fileSize") as? CLong
        let sizeOnDisk = Int64(bitPattern: UInt64(unsignedInt64!))
        let fileURL = resource.value(forKey: "fileURL") as? URL
    }
}

AttachmentInputでの使用例

AttachmentInputでは使っていないですが、iCloudに保存されていて取得にネットワークが必要な場合はlocallyAvailableをチェックすると判定できます。
こちらも非公式の方法なので今後も使えるかは保証できません。

resource.value(forKey: "locallyAvailable")

PHImageManagerのオプションの組み合わせによる挙動の違い

PHImageManagerのオプションで挙動を理解できずに一度は苦しむresizeModedeliveryModeがあります。
複数回段階的にロードされたり取得される画像のサイズが指定したものと違うものが返ってきたり、この二つのオプションの組み合わせによって直感では理解しがたい動きをします。
ドキュメントを読んでも細かいサイズの違いなどは読み取ることが難しいです。こちらは先人の知恵で素晴らしいマトリクスが公開されていましたので転載させていただきます。

ImagePickerControllerからphAssetを取得する

ImagePickerCellDelegateimagePickerController(_:didFinishPickingMediaWithInfo:)でImagePickerから画像や動画を選択した際の情報を取得できます。ここからphAssetを得るにはiOS11以降とそれより前で実装が変わります。
iOS11以降はinfo[.phAsset]で一発で取得できます。iOS10以下の場合はinfo[.referenceURL](Deprecated)からURLが取得できるので、fetchAssets(withALAssetURLs:options:)で取得することができます。

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
    var phAsset: PHAsset? = nil
    if #available(iOS 11.0, *) {
        phAsset = info[.phAsset] as? PHAsset
    } else {
        if let url = info[.referenceURL] as? URL {
            let fetchResult = PHAsset.fetchAssets(withALAssetURLs: [url], options: nil)
            phAsset = fetchResult.firstObject
        }
    }
}

AttachmentInputでの使用例

現場からは以上です!良いPhotosライフを!