見出し画像

【Android】Jetpack Composeで全画面を覆わないボトムシートを作る

こんにちは、ゆっちです。
他社との協業事業として提供しているアプリのAndroid版の開発を担当しています。

Android開発者のみなさま、ボトムシートをJetpack Composeで表示したくなった場合どうしているでしょうか?

多くの場合は、BottomSheetScaffold もしくは ModalBottomSheetLayout を使うことで要求を満たせると思います。
しかしこれらは内部でScaffoldを使用しており、Scaffoldが画面全体を覆うことで都合が悪いこともあります。

ボトムシートが画面全体を覆うことの課題

例えば当社の例ですと、1つのFragmentで表示している地図の上に別のFragmentでコンテンツを表示している場合、コンテンツとしてボトムシートを表示してしまうと、Scaffoldがタッチイベントを阻害してしまうため地図が操作できなくなってしまうのです。

地図とBottomSheetScaffoldを併用しているため、地図が操作できない画面

この課題を解決するため「Scaffoldを使用しないボトムシート」つまり「全画面を覆わないボトムシート」を作成しました。
今回の記事ではその制作過程をご紹介します。


完成図

先にどのようなものが出来上がるのかお見せします。

環境

implementation "androidx.compose.material:material:1.4.3"
implementation 'androidx.activity:activity-compose:1.3.1'

どうやってボトムシートを作るか

ボトムシートの要件として「スワイプした時に動くこと」が必須です。
そのために、まずはSwipeable Modifierを知っておかなくてはなりません。

Swipeable Modifierとは、簡単に言うとComposableをスワイプできるようにするためのModifierです。
このModifierを理解するため、まずはこれだけを使ってボトムシートを作成してみます。

enum class BottomSheetSwipeableState {
    COLLAPSED, EXPANDED
}

これはボトムシートの状態を表すenumです。「折り畳まれている」「広げられている」という2つの状態を作りたく名づけました。ボトムシートを途中で止めたければ、もう1つの定義を追加してください。
このenumは理解しやすいよう定義しているだけで、数字の0, 1に置き換えていただいても構いません。

そしてこれとSwipealbe Modifierを用いて、スワイプできるComposableをシンプルに作ってみます。

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SimpleSwipeable() {
    // SwipeableStateとその初期値を定義
    // SwipeableStateからはボトムシートがスワイプされた時の位置(offset)が取れる
    val swipeableState = rememberSwipeableState(
        initialValue = BottomSheetSwipeableState.COLLAPSED
    )

    // ボトムシートが折りたたまれているときの高さ
    val peekHeightPx = with(LocalDensity.current) { 60.dp.toPx() }

    // ボトムシートが広げられているときの高さ(=ボトムシート自体の高さ)
    val sheetHeight = 200.dp
    val sheetHeightPx = with(LocalDensity.current) { sheetHeight.toPx() }

    Box(
        contentAlignment = Alignment.BottomCenter,
        modifier = Modifier
            .fillMaxSize()
            // スワイプ可能領域が分かりやすいように背景を黒に設定
            .background(Color.Black)
            .swipeable(
                state = swipeableState,
                // スワイプした時の座標とボトムシートの状態を紐づけるMap
                anchors = mapOf(
                    // y座標が(ボトムシートが折りたたまれているときの高さ) - (ボトムシート自体の高さ)のとき、折りたたまれている状態とする
                    (peekHeightPx - sheetHeightPx) to BottomSheetSwipeableState.COLLAPSED,
                    // y座標が0のとき、ボトムシートは広げられている状態とする
                    0f to BottomSheetSwipeableState.EXPANDED
                ),
                orientation = Orientation.Vertical,
                reverseDirection = true
            )
            .offset {
                // オフセット(元の位置に対しての相対的な位置)をswipeableStateから得る
                // ボトムシートは上下に動くためx軸は0で固定され、y軸が変動する
                // 画面下から上はy軸がマイナスの移動のため、オフセットをマイナスにする
                IntOffset(0, -swipeableState.offset.value.roundToInt())
            }
    ) {
        // ボトムシートを模したBox
        Box(
            modifier = Modifier
                // 背景は赤
                .background(Color.Red)
                .fillMaxWidth()
                .height(sheetHeight)
        )
    }
}

