🏞️

【Compose Multiplatform】端末内の写真・動画ファイルを選択するファイルピッカーを作る

に公開

こんにちは!アルダグラムでエンジニアをしている渡邊です。

Android や iOS でアプリ開発を行う中で、端末内の写真や動画をアプリから参照したい、というケースは少なくないのではないでしょうか。
ところが Android や iOS で写真や動画にアクセスするためにはアクセス権限が必要になったりと、実装が手間になることが多いです。

ただ Android であれば PhotoPicker、iOS であれば PHPhotoPickerViewController を使うことによって、写真・動画へのアクセス権限が必要なくそれらのリソースを参照することが可能になっています。

今回は Compose Multiplatform において、Android と iOS それぞれで端末内の写真・動画ファイルを選択するファイルピッカーの実装を紹介したいと思います。
サンプルプロジェクトを GitHub で公開しているので、こちらも参考にしてください。

Android、iOS の共通処理の実装

ファイルピッカーを Compose 関数内から表示できるようにするために、まずはファイルピッカーのランチャーを commonMain に作ります。

ImageVideoPickerLauncher.kt
/**
 * 画像・動画ファイルを選択するファイルピッカーのランチャーを返す.
 *
 * @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
}

rememberImageVideoPickerLauncherexpect 関数として定義しており、各プラットフォーム固有の実装は androidMainiosMain で行います。

次に、ファイルピッカーで選択されたファイルの情報を保持する PickedFile クラスを定義します。

PickedFile.kt
expect class PickedFile {
    /**
     * ファイル名.
     */
    val name: String
}

PickedFileexpect class として定義し、各プラットフォームでファイル名以外の情報(URI や URL など)を保持できるようにします。

Android 側の実装

Android 側では、Photo Picker を使用してファイルピッカーを実装します。

PickedFile の実装

まず、Android 側の PickedFile を定義します。

PickedFile.android.kt
actual data class PickedFile(
    actual val name: String,

    /**
     * ファイル URI.
     */
    val uri: Uri,
)

Android では選択されたファイルの URI を Uri 型で保持します。

rememberImageVideoPickerLauncher の実装

次に rememberImageVideoPickerLauncher を実装します。

ImageVideoPickerLauncher.android.kt
@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 を定義します。

PickedFile.ios.kt
actual data class PickedFile(
    actual val name: String,

    /**
     * ファイル URL.
     */
    val url: NSURL,
)

iOS では選択されたファイルの URL を NSURL 型で保持します。

rememberImageVideoPickerLauncher の実装

次に rememberImageVideoPickerLauncher を実装します。

ImageVideoPickerLauncher.ios.kt
@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 で選択されたファイルは、PHPickerResultitemProvider プロパティから NSItemProvider として取得できます。このファイルを読み込むために、以下のような suspend 関数を実装しています。

ImageVideoPickerLauncher.ios.kt
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 メソッドが終了すると自動的にシステムによってファイルが破棄されてしまいます。そのため、ファイルを一時ディレクトリにコピーする必要があります。

TempFile.kt
@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
}

この拡張関数では、以下の処理を行っています:

  1. 一時ディレクトリに UUID をファイル名とした新しいファイルパスを生成
  2. NSFileManager を使ってファイルをコピー
  3. コピーが失敗した場合は、NSData 経由でコピーを試みる(大容量ファイルには非推奨)

使用例

実装したファイルピッカーは、以下のように使用できます。

サンプル実装
App.kt
@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 を使ってサムネイル画像を表示する記事を書きたいと思います。

GitHubで編集を提案
アルダグラム Tech Blog

Discussion