BottomSheetDialogFragmentでJetpack Composeを使う

既存プロジェクトの一部画面をJetpack Compose化していくなかで、BottomSheetDialogFragmentをJetpack Compose化しました。
その際にBottomSheetDialogFragmentのスクロールの処理と、Jetpack ComposeのLazyColumnのスクロールがいい感じに動作しなかったため大苦戦しました。なんとか解決できたのでご紹介します。

ちなみにですが、ボトムシート自体をJetpack Compose化したのではなく、BottomSheetDialogFragmentのまま、レイアウトファイルをJetpack Composeリプレースした感じです。

この記事で実装したサンプルコートも公開しています。

https://github.com/yasukotelin/BottomSheetDialogFragmentWithJetpackCompose

## 普通に実装してみた

まず、普通にレイアウトファイルをJetpack Compose化してみます。ボタンが8つあるちょっと縦に長いボトムシートのイメージです。
Previewはこんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/f935f3cc-69ee-fb5f-80bb-a05bc3802c1a.png)

`LazyColumn` でリスト表示しています。

```kotlin
private val items = (1..8).map {
    "item $it"
}

@Composable
fun BottomSheetDialogScreen() {
    Surface(color = MaterialTheme.colors.background) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {

            Knob(modifier = Modifier.padding(top = 8.dp, bottom = 16.dp))

            LazyColumn {
                items(items) {
                    ItemButton(it)
                    Spacer(modifier = Modifier.height(16.dp))
                }
            }
        }
    }
}

@Composable
fun Knob(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .width(30.dp)
            .height(3.dp)
            .background(color = Color(0xFFC4C4C4), shape = RoundedCornerShape(size = 12.dp))
    )
}

@Composable
fun ItemButton(text: String) {
    OutlinedButton(modifier = Modifier
        .fillMaxWidth()
        .height(37.dp)
        .padding(horizontal = 26.dp),
        border = BorderStroke(width = 1.dp, color = Purple200),
        onClick = { /*TODO*/ }) {
        Text(text, fontSize = 14.sp, color = Black)
    }
}
```

Fragment側はこんな感じです。

```kotlin
class BottomSheetDialog : BottomSheetDialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            // Dispose of the Composition when the view's LifecycleOwner is destroyed
            setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)

            setContent {
                SampleTheme {
                    BottomSheetDialogScreen()
                }
            }
        }
    }
}
```

とくに目立ったことはしておらず、通常のFragmentから呼び出すJetpack Composeの書き方と全く同じ書き方をしています。
これを実行すると期待したとおりに表示されました💡

![adbeem-20220128132721.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/dd0b9a2a-c336-6822-27e5-d024be25a27c.gif)

## NestedScrollが適切に処理されない

先程の例のように、コンテンツが画面に収まるケースではとくに問題なく実装できると思います。ですが、ユーザーの端末に収まらず、コンテンツが溢れた場合にうまく動作しません。
たとえば先程のボトムシートを横向きに表示してみます。

![adbeem-20220128132737.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/22fda3d9-7f9c-4526-8192-fb933d5b84ce.gif)

下方向へのスクロールは適切に動作しているのですが、上方向にコンテンツをスクロールしようとすると、ボトムシート側のスクロール処理が優先されてしまってボトムシートが閉じてしまいます🥺

この挙動は、RecyclerViewを使っていても発生するのですが、その場合はNestedScrollViewを親要素に配置することで適切に動作するようになるようです。ですが、今回のようにJetpack Composeを使っている場合それを使うことができません。

苦渋の策として、スクロールの位置を見て、コンテンツスクロールが一番上のときにだけボトムシートのスクロール処理を許諾するような実装してみました💡

### NestedScrollに対応してみる

色々トライしてみて一番うまく行ったやり方を紹介します。

発想としては単純です。

- LazyColumnのスクロールのoffsetを見て、先頭だったときにのみボトムシートのスクロール処理を許可します
- 先頭以外の場合はボトムシートのスクロールを許可しません

