Jetpack Composeのボトムシートが簡単に実装できるようになった！

# はじめに　(そもそもボトムシートとは)
Material design 2と3で若干異なりますが、主に二つのタイプのボトムシートが存在します。スタンダードボトムシート（下図①）は下画面の操作を妨げないタイプで、モーダルボトムシート（下図②）はボトムシートが開いている間、下画面の操作を受け付けません[^1]。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287959/fa1762c0-837d-3d6e-867e-c9f9a4e87add.png" width=80% />
ボトムシートは親画面を補助するためのコンポーネントで、特にモーダルタイプでは一つの画面から複数のボトムシートコンテンツが表示されることもよくあります。
スタンダードボトムシートはGoogle mapのように補足情報を表示したまま親画面を操作したいときに向いています。その性質上、親画面は複数存在し得ます。
一方でモーダルボトムシートはひとつの親画面の補佐に特化しており、親画面は常にひとつです。

本記事ではモーダルボトムシートに焦点をあてています。以下単純にボトムシートと呼んだ場合はモーダルボトムシートのことを指します。

[^1]: Material design 2ではこれに加えて[エキスパディングボトムシート](https://m2.material.io/components/sheets-bottom#expanding-bottom-sheet)があります。画面下部から上に画面全体を覆うようにスライドして表示されるものがこれにあたります。


# 従来の実装方法
これまではボトムシートを表示したい画面のComposable全体を、ボトムシートのComposableで囲むしかありませんでした。
```Kotlin
@Composable
fun FriendsListScreen() {
    var skipHalfExpanded by remember { mutableStateOf(false) }
    val state = rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
        skipHalfExpanded = skipHalfExpanded
    )
    val scope = rememberCoroutineScope()
    ModalBottomSheetLayout(
        modifier = Modifier.zIndex(100F),
        sheetElevation = 60.dp,
        sheetState = state,
        sheetContent = {
            //ボトムシート内に表示するコンテンツ
            Column(
                modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, start = 8.dp),
                verticalArrangement = Arrangement.SpaceBetween
            ) {
                BottomSheetIconTextRow(icon = R.drawable.baseline_share_24, text = "Share")
                BottomSheetIconTextRow(icon = R.drawable.baseline_link_24, text = "Get link")
                BottomSheetIconTextRow(icon = R.drawable.baseline_edit_24, text = "Edit name")
                BottomSheetIconTextRow(icon = R.drawable.baseline_delete_24, text = "Delete collection")
            }
        }
    ) {
        //ボトムシートを開く画面はModalBottomSheetLayoutでラップする必要があった。
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
        ) {
            Text(text = "主画面のコンテンツ")
            Button(
                modifier = Modifier.padding(bottom = 16.dp),
                colors = ButtonDefaults.textButtonColors(
                    backgroundColor = Color.Gray,
                    contentColor = Color.White
                ),
                onClick = { scope.launch { state.show() } }) {
                Text("ボトムシートを開く")
            }
        }
    }
}
```

## 従来の実装方法のデメリット
実装したことがある方なら分かると思いますが、上記の実装にはいくつかの問題があります。
### 複数のボトムシートがある場合に分岐が必要
一つの画面から複数のボトムシートを表示したい場合、ボトムシートコンテンツの表示に分岐が必要になります。イベントを送信する側はどのボトムシートを開くべきかを意識しなければならず、またUIの中に分岐が存在するため、コードの読みやすさが損なわれます。
```kotlin
sheetContent = {
    when(sheetCategory) {
        BottomSheetCategory.SHEET_A -> {
            //ボタンAに対応したボトムシートを表示
        }
        BottomSheetCategory.SHEET_B -> {
            //ボタンBに対応したボトムシートを表示
        }
    }
}
```
### ボトムナビゲーションバーと併用できない
ComposableのUIの表示順序の関係上、bottomBarが設定されたScaffoldのcontentからボトムシートを開くと、ボトムシートがボトムナビゲーションの上に表示されてしまいます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287959/3e182d68-13ae-ea69-6f15-c8f418310377.png" width=50%/>

これを回避する手段として、[Scaffold自体をボトムシートでラップするという荒業](https://stackoverflow.com/questions/67112512/cannot-find-a-way-to-use-both-at-same-time-bottom-sheet-and-bottom-navigation-b)がありますが、これはこれでいくつかの問題をはらんでいます。
たとえば、ボトムシートが不要な画面までボトムシートで包むことになりUIが宣言的でなくなることや、複数のボトムシートが存在する場合その管理が複雑になるなどです。

おそらくこれらの問題から、ボトムシートの使用を諦め、画面遷移で代用していると思われるアプリもいくつか観測できます(本当のところは分からないので名前は伏せます。気になる方はJetpack Composeの採用を公にしており、iOS版とAndroid版の両方リリースしているアプリを調べてみてください)。


# Compose Material 3にて新しいボトムシートがリリース
上記のように課題の多かったJetpack Composeでのボトムシートですが2023/05/10にリリースされたCompose Material 3のバージョン1.1.0を利用することで、直感的な書き方ができるようになりました。
## 導入方法
build.gradle以下を追記してCompose Material 3を使えるようにします。
```gradle:build.gradle
dependencies {
    //記事執筆中に1.1.1がリリースされました。いくつかの細かいバグが修正されているようです。
    implementation "androidx.compose.material3:material3:1.1.1"
    implementation "androidx.compose.material3:material3-window-size-class:1.1.1"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.1.1"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}
```
## 実装例
```Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen() {
    var openBottomSheet by rememberSaveable { mutableStateOf(false) }
    var skipPartiallyExpanded by remember { mutableStateOf(false) }
    val scope = rememberCoroutineScope()
    val bottomSheetState = rememberModalBottomSheetState(
        skipPartiallyExpanded = skipPartiallyExpanded
    )

    //主画面のコンテンツ
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "主画面のコンテンツ")
        Button(onClick = {
            scope.launch {
                openBottomSheet = true
            }
        }) {
            Text(text = "ボトムシートを開く")
        }
    }
    //ここからボトムシートのコンテンツ
    if (openBottomSheet) {
        ModalBottomSheet(
            modifier = Modifier.padding(top = 16.dp),
            onDismissRequest = {openBottomSheet = false },
            sheetState = bottomSheetState,
        ) {
            Column(
                modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
            ) {
                BottomSheetIconTextRow(icon = R.drawable.baseline_share_24, text = "Share")
                BottomSheetIconTextRow(icon = R.drawable.baseline_link_24, text = "Get link")
                BottomSheetIconTextRow(icon = R.drawable.baseline_edit_24, text = "Edit name")
                BottomSheetIconTextRow(icon = R.drawable.baseline_delete_24, text = "Delete collection")
            }
        }
    }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287959/524c06c4-61a0-90d3-a0f6-4dd5c7239127.gif" width=50% />

親画面とボトムシートの関係が従来の書き方とは逆転しているのが分かるでしょうか。
これにより上記にあげたデメリットを簡単に解消することができます。
各ボトムシートをコンポーネント化することで、onClickイベントに対応したコンポーネントを開くだけでよくなり分岐はなくなりますし、gifを見ての通りボトムナビゲーションの上に表示も何もしなくても実現できています。

# まとめ
現時点ではCompose Material3だけが上記のボトムシートの実装を提供しており、Compose Materialではまだ提供されていません。しかし、Compose MaterialとCompose Material 3は共存可能なので、ボトムシートのためだけに導入することもできます[^3]。
ボトムシートを使用するのを諦めていた方々にとって、この情報が役立つことを願います。

[^3]: Android Studio Flamingoからプロジェクト新規作成時のデフォルトのdependencyがCompose Material 3になったことから今後こちらが主流になっていくのは間違いありませんので、この機に移行してしまうのもアリだと思います。