【Android】Jetpack Composeで、イメージピッカーを作ってみる【rememberLauncherForActivityResult()編】

# イメージピッカーをJetpack Composeで作ってみたい

なにかしらの登録画面で、ギャラリーから画像を選択して、その選択した画像を登録できるようにしたいと思ったことはありませんか？しかも、Jetpack Composeを使って。

僕はこの問題に、今まさに直面しています。

そこでこの記事では、未来の自分への忘備録として、Jetpack Composeでイメージピッカーを作成する手順を解説しようと思います。

# rememberLauncherForActivityResult()とは

実際に、イメージピッカーの実装に入る前に、あるメソッドについて説明しておかないといけません。
そのメソッドとは、`rememberLauncherForActivityResult()`のことです。

コンポーザブル内で、ファイルを選択したり保存したりする機能を実装したことがある方なら見覚えがあるかもしれません。

公式のドキュメントには次のように書かれています。

> rememberLauncherForActivityResult() API を使用すると、コンポーザブル内のアクティビティの結果を取得できます。
> https://developer.android.com/jetpack/compose/libraries?hl=ja

この、コンポーザブル内のアクティビティの結果とはなんなのでしょうか？

具体的に言うと、コンポーザブル内で発行されたインテントにより、起動されたアクティビティが完了した段階で、登録されたコールバックに渡される値という意味になります。

`rememberLauncherActivityForResult()`を呼び出す際には、２つの引数が必要になります。１つが**ActivityResultContract**、そしてもう1つが結果を受け取る**コールバック関数**です。

## contract引数で渡すActivityResultContractとは

`rememberLauncherForActivityResult()`の`contract`引数は`ActivityResultContract`を表しています。

この`ActivityResultContract`とは一体何を表しているのでしょうか？

`ActivityResultContract`はコントラクトと呼ばれるものです。

コールバックによって受け取る結果を生成するためには、アクティビティを起動する必要があります。そのアクティビティを起動するためにはインテントを発行する必要があります。

そしてこの、結果を生成する一連のプロセスを開始する際に呼び出されるメソッド（`launch()`）に渡す**引数のクラス**と、アクティビティの起動後に**呼び出されるコールバックに対して渡される値のクラス**を定義するためのものが**コントラクト**となります。

このコントラクトには、あらかじめデフォルトのコントラクトが用意されています。
例えば、写真を撮影するためにはカメラのアクティビティを起動する必要があります。そのためのインテントを発行するためのコントラクトがデフォルトで用意されています。あとは権限のリクエストを行うためのコントラクトなども用意されています。

デフォルトのコントラクトには、次のようにしてアクセスします。

```kotlin
ActivityResultContracts.GetMultipleContents()
```

## onResult引数で渡すコールバックとは

コールバックでは、コントラクトによって定義された結果の型で表現される値が引数として渡されます。

例えば、今回の場合は画像の`uri`を取得する必要があります。
そして、コントラクトとして渡される値は、`ActivityResultContracts.GetContent()`になります。

そのため、アクティビティの完了の結果として渡される値は`Uri`型の値となるのです。

```kotlin
rememberLauncherForActivityResult(contract =
    ActivityResultContracts.GetContent()
) { uri: Uri? ->
    // TODO: 受け取ったuriを元に処理を行う
}
```

## ManagedActivityResultLauncherとは

アクティビティの起動は、`rememberLauncherForActivityResult()`が行うわけではありません。
このメソッドの呼び出しによって返される`ManagedActivityResultLauncher`インスタンスを使って、アクティビティの起動を任意のタイミングで行う必要があります。

この、インテントを発行してアクティビティを起動する処理を行うために呼び出されるのが、このインスタンスの`launch()`メソッドです。

`rememberLauncherForActivityResult()`を使う際のコールバックの登録から呼び出しまでの流れは、実際には次のようになります。

- `rememberLauncherForActivityResult()`にコントラクトとコールバックを渡して呼びして、`ManageActivityResultLauncher`インスタンスを取得する。
- `ManageActivityResultLauncher`インスタンスの`launch()`を呼び出す。
- アクティビティが完了してアプリに復帰すると、生成された結果と共に事前に登録されているコールバックが呼び出される。

# 作成したい機能

`ImagePicker`コンポーザブルで実装したい機能をリストにしてまとめます。

- 画像を端末内のギャラリーから選択できる
- 選択した画像をプレビューとして表示する
- 選択した画像の`uri`をコールバックを通して取得できる

# 完成したImagePickerコンポーザブルの絵

上記でまとめた機能を実装すると、次のようになりました。

まず、何も表示されていない真っ黒なプレビュー部分と、画像を選択するアクティビティを起動するボタンを配置します。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/577119/87b0855a-12a4-a80f-ede5-5a3451a016f4.png)

ボタンをクリックすると、新しいアクティビティが開きます。
ここでは、端末内にダウンロードされた画像の一覧が表示されています。

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/577119/e5458d24-15b4-759e-a9d1-5faafc8c52e5.png)

画像をタップすると、元のアプリに復帰して、真っ黒だったプレビュー部分に選択した画像が表示されます。

![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/577119/c814a65b-1ffc-7e58-5d3f-f9313705e268.png)

# ImagePickerコンポーザブルのコード

上記の内容を表現したコードが次のようになります。