それでは早速Jetpack Compose側で実装してみます。

```kotlin
@Composable
fun BottomSheetDialogScreen(
    // 先頭かどうかをコールバックするようにする
    onScrollState: (isTop: Boolean) -> Unit,
) {
    val listState = rememberLazyListState()

    Surface(color = MaterialTheme.colors.background) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {

            Knob(modifier = Modifier.padding(top = 8.dp, bottom = 16.dp))

            LazyColumn(state = listState) {
                items(items) {
                    ItemButton(it)
                    Spacer(modifier = Modifier.height(16.dp))
                }
            }
        }
    }

    LaunchedEffect(listState) {
        // ListStateのoffsetの監視
        snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.offset }
            // offsetが0（先頭）かどうかを判定
            .map { it == 0 }
            // 連続して同じ値が流れないようにする
            .distinctUntilChanged()
            .collect {
                onScrollState(it)
            }
    }
}
```

`val listState = rememberLazyListState()` でListStateを生成してLayzyColumnに渡すことで状態を見ることができます。

ポイントは一番下部の `LaunchedEffect` の部分です。ここでoffsetの状態を見て、先頭かどうかをコールバックしています。
Compose内でCoroutineとFlowを使って、Compose外に通知しているのですが、正直ここの部分は公式のドキュメントを参考にして書いてます（笑）

https://developer.android.com/jetpack/compose/lists?hl=ja#react-to-scroll-position

`distinctUntilChanged()` を使うことで重複した値を無視することができます。例えばこれがないと、offsetが変更されるたびに false, false, false, false....のように何度もコールバックが呼ばれてしまいます。trueかfalseが切り替わったときにだけ流れればいいので、これを指定しています。

Fragment側では、BottomSheetDialogのBehaviorから `isDraggable` を操作しています。

```kotlin
class BottomSheetDialog : BottomSheetDialogFragment() {

    private val bottomSheetDialogBehavior: BottomSheetBehavior<FrameLayout>?
        get() = (dialog as? com.google.android.material.bottomsheet.BottomSheetDialog)?.behavior
```

Behaviorのとり方に微妙に苦戦したのですが、こんな感じで取得できると思います。dialogをBottomSheetDialogにキャストして、behaviorを取得します。このサンプルではBottomSheetDialogが名前被りしてしまったのでパッケージ指定も入ってます。。笑

```kotlin
setContent {
    SampleTheme {
        BottomSheetDialogScreen(
            onScrollState = { isTop ->
                bottomSheetDialogBehavior?.isDraggable = isTop
            }
        )
    }
}
```

setContent以下のコールバック部分で、先程取得コードを書いたbehaviorを使って `isDraggable`　を切り替えます。

実際に動かしてみます。

まずは縦表示から。

![adbeem-20220128142148.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/1680e6db-bd11-8b6a-d5e4-d97d5f341e4e.gif)

そして横表示

![adbeem-20220128142200.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/12631d71-9d32-82ed-493d-0591e0d93dc1.gif)

すごくいい感じになっています🥳

### スクロールされている状態でツマミ部分で閉じれない

一点だけ欠点があるとすれば、スクロールされた状態でツマミの部分をタップしても閉じれないことです。（先頭の場合はもちろんツマミ部分を触って閉じることができます）

そこだけは微妙な感じがありますが、画面外タップで閉じれますし、気になるようであればレイアウトを変えてバツボタンを配置すれば回避できるかも知れません。
あるいは、つまみごとスクロールコンテンツに含めれば、ユーザーに一番上にスクロールしてツマミが出ていないとスワイプで閉じれない、というのが伝わりやすいかも知れません。

![adbeem-20220128142934.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/29346a4a-29e2-2881-932c-87ebd240c80c.gif)


将来的にBottomSheetDialogFragment自体をJetpack Compose化すれば回避できる問題でもあるので、一旦は目をつぶるというのも手かなという気もしています😌