【Compose Multiplatform】端末内の写真・動画ファイルを選択するファイルピッカーを作る
こんにちは!アルダグラムでエンジニアをしている渡邊です。
Android や iOS でアプリ開発を行う中で、端末内の写真や動画をアプリから参照したい、というケースは少なくないのではないでしょうか。
ところが Android や iOS で写真や動画にアクセスするためにはアクセス権限が必要になったりと、実装が手間になることが多いです。
ただ Android であれば PhotoPicker、iOS であれば PHPhotoPickerViewController を使うことによって、写真・動画へのアクセス権限が必要なくそれらのリソースを参照することが可能になっています。
今回は Compose Multiplatform において、Android と iOS それぞれで端末内の写真・動画ファイルを選択するファイルピッカーの実装を紹介したいと思います。
サンプルプロジェクトを GitHub で公開しているので、こちらも参考にしてください。
Android、iOS の共通処理の実装
ファイルピッカーを Compose 関数内から表示できるようにするために、まずはファイルピッカーのランチャーを commonMain
に作ります。
/**
* 画像・動画ファイルを選択するファイルピッカーのランチャーを返す.
*
* @param maxItems 選択可能数。0の場合は無制限
* @param onResult ファイルピッカーの結果を受け取るコールバック
*/
@Composable
expect fun rememberImageVideoPickerLauncher(
@IntRange(from = 0) maxItems: Int,
onResult: (List<PickedFile>) -> Unit,
): ImageVideoPickerLauncher
/**
* 画像・動画ファイルを選択するファイルピッカーのランチャー.
*/
fun interface ImageVideoPickerLauncher {
fun launch(type: ImageVideoPickerFileType)
}
sealed interface ImageVideoPickerFileType {
data object Image : ImageVideoPickerFileType
data object Video : ImageVideoPickerFileType
data object ImageAndVideo : ImageVideoPickerFileType
}
rememberImageVideoPickerLauncher
は expect
関数として定義しており、各プラットフォーム固有の実装は androidMain
と iosMain
で行います。
次に、ファイルピッカーで選択されたファイルの情報を保持する PickedFile
クラスを定義します。
expect class PickedFile {
/**
* ファイル名.
*/
val name: String
}
PickedFile
も expect class
として定義し、各プラットフォームでファイル名以外の情報(URI や URL など)を保持できるようにします。
Android 側の実装
Android 側では、Photo Picker を使用してファイルピッカーを実装します。
PickedFile の実装
まず、Android 側の PickedFile
を定義します。
actual data class PickedFile(
actual val name: String,
/**
* ファイル URI.
*/
val uri: Uri,
)
Android では選択されたファイルの URI を Uri
型で保持します。
rememberImageVideoPickerLauncher の実装
次に rememberImageVideoPickerLauncher
を実装します。
@Composable
actual fun rememberImageVideoPickerLauncher(
@IntRange(from = 0) maxItems: Int,
onResult: (List<PickedFile>) -> Unit,
): ImageVideoPickerLauncher {
val context = LocalContext.current
val updatedOnResult by rememberUpdatedState(onResult)
val pickMedia = if (maxItems == 1) {
rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri ->
if (uri != null) {
updatedOnResult(
listOf(
PickedFile(name = getDisplayName(context, uri), uri = uri)
)
)
}
}
} else {
rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = if (maxItems == 0) Int.MAX_VALUE else maxItems),
) { uris ->
if (uris.isNotEmpty()) {
updatedOnResult(
uris.map { uri ->
PickedFile(name = getDisplayName(context, uri), uri = uri)
}
)
}
}
}
return remember(pickMedia) {
ImageVideoPickerLauncher { type ->
pickMedia.launch(
input = PickVisualMediaRequest(
mediaType = when (type) {
ImageVideoPickerFileType.Image -> ActivityResultContracts.PickVisualMedia.ImageOnly
ImageVideoPickerFileType.Video -> ActivityResultContracts.PickVisualMedia.VideoOnly
ImageVideoPickerFileType.ImageAndVideo -> ActivityResultContracts.PickVisualMedia.ImageAndVideo
}
)
)
}
}
}
private fun getDisplayName(context: Context, uri: Uri): String {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx != -1 && cursor.moveToFirst()) {
return cursor.getString(idx)
}
}
return uri.lastPathSegment.orEmpty()
}
Android の実装のポイントは以下の通りです。
-
単一選択と複数選択の切り分け:
maxItems
が 1 の場合はPickVisualMedia
、それ以外はPickMultipleVisualMedia
を使用します -
選択可能数の指定:
maxItems
が 0 の場合は無制限(Int.MAX_VALUE
)として扱います -
ファイル名の取得:
ContentResolver
を使って URI からファイル名を取得します
iOS 側の実装
iOS 側では、PHPickerViewController を使用してファイルピッカーを実装します。
PickedFile の実装
まず、iOS 側の PickedFile
を定義します。
actual data class PickedFile(
actual val name: String,
/**
* ファイル URL.
*/
val url: NSURL,
)
iOS では選択されたファイルの URL を NSURL
型で保持します。
rememberImageVideoPickerLauncher の実装
次に rememberImageVideoPickerLauncher
を実装します。
@Composable
actual fun rememberImageVideoPickerLauncher(
@IntRange(from = 0) maxItems: Int,
onResult: (List<PickedFile>) -> Unit,
): ImageVideoPickerLauncher {
val scope = rememberCoroutineScope()
val currentUIViewController = LocalUIViewController.current
val updatedOnResult by rememberUpdatedState(onResult)
val pickerDelegate = remember {
object : NSObject(), PHPickerViewControllerDelegateProtocol {
override fun picker(
picker: PHPickerViewController,
didFinishPicking: List<*>,
) {
scope.launch {
val results = didFinishPicking.mapNotNull { it as? PHPickerResult }
.map { result ->
async {
result.itemProvider.loadFileRepresentationForTypeIdentifierAsync()?.let { (url, tempUrl) ->
// url は一時的にアクセス可能なファイル URL、tempUrl は url からデータをコピーしたファイル URL.
PickedFile(
name = url.lastPathComponent!!,
url = tempUrl, // url は picker メソッドが抜けるとアクセス不可になるので、コピー先の tempUrl を設定.
)
}
}
}
.awaitAll()
.filterNotNull()
withContext(Dispatchers.Main) {
updatedOnResult(results)
}
}
picker.dismissViewControllerAnimated(true, null)
}
}
}
return remember(currentUIViewController) {
ImageVideoPickerLauncher { type ->
val imagePicker =
createPHPickerViewController(
delegate = pickerDelegate,
type = type,
selectionLimit = maxItems.toLong(),
)
currentUIViewController.presentViewController(
imagePicker,
true,
null,
)
}
}
}
NSItemProvider からのファイル読み込み
PHPickerViewController で選択されたファイルは、PHPickerResult
の itemProvider
プロパティから NSItemProvider
として取得できます。このファイルを読み込むために、以下のような suspend 関数を実装しています。
private suspend fun NSItemProvider.loadFileRepresentationForTypeIdentifierAsync(): Pair<NSURL, NSURL>? =
suspendCancellableCoroutine { continuation ->
val progress = loadFileRepresentationForTypeIdentifier(
typeIdentifier = registeredTypeIdentifiers.firstOrNull() as? String ?: UTTypeImage.identifier
) { url, error ->
if (error != null) {
continuation.resume(null)
return@loadFileRepresentationForTypeIdentifier
}
continuation.resume(
// NOTE: url は一時的なファイル URL となっており、PHPickerViewControllerDelegate の picker メソッドが終了した時に自動的にシステムによってファイルが破棄されてしまう.
// 参考: https://developer.apple.com/documentation/foundation/nsitemprovider/loadfilerepresentation(fortypeidentifier:completionhandler:)
// そのためここでデータをコピーしている.
url?.createTempFile()?.let { url to it }
)
}
continuation.invokeOnCancellation {
progress.cancel()
}
}
この関数のポイントは以下の通りです。
-
suspendCancellableCoroutine の使用: コールバックベースの
loadFileRepresentationForTypeIdentifier
を Kotlin の suspend 関数に変換しています -
型識別子の取得:
registeredTypeIdentifiers
から適切な型識別子を取得します。画像や動画など、ファイルの種類に応じた型識別子が自動的に設定されます -
キャンセル対応: コルーチンがキャンセルされた場合、
Progress
オブジェクトもキャンセルしてリソースを適切に解放します -
一時ファイルの問題: Apple のドキュメントにもある通り、取得した URL は一時的なもので、delegate メソッドが終了すると自動的に削除されます。そのため、
createTempFile()
を使ってファイルをコピーしています
一時ファイルのコピー処理
iOS の PHPickerViewController では、選択されたファイルの URL が一時的なものであり、delegate メソッドが終了すると自動的にシステムによってファイルが破棄されてしまいます。そのため、ファイルを一時ディレクトリにコピーする必要があります。
@OptIn(ExperimentalForeignApi::class)
internal fun NSURL.createTempFile(): NSURL? {
val ext = this.pathExtension?.let { ".$it" } ?: ""
val dest = NSURL.fileURLWithPath("${NSTemporaryDirectory()}${NSUUID().UUIDString}$ext")
// ファイルをコピー.
val success = NSFileManager.defaultManager.copyItemAtURL(this, dest, null)
if (!success) {
// NSData 経由でコピーする(大容量ファイルには非推奨)
val data = NSData.dataWithContentsOfURL(this@createTempFile)
if (data != null) {
data.writeToURL(dest, true)
return dest
}
return null
}
return dest
}
この拡張関数では、以下の処理を行っています:
- 一時ディレクトリに UUID をファイル名とした新しいファイルパスを生成
-
NSFileManager
を使ってファイルをコピー - コピーが失敗した場合は、
NSData
経由でコピーを試みる(大容量ファイルには非推奨)
使用例
実装したファイルピッカーは、以下のように使用できます。
サンプル実装
@Composable
fun App() {
MaterialTheme {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
var pickedFiles by remember { mutableStateOf(emptyList<PickedFile>()) }
val imageVideoPickerLauncher = rememberImageVideoPickerLauncher(maxItems = 100) { files ->
pickedFiles = files
}
val (selectedFileType, onFileTypeSelected) = remember { mutableStateOf(ImageVideoPickerFileType.ImageAndVideo) }
// ファイルタイプ選択 UI
FileTypeSelection(
selectedFileType = selectedFileType,
onFileTypeSelected = onFileTypeSelected,
)
// ファイルピッカーを開くボタン
Button(
onClick = {
imageVideoPickerLauncher.launch(selectedFileType)
}
) {
Text("Open file picker")
}
// 選択されたファイルのリスト表示
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items = pickedFiles) { pickedFile ->
Text(text = pickedFile.toString())
}
}
}
}
}
以下は実際に動かしてみた時のキャプチャです。
Android | iOS |
---|---|
![]() |
![]() |
まとめ
Compose Multiplatform で Android と iOS 両方に対応した写真・動画ファイルピッカーの実装方法を紹介しました。
この実装により、Android と iOS の両方で統一された API を使用してファイルピッカーを利用できるようになります。ぜひサンプルプロジェクトを参考に、実際のアプリケーション開発に役立ててください。
次回はファイルピッカーで選択した画像や動画ファイルから、Coil を使ってサムネイル画像を表示する記事を書きたいと思います。

株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら: herp.careers/v1/aldagram0508/
Discussion