ModalBottomSheetLayoutの使い方とTips・トラブルシューティング

Android Advent Calendar 2022の10日目の記事です。
本記事では、[Compose Material](https://developer.android.com/jetpack/androidx/releases/compose-material) のライブラリで提供されている ModalBottomSheetLayout の使い方とTipsを紹介します。

※ 2022年12月10日(ver 1.3.1)時点で ExperimentalMaterialApi であることから動作は今後変更される可能性があります。

# ModalBottomSheetLayoutについて

マテリアルデザインにおけるBottomSheetとしては、Standard, Modal, Expanded の3種類が紹介されており、その中のModalが本記事で紹介するLayoutに該当します。

Android Viewの中では [BottomSheetDialogFragment](https://developer.android.com/reference/com/google/android/material/bottomsheet/BottomSheetDialogFragment) に相当する機能がComposeで提供されたもので、BottomSheetの中でも背景が暗転してモーダルの機能のみにフォーカスして伝えるような使い方をするコンポーネントとなります。

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33071/0a699e56-7318-875f-1c8d-a131cd67f441.png width=300>


[https://m2.material.io/components/sheets-bottom#modal-bottom-sheet](https://m2.material.io/components/sheets-bottom#modal-bottom-sheet)

現状、ModalBottomSheetLayoutは `androidx.compose.material` でのみ提供されており、`androidx.compose.material3` ではまだ提供されていません。m3のドキュメントでは、Expandedの記載が消えたものの、StandardとModalは引き続き残っていくようです。

![スクリーンショット 0004-12-10 2.15.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33071/0debd0fc-a286-386c-7363-31b753811373.png)

[https://m3.material.io/components/bottom-sheets/overview](https://m3.material.io/components/bottom-sheets/overview)

# 実装

## Composable function を作成

BottomSheetDialogFragmentとの大きな違いとして、ModalBottomSheetLayoutは表示したいComposableをwrapする形で呼び出す必要があります。

下記のように `sheetContent`へBottomSheet内で表示したいコンテンツのComposableを渡し、`content`へ本来のその画面のコンテンツを渡します。

多くの場合、画面全体を暗転しつつモーダルを表示する使い方となると思いますので、その画面のScaffoldなどを`content`に対して渡すことになります。

```kotlin
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
ModalBottomSheetLayout(
    modifier = modifier,
    sheetState = sheetState,
    sheetContent = {
        // BottomSheet内に表示するコンテンツ
    },
    content = {
        // BottomSheet自体を表示したい画面のコンテンツ
    },
)
```

## 表示・非表示の呼び出し

ModalBottomSheetを表示/非表示にする際には、`ModalBottomSheetState.show()`  または `hide()` を呼び出します。

suspend functionとなっているため、ボタンクリックなどの関数内で実行したい場合には、`rememberCoroutineScope()` 、UiStateなどの変更をトリガーとしてComposable内で起動したい場合には `LaunchedEffect()` を利用して実行します。 

### ボタンタップなど関数内で実行

```kotlin
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
Button(
    onClick = {
        coroutineScope.launch {
            sheetState.show()
        }
    }
) {
    Text(text = "Show BottomSheet")
}
```

### UiStateの変更などでComposable内で実行

```kotlin
val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
LaunchedEffect(uiState.showModal) {
    if (uiState.showModal) {
        sheetState.show()
    } else {
        sheetState.hide()
    }
}
```

# Tips・トラブルシューティング

## BottomSheet内のコンテンツの高さで最初から表示したい

デフォルトでは、画面の半分の高さで開かれるため、BottomSheetのコンテンツの高さが画面の半分よりも大きい場合は若干スクロールが必要になってしまいます。

最初からコンテンツの高さ最大で表示してほしい場合には、`rememberModalBottomSheetState` の引数に `isSkipHalfExpanded` が用意されているため、こちらをtrueにすることで可能です。

## sheetContentに高さがないとIllegalArgumentExceptionになる

ModalBottomSheetLayoutの引数であるsheetContentには、BottomSheet内で表示したいコンテンツを渡す必要がありますが、この際に高さのあるコンテンツを渡さないとIllegalArgumentExceptionとなりクラッシュします。

> java.lang.IllegalArgumentException: The initial value must have an associated anchor.

例えば、以下のようにsheetContentの描画のために、APIなどで取得したなんらかのデータ(ここではModalSheetData)が必要となる場合に下記のようにnullチェックしたとします。

```kotlin
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MainModalBottomSheetLayout(
    modifier: Modifier = Modifier,
    sheetState: ModalBottomSheetState,
    modalSheetData: ModalSheetData?,
    content: @Composable () -> Unit,
) {
    ModalBottomSheetLayout(
        modifier = modifier,
        sheetState = sheetState,
        sheetContent = {
            if (modalSheetData != null) {
                ModalSheetContent(modalSheetData = modalSheetData)
            }
        },
        content = content,
    )
}
```

この場合、nullでModalSheetContentが渡らなかった場合に、IllegalArgumentExceptionとなりクラッシュします。(ModalSheetContent内で早期リターンなどした場合も同様)

呼び出し前にチェックを行い、nullableではなくnonnullで最初から受け付ければ良いのではと思うかもしれませんが、ModalBottomSheetLayoutはcontent(本来の画面で表示するためのComposable)をwrapする必要があるため、ModalBottomSheet自体を描画しないということはできません。この方法で回避しようとするとmodalSheetDataがあるときだけ画面のcontentをwrapするという実装になりやや冗長になります。

これを回避するワークアラウンドとしては、nullの場合のはSpacerで高さのあるコンテンツを挿入する方法が紹介されています。ややハッキーなコードのため、stableでは解消されると嬉しく思います。

```kotlin
@Composable
fun ModalBottomSheetEmptyContent() {
    Spacer(modifier = Modifier.size(1.dp))
}
```

```kotlin
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MainModalBottomSheetLayout(
    modifier: Modifier = Modifier,
    sheetState: ModalBottomSheetState,
    modalSheetData: ModalSheetData?,
    content: @Composable () -> Unit,
) {
    ModalBottomSheetLayout(
        modifier = modifier,
        sheetState = sheetState,
        sheetContent = {
            if (modalSheetData != null) {
                ModalSheetContent(modalSheetData = modalSheetData)
            } else {
                ModalBottomSheetEmptyContent()
            }
        },
        content = content,
    )
}
```

- https://stackoverflow.com/questions/68623965/jetpack-compose-modalbottomsheetlayout-throws-java-lang-illegalargumentexception
- https://stackoverflow.com/questions/66511309/jetpack-compose-bottom-sheet-initialization-error

## ステータスバーの位置も暗転してほしい

下記のように何もしないとステータスバーだけ暗転が適用されない挙動になります。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33071/dd25b044-9c55-f501-dc0f-19c45b907693.gif width=300>


ステータスバー領域まで暗転するには、`WindowCompat.setDecorFitsSystemWindows` を利用してシステムバー(ステータスバーとナビゲーションバー)領域まで画面を広げ、ステータスバーを透過することで対応できます。

```kotlin
class MainActivity : ComponentActivity() {
    private val viewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        WindowCompat.setDecorFitsSystemWindows(window, false)
        setContent {
            AppTheme {
                MainScreen(viewModel)
            }
        }
    }
}
```

ステータスバーやナビゲーションバーの調整には、Accompanistの[SystemUiController](https://google.github.io/accompanist/systemuicontroller/)が利用できます。

```kotlin
@ExperimentalLifecycleComposeApi
@Composable
fun MainScreen(viewModel: MainViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    val systemUiController = rememberSystemUiController()
    SideEffect {
        systemUiController.setSystemBarsColor(
            color = Color.Transparent,
            darkIcons = true,
        )
    }
    MainScreen(uiState = uiState)
}

@Composable
private fun MainScreen(
    uiState: UiState,
) {
    ...
}
```

これだけだと、ステータスバーにめり込んだような見た目になるので、下記のように`Modifier.systemBarsPadding()`を適用することで、適切に表示されます。

`Modifier.systemBarsPadding()` ではステータスバーとナビゲーションバーのインセットに合わせてpaddingが適用されます。個別に適用したい場合には、`Modifier.statusBarsPadding()` や`Modifier.navigationBarsPadding()` にて適用できます。

- https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).systemBarsPadding()

```kotlin
MainModalBottomSheetLayout(
    sheetState = sheetState,
) {
    Scaffold(
        modifier = Modifier
            .fillMaxSize()
            .systemBarsPadding(),
    ) { paddingValue ->
        MainContent(modifier = Modifier.padding(paddingValue))
    }
}
```

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33071/f1509ab0-9748-9cd4-e938-b97a554e8147.gif width=300>


## シートの状態変化をハンドリングしたい

シート外がタップされてキャンセルされたイベントをフックしたいケースなど、BottomSheetの状態変化を検知したいケースがあります。

### 方法1  confirmStateChangeを利用する

下記のように `rememberModalBottomSheetState`の引数には `confiromStateChange` を渡すことができます。

```kotlin
val sheetState = rememberModalBottomSheetState(
    initialValue = ModalBottomSheetValue.Hidden,
    confirmStateChange = {
        if (it == ModalBottomSheetValue.Hidden) {
            // 実行したい処理
        }
        true
    }
)
```

ModalBottomSheetLayoutでは下記のように状態が変わるタイミングで `confirmStateChange` が呼び出され、trueを返している場合には、hideやexpandなどの後続の処理が呼び出されるようになっています。

https://github.com/androidx/androidx/blob/7d333fc6fb49ed9acfbdc83486a0cb42f4f66213/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ModalBottomSheet.kt#L520-L544

```kotlin
// ModalBottomSheet.kt より抜粋
.semantics {
    if (sheetState.isVisible) {
        dismiss {
            if (sheetState.confirmStateChange(Hidden)) {
                scope.launch { sheetState.hide() }
             }
            true
        }
        if (sheetState.currentValue == HalfExpanded) {
            expand {
                if (sheetState.confirmStateChange(Expanded)) {
                    scope.launch { sheetState.expand() }
                }
                true
            }
        } else if (sheetState.hasHalfExpandedState) {
            collapse {
                if (sheetState.confirmStateChange(HalfExpanded)) {
                    scope.launch { sheetState.halfExpand() }
                }
                true
            }
        }
    }
},
```

注意点として、recomposeが大量に走るケースなどでconfirmStateChangeにViewModelのメソッドを実行する処理などを直接記述すると、都度ModalBottomSheetStateが別インスタンスとして生成されてしまい意図しない挙動になります。

そのような処理の場合に備え、下記のように `rememberUpdatedState` を利用しておくと良いでしょう。

https://developer.android.com/jetpack/compose/side-effects#rememberupdatedstate

```kotlin
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MainScreen(
    uiState: UiState,
    onModalBottomSheetCanceled: () -> Unit,
) {
    val cancelModalBottomSheet = rememberUpdatedState(newValue = onModalBottomSheetCanceled)
    val sheetState = rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
        confirmStateChange = {
            if (it == ModalBottomSheetValue.Hidden) {
                cancelModalBottomSheet.value.invoke()
            }
            true
        }
    )
```

### 方法2 snapshotFlowを利用する

別の方法としては、 `ModalBottomSheetState.currentValue` を `snapshotFlow` で監視して特定の状態の時のみ実行するという方法もあります。

ModalBottomSheetStateに対して直接影響を与えないので、こちらの方が便利に使える場面が多いかと思います。

https://developer.android.com/jetpack/compose/side-effects#snapshotFlow

```kotlin
LaunchedEffect(modalBottomSheetState) {
    snapshotFlow { modalBottomSheetState.currentValue }
        .filter { it == ModalBottomSheetValue.Hidden }
        .collect {
            // 実行したい処理
   　　　　　　　　　　}
}
```

# 所感

BottomSheetDialogFragmentがある程度グローバルに表示できる仕組みだったのに対し、ModalBottomSheetLayoutはレイアウトであるため、各画面でそれぞれ実装する必要が出てきました。
RecyclerViewに対してはLazyColumnの方が圧倒的に実装が楽だと思いますが、BottomSheetDialogFragmentに対してはModalBottomSheetLayoutの方が実装がやや複雑になる印象がありました。

複数の画面で共通のBottomSheetを利用したい場合、特定機能のModalBottomSheetLayoutとして共通化してしまうと、将来複数種類のBottomSheetを1画面で表示する必要が出たときに、複数のModalBottomSheetLayoutでwrapすることになってしまいます。
このため、ModalBottomSheetLayoutは画面単位でComposableを作っておき、sheetContentで渡す中身のComposableを共通化するような使い方が良いかと思いました。

記載内容について誤りやより良い実装方法がありましたら、コメント等で教えていただけると嬉しいです。