```kotlin
@RequiresApi(Build.VERSION_CODES.P)
@Composable
fun ImagePicker(
    context: Context,
    onResult: (uri: Uri) -> Unit
) {
    var imageUri: Uri? by remember {
        mutableStateOf(null)
    }

    var bitmap: Bitmap? by remember {
        mutableStateOf(null)
    }

    val launcher =
        rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri ->
            if (uri == null) return@rememberLauncherForActivityResult
            imageUri = uri
            onResult(uri)
        }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(256.dp)
                .background(Color(0xFF000000))
                .border(
                    width = 1.dp,
                    color = MaterialTheme.colorScheme.primary,
                ),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            imageUri?.let {
                val source = ImageDecoder
                    .createSource(
                        context.contentResolver,
                        it,
                    )
                bitmap = ImageDecoder.decodeBitmap(source)

                bitmap?.let { bm ->
                    Image(
                        bitmap = bm.asImageBitmap(),
                        contentDescription = null,
                        modifier = Modifier.fillMaxWidth()
                    )
                }
            }
            Spacer(modifier = Modifier.height(8.dp))

            if (imageUri == null) {
                Text(
                    color = Color(0xFFFFFFFF),
                    text = stringResource(id = R.string.image_picker_text)
                )
            }
        }
        Spacer(modifier = Modifier.height(8.dp))
        OutlinedButton(
            modifier = Modifier
                .testTag("AddButton")
                .fillMaxWidth(),
            onClick = {
                launcher.launch("image/*")
            },
        ) {
            Text(
                text = stringResource(id = R.string.select_image)
            )
        }
    }
}
```

`rememberLauncherForActivityResult()`で登録したコールバックの中で、選択した画像の`uri`を状態として保存しています。

## Bitmapを使って画像を表示する方法の解説

紹介したコードでは、`ImageDecoder.createSource()`を使って、取得した`Uri`を`ImageDecoder.Source`型に変換しています。

```kotlin
val source = ImageDecoder
    .createSource(
        context.contentResolver,
        it,
    )
```

それを`ImageDecoder.decodeBitmap()`に渡して`Bitmap`を生成、`asImageBitmap()`を呼び出して画像を表示させています。

```kotlin
bitmap = ImageDecoder.decodeBitmap(source)

Image(
    bitmap = bm.asImageBitmap(),
    contentDescription = null,
    modifier = Modifier.fillMaxWidth()
)
```

## Bitmapを使わないで簡単に画像を表示する方法

実は、わざわざ`Bitmap`に変換せずとも簡単に画像を表示する方法があります。
それは`AsyncImage`コンポーザブルを使う方法です。

https://coil-kt.github.io/coil/compose/#asyncimage

`AsyncImage`はCoilライブラリに存在するコンポーザブルです。使用する場合は、**build.gradle**で次のインポートを行ってください。

```gradle
implementation("io.coil-kt:coil-compose:2.2.2")
```

`AsyncImage`を使えば、`imageUri`を渡すだけで、簡単に画像を表示させることができます。

```kotlin
AsyncImage(model = imageUri, contentDescription = null)
```

なぜ、`AsyncImage`を最初から使っていないかと言うと、この`ImagePicker`コンポーザブルを作り始めていた当初は`AsyncImage`の採用を考えていました。

しかし、画像を表示させることができませんでした。試行錯誤しながらたどり着いたのが、この`Bitmap`を使う方法だったのです。

しかし、この記事を書いている最中にもう一度試してみたところ、`AsyncImage`でも画像を表示させることができるようになっていました。~~（あの頃の努力は一体・・・）~~

ならば`AsyncImage`で画像を表示するように書き換えれば良いと思うかもしれません。が、もしかしたら僕の他の設定が原因でたまたま`AsyncImage`でも表示できるようになっているという疑惑を払拭することができなかったため、汎用的な方法である`Bitmap`を採用させていただきました。

もし`AsyncImage`が使えるのなら、ぜひ`AsyncImage`を使用することをおススメします。とても簡単です。

# もしも権限エラーが出た場合・・・

おそらく、今回の使用用途で発生することはないと思うのですが、外部ストレージやインターネット上のデータを取得する際には関連する権限のエラーが発生する可能性があります。

そこで、そのエラーを回避するために、AndroidManifest.xmlに次の`<uses-permission />`を追加してください。

```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
```

データストレージに対する使用の用途や権限に関する詳細については、こちらのドキュメントを参照してください。

https://developer.android.com/training/data-storage?hl=ja

ネットワークに接続する際の権限については、こちらのドキュメントを参照してください。

https://developer.android.com/training/basics/network-ops/connecting?hl=ja

# まとめ

アプリを開発していて、ユーザーが写真を投稿できる機能を実装したい場合に、必ずイメージピッカーの問題に直面すると思います。その際にサードパーティ製のライブラリを使おうか迷うと思いますが、`rememberLauncherForActivityResult()`を使って簡単にカスタマイズ可能なイメージピッカーを実装することができます。

ぜひ、みなさんも自分がデザインしたイメージピッカーを実装してみてください！

# 参考にした記事

https://ngengesenior.medium.com/pick-image-from-gallery-in-jetpack-compose-5fa0d0a8ddaf

https://www.howtodoandroid.com/pick-image-from-gallery-jetpack-compose/

https://www.gesource.jp/weblog/?p=8773

https://developer.android.com/jetpack/compose/libraries?hl=ja

https://developer.android.com/training/basics/intents/result?hl=ja

https://github.com/coil-kt/coil/discussions/833#discussioncomment-1527804