黒背景Boxがスワイプ可能領域になっており自身のオフセットを変えることで、赤背景Boxが移動しているように見えます。
ただし、モーダルシートの体を成すのであれば、赤背景Boxをスワイプした時にのみ、赤背景Boxが移動してほしいと思います。
これを実現しようとすると、下記のようになります。

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableBottomSheet() {
    // SwipeableStateとその初期値を定義
    // SwipeableStateからはボトムシートがスワイプされた時の位置(offset)が取れる
    val swipeableState = rememberSwipeableState(
        initialValue = BottomSheetSwipeableState.COLLAPSED
    )
   
    // ボトムシートが折りたたまれているときの高さ
    val peekHeightPx = with(LocalDensity.current) { 60.dp.toPx() }

    // ボトムシートが広げられているときの高さ(=ボトムシート自体の高さ)
    val sheetHeight = 200.dp
    val sheetHeightPx = with(LocalDensity.current) { sheetHeight.toPx() }

    Box(
        contentAlignment = Alignment.BottomCenter,
        modifier = Modifier
            .fillMaxSize()
            // 背景は青
            .background(Color.Blue)
            // このBoxにし対してオフセットを設定
            .offset { IntOffset(0, -swipeableState.offset.value.roundToInt()) }
    ) {
        Box(
            modifier = Modifier
                // スワイプ可能領域が分かりやすいように背景を黒に設定
                .background(Color.Black)
                .swipeable(
                    state = swipeableState,
                        // スワイプした時の座標とボトムシートの状態を紐づけるMap
                    anchors = mapOf(
                            // y座標が(ボトムシートが折りたたまれているときの高さ) - (ボトムシート自体の高さ)のとき、折りたたまれている状態とする
                        (peekHeightPx - sheetHeightPx) to BottomSheetSwipeableState.COLLAPSED,
                            // y座標が0のとき、ボトムシートは広げられている状態とする
                        0f to BottomSheetSwipeableState.EXPANDED
                    ),
                    orientation = Orientation.Vertical,
                    reverseDirection = true
                )
                .fillMaxWidth()
                .wrapContentHeight()
            , contentAlignment = Alignment.BottomEnd
        ) {
                // ボトムシートを模したBox
            Box(
                modifier = Modifier
                    .background(Color.Red)
                    .height(sheetHeight)
                    .fillMaxWidth()
            )
        }
    }

Swipealbe ModifierをつけたBoxを囲うBoxに対してオフセットを適用することで、スワイプ可能領域(黒背景Box)自体が移動し、さらにその中にある赤背景Boxも移動します。
黒背景Boxは赤背景Boxに完全に隠れているため見えません。

Swipeable Modifierの理解には下記サイトを参考にさせていただきました。
ここまでの話が理解できていない方は、ぜひ読んでみてください。

操作  _  Jetpack Compose  _  Android Developers

Android Jetpack Compose で途中で止まる Swipeable レイアウトを作ってみる - Gunosy Tech Blog

これでBottomSheetScaffoldを使わずにボトムシートができた!と喜びたくなりますが、実は当初の問題の Composableが画面全体を覆ってしまう という問題を解決できていません。上記の例では青背景のBoxが画面全体を覆っています。
次の章ではこれを解決するためにLayout Composableについて理解しましょう。

Layout Composable

Layout Composableとは、私の理解では、Composableを配置するためのComposableです。
カスタムレイアウトなどに使用され、ColumnやBoxなど標準で用意されたComposableより自由な位置で配置できます(ColumnやBoxも内部的にはLayoutを使用しています)

Layout Composableの説明について、すでに素晴らしい記事が日本語でありましたので共有します。

Jetpack Composeでカスタムレイアウトを作る - Qiita

また、カスタムレイアウトについて公式ページもありましたので共有します。
https://developer.android.com/jetpack/compose/layouts/custom?hl=ja

重要なのは、Layout Composableの中では下記3つの手順を行いComposableを配置する、という点です。

・それぞれのアイテムのmeasureを呼び出す。
・layout()を呼ぶ。
・それぞれのアイテムを置く(placeを呼び出す)。

Swipebleの章では、Swipeable Modifierから得られたオフセットをBoxに適用してボトムシートを動かす、ということを行いましたが、Layout Composableを使うことにより Swipeable Modifierから得られたオフセットを、Composableを配置するときの座標に使う ということが可能になります。

実際のコードを見てみましょう。

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun BottomSheetByLayout() {
    // ボトムシートが広げられているときの高さ(=ボトムシート自体の高さ)
    val bottomSheetHeight = 200.dp
    val bottomSheetHeightPx = with(LocalDensity.current) { bottomSheetHeight.toPx() }
    // ボトムシートが折りたたまれているときの高さ
    val peekHeightPx = with(LocalDensity.current) { 60.dp.toPx() }
    val swipeableState = rememberSwipeableState(initialValue = BottomSheetSwipeableState.COLLAPSED)

    Layout(content = {
        // ボトムシートを模したBox
        Box(modifier = Modifier
            // 背景は赤
            .background(Color.Red)
            .swipeable(
                state = swipeableState,
                anchors = mapOf(
                    // y座標がpeekHeightPxの時、折りたたまれている状態とする
                    peekHeightPx to BottomSheetSwipeableState.COLLAPSED,
                    // y座標がbottomSheetHeightPxの時、ボトムシートは広げられている状態とする
                    bottomSheetHeightPx to BottomSheetSwipeableState.EXPANDED
                ),
                orientation = Orientation.Vertical,
                reverseDirection = true
            )
            .fillMaxWidth()
            .height(bottomSheetHeight))
    }) { measurables, constraints ->
        val boxPlaceable = measurables.first().measure(constraints)
        layout(width = constraints.maxWidth, height = constraints.maxHeight) {
            // swipeablStateから得られたオフセットからy座標を計算する
            boxPlaceable.place(0, constraints.maxHeight - swipeableState.offset.value.roundToInt())
        }
    }
}

使用するBoxは1つだけになり、画面全体を覆うComposableはいなくなりました。
これで完成!と喜びたいところなのですが、まだ問題があります。
それは、ボトムシートの中をスクロール可能にしたいときに発生します。

ボトムシート内をスクロール可能にしたい

通常、リストをスクロールさせるだけであればLazyColumnを使うだけで済みます。
さっそく上記のボトムシートに使ってみましょう

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun BottomSheetByLayout() {
    // ボトムシートが広げられているときの高さ(=ボトムシート自体の高さ)
    val bottomSheetHeight = 200.dp
    val bottomSheetHeightPx = with(LocalDensity.current) { bottomSheetHeight.toPx() }
    // ボトムシートが折りたたまれているときの高さ
    val peekHeightPx = with(LocalDensity.current) { 60.dp.toPx() }
    val swipeableState = rememberSwipeableState(initialValue = BottomSheetSwipeableState.COLLAPSED)

    Layout(content = {
        // ボトムシートを模したBox
        Box(modifier = Modifier
            // 背景は赤
            .background(Color.Red)
            .swipeable(
                state = swipeableState,
                anchors = mapOf(
                    peekHeightPx to BottomSheetSwipeableState.COLLAPSED,
                    bottomSheetHeightPx to BottomSheetSwipeableState.EXPANDED
                ),
                orientation = Orientation.Vertical,
                reverseDirection = true
            )
            .fillMaxWidth()
            .height(bottomSheetHeight)){
                LazyColumn(Modifier.fillMaxWidth()) {
                    // 100行のリストを表示する
                    items(100) {
                        Text(text = "this is text$it")
                    }
                }
        }
    }) { measurables, constraints ->
        val boxPlaceable = measurables.first().measure(constraints)
        layout(width = constraints.maxWidth, height = constraints.maxHeight) {
            // swipeablStateから得られたオフセットからy座標を計算する
            boxPlaceable.place(0, constraints.maxHeight- swipeableState.offset.value.roundToInt())
        }
    }
}

ボトムシート内のリストはスクロールできますが、ボトムシート自体が動かなくなってしまいました。
これはボトムシート全体のスクロール可能量が先にリストで消費され、ボトムシート自体が移動することができなくなっているためです。
これはnestedScroll Modifierを使うことで解決できます。

nestedScroll Modifier

nestedScroll Modifierとは、Composableのスクロールを制御するためのModifierです。

このModifierの理解については、下記の記事を参考にさせていただきました。

Jetpack ComposeのNestedScrollによるスクロール幅を自由に制御する - 鯰の住処

Deep dive NestedScrollConnection - Qiita

特に重要な2つのメソッドについて、私なりの理解をまとめます。

onPreScroll

nestedScroll Modifierを指定したComposableの子要素がスクロールする前に呼ばれ、nestedScroll Modifierを指定したComposableがどのくらいスクロールを消費するかを返すメソッドです。この返却値から、子要素のonPreScrollのavailableが計算されます。
availableすべてを返すことで子要素はスクロールしなくなり、Offset.ZEROを返すことで子要素がスクロールします。

今回は下記のようになります。

override fun onPreScroll(
    available: Offset,
    source: NestedScrollSource
): Offset {
    // ボトムシートの動きはy軸のため、オフセットのy座標のみを計算に使用
    val delta = available.toFloat()
    return if (delta < 0 && source == NestedScrollSource.Drag) {
        // 上向きに動く時はswipeableStateが処理する
        -swipeableState
            .performDrag(-delta)
            .toOffset()
    } else {
        // 下向きに動く時は、onPostScroll()で処理するためゼロを返す
        Offset.Zero
    }
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y

ボトムシートを上向きに移動させたいとき(移動量がy軸にマイナスのとき)

  • 親のボトムシートが上端に達していない間は、子要素が使う予定だったスクロール幅を使って親のボトムシートを動かします

  • 親のボトムシートが上端に達して以降、Offset.Zeroを返します

上記の動きは swipeableState .performDrag() にavailableを全て渡すことで、うまく行なってくれます。
一部値をマイナスにしているのは、ボトムシートを画面下側から登場させるためです。

ボトムシートを下向きに移動させたいとき(移動量がy軸にプラスのとき)は、 onPostScroll() に移動可能量が現れ、処理するため、onPreScroll()では何もしません(固定でOffset.ZEROを返します)

onPostScroll

nestedScroll Modifierを指定したComposableの子要素がスクロールした後に呼ばれ、子要素がどのくらいスクロールを消費したか、どのくらいまだスクロールできるのかを引数で受け取り、親要素がどのくらいスクロールできるのかを返します。

今回はボトムシートが一番の親のため、どんな値を返すかはあまり重要ではありません。
渡されたavailableをswipeableStateに渡し、スワイプさせます。

override fun onPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
): Offset {
    return if (source == NestedScrollSource.Drag) {
        -swipeableState
            .performDrag(-available.toFloat())
            .toOffset()
    } else {
        Offset.Zero
    }
}

コード全文は下記のようになります。

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun NestedScrollableBottomSheetByLayout() {
    val bottomSheetHeight = 200.dp
    val bottomSheetHeightPx = with(LocalDensity.current) { bottomSheetHeight.toPx() }
    val swipeableState = rememberSwipeableState(initialValue = BottomSheetSwipeableState.COLLAPSED)
    val peekHeightPx = with(LocalDensity.current) { 60.dp.toPx() }
    val scrollState = rememberScrollState()

    Layout(content = {
        Box(
            modifier = Modifier
                .background(Color.Red)
                .swipeable(
                    state = swipeableState,
                    anchors = mapOf(
                        peekHeightPx to BottomSheetSwipeableState.COLLAPSED,
                        bottomSheetHeightPx to BottomSheetSwipeableState.EXPANDED
                    ),
                    orientation = Orientation.Vertical,
                    reverseDirection = true
                )
                .nestedScroll(object : NestedScrollConnection {
                    override fun onPreScroll(
                        available: Offset,
                        source: NestedScrollSource
                    ): Offset {
                        val delta = available.toFloat()
                        return if (delta < 0 && source == NestedScrollSource.Drag) {
                            -swipeableState
                                .performDrag(-delta)
                                .toOffset()
                        } else {
                            Offset.Zero
                        }
                    }

                    override fun onPostScroll(
                        consumed: Offset,
                        available: Offset,
                        source: NestedScrollSource
                    ): Offset {
                        return if (source == NestedScrollSource.Drag) {
                            -swipeableState
                                .performDrag(-available.toFloat())
                                .toOffset()
                        } else {
                            Offset.Zero
                        }
                    }

                    override suspend fun onPreFling(available: Velocity): Velocity {
                        val toFling = Offset(available.x, available.y).toFloat()
                        return if (toFling < 0 && swipeableState.offset.value > Float.NEGATIVE_INFINITY) {
                            swipeableState.performFling(velocity = toFling)
                            available
                        } else {
                            Velocity.Zero
                        }
                    }

                    override suspend fun onPostFling(
                        consumed: Velocity,
                        available: Velocity
                    ): Velocity {
                        swipeableState.performFling(
                            velocity = Offset(
                                available.x,
                                available.y
                            ).toFloat()
                        )
                        return available
                    }

                    private fun Float.toOffset(): Offset = Offset(0f, this)

                    private fun Offset.toFloat(): Float = this.y
                })
                .fillMaxWidth()
                .height(bottomSheetHeight)
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
                    .verticalScroll(scrollState)
            ) {
                repeat(100) {
                    Text(
                        text = "this is text$it", modifier = Modifier
                            .padding(8.dp)
                            .fillMaxWidth()
                    )
                }
            }
        }
    }) { measurables, constraints ->
        val boxPlaceable = measurables.first().measure(constraints)
        layout(width = constraints.maxWidth, height = constraints.maxHeight) {
            boxPlaceable.place(
                0,
                constraints.maxHeight - swipeableState.offset.value.roundToInt()
            )
        }
    }
}

