8
4

More than 1 year has passed since last update.

BottomSheetDialogFragmentでJetpack Composeを使う

Last updated at Posted at 2022-01-28

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

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

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

普通に実装してみた

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

image.png

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

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側はこんな感じです。

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

NestedScrollが適切に処理されない

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

adbeem-20220128132737.gif

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

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

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

NestedScrollに対応してみる

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

発想としては単純です。

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

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

@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外に通知しているのですが、正直ここの部分は公式のドキュメントを参考にして書いてます(笑)

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

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

class BottomSheetDialog : BottomSheetDialogFragment() {

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

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

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

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

実際に動かしてみます。

まずは縦表示から。

adbeem-20220128142148.gif

そして横表示

adbeem-20220128142200.gif

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

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

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

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

adbeem-20220128142934.gif

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

8
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4