全体のコードは下記サイトも参考にさせていただきました。

How to master Swipeable and NestedScroll modifiers in Jetpack Compose - droidcon

これで完成!と言いたいところですが、これにはまだ大きな欠点があります。
それは swipeable Modifierが非推奨になる という点です。
今後は AnchoredDraggable が推奨されるとのことで、Android Developersから移行手順が公開されていました。

Migrate from Swipeable to AnchoredDraggable  _  Jetpack Compose  _  Android Developers

今回の記事では、今回のコードをどのように移行するかまでは触れないこととします。

また、今回作成したボトムシートにはまだまだ改善点があります。

  • スロットAPIにしてボトムシートの内部コンテンツを受け取る

  • 画面いっぱいまでボトムシートを引き上げられるようにする

  • 内部コンテンツの高さが、ボトムシートを引き上げた時の高さより小さい場合を考慮する

ぜひこれらの改善に挑戦してみてください。

最後に

画面全体を覆わないボトムシート、という特殊な要件にも、Swipeable ModifierやLayout Composable、nestedScroll Modifierを使うことで対応することができました。
Modifierが非推奨になったりとまだ不安定な部分はあるものの、Jetpack Composeは柔軟性に優れていると感じました。

ご紹介したいことが多く、長い記事になってしまいました。
ご覧いただきありがとうございました。