😺

Jetpack Compose と Accompanist で ModalBottomSheet を実装する

2022/11/19に公開

概要

Jetpack ComposeModalBottomSheet を実装する方法について紹介していきます.

現在 ModalBottomSheet の主な実装として

の2つのどちらかのライブラリを用いるのが一般的です.

しかし前者の Material Design による ModalBottomSheet の実装面ではいくつかの問題が発生することがあります.

そこでこの記事では前半に Material Design による ModalBottomSheet の実装と問題点を紹介した後, 後半で Navigation Material for Jetpack Compose による実装を説明していきます.

また補足として StatusBar の部分に Scrim を表示する方法と ModalBottomSheet が画面全体に表示される場合に ModalBottomSheetContent が StatusBar にめり込んでしまう実装の workaround について紹介しています.

環境

検証時のライブラリのバージョンを表に簡単にまとめておきます.
現時点で最新の androidx.compose.material3:material3 では ModalBottomSheet の実装がサポートされておらず, MaterialDesign のライブラリで ModalBottomSheet を実装するためには androidx.compose.material が必要です.

ライブラリ バージョン
androidx.compose.material 1.4.0-alpha02
com.google.accompanist:accompanist-navigation-material 0.27.1
androidx.compose.material3:material3 1.1.0-alpha02

Material Design の場合の ModalBottomSheet の実装

今回 TAP ボタンを押したら ListItem を表示するアプリを例に ModalBottomSheet の実装を紹介していきます.

最初に Material Design の場合の ModalBottomSheet の実装のサンプルコードは示します.
あくまでサンプルなので細かい部分に荒がありますがご了承ください :pray:

MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ModalBottomSheetSampleTheme {
                ModalBottomSheetScreen()
            }
        }
    }
}

@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ModalBottomSheetScreen(modifier: Modifier = Modifier) {
    // 1. rememberModalBottomSheetState() で state を作成
    val state = rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
    )

    val scope = rememberCoroutineScope()

    // 2. ModalBottomSheet を表示させたい画面を ModalBottomSheetLayout で囲む
    ModalBottomSheetLayout(
        sheetContent = {
            // 3. ModalBottomSheet に表示したい Composable をsheetContent に記述
            LazyColumn {
                items(20) {
                    ListItem(
                        text = { Text("Item $it") },
                    )
                }
            }
        },
        modifier = modifier,
        sheetState = state,
    ) {
        Scaffold { insetsPadding ->
            ModalBottomSheetPage(
                onClick = {
                    // 4. state.show() で ModalBottomSheet を呼び出す
                    scope.launch { state.show() }
                },
                modifier = Modifier.padding(insetsPadding)
            )
        }
    }
}

@Composable
fun ModalBottomSheetPage(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier.fillMaxSize()) {
        Button(
            onClick = onClick,
            modifier = Modifier.align(Alignment.Center),
        ) {
            Text(
                text = "TAP!",
                color = Color.White,
            )
        }
    }
}

MainActivity

具体的な実装を説明していきます.
MainActivity からは ModalBottomSheetScreen という Composable を呼び出します.

後述しますが StatusBar の領域に ModalBottomSheet の Scrim を表示したい場合は, WindowCompat.setDecorFitsSystemWindows(window, false) の呼び出しと StatusBar の色を透明にする必要があります.

MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ModalBottomSheetSampleTheme {
                // 1. ModalBottomSheet の実装がある ModalBottomSheetScreen を呼び出す
                ModalBottomSheetScreen()
            }
        }
    }
}

ModalBottomSheetScreen

ModalBottomSheetScreen では ModalBottomSheet を呼び出す基本的な実装を記述しています.

ModalBottomSheetScreen.kt
@Composable
fun ModalBottomSheetScreen(modifier: Modifier = Modifier) {
    // 1. rememberModalBottomSheetState() で state を作成
    val state = rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
    )

    val scope = rememberCoroutineScope()

    // 2. ModalBottomSheet を表示させたい画面を ModalBottomSheetLayout で囲む
    ModalBottomSheetLayout(
        sheetContent = {
            // 3. ModalBottomSheet として表示したい Composable をsheetContent に記述
            LazyColumn {
                items(20) {
                    ListItem(
                        text = { Text("Item $it") },
                    )
                }
            }
        },
        modifier = modifier,
        sheetState = state,
    ) {
        Scaffold { paddingValues ->
            ModalBottomSheetPage(
                onClick = {
                  // 4. state.show() で ModalBottomSheet を呼び出す
                  scope.launch { state.show() }
                },
                modifier = Modifier
                    .padding(paddingValues)
            )
        }
    }
}

簡単に要点をまとめると,

  1. rememberModalBottomSheetState()ModalBottomSheetState を作成
  2. ModalBottomSheet を表示させたい画面を ModalBottomSheetLayout で囲む
  3. ModalBottomSheet として表示させたい ComposablesheetContent に記述
  4. state.show()ModalBottomSheet を表示

の4ステップです.

また ModalBottomSheetState の状態を変更する show(), hide()suspend function として定義されているため, CoroutineScope を用意してあげる必要がありますが簡単に ModalBottomSheet を表示することができますね.

Material Design の場合の ModalBottomSheet の問題点

Material Design でも ModalBottomSheet の表示自体は簡単に行うことができるのですがいくつか問題があります.

ModalBottomSheet の描画領域

Material Design だけではなく Navigation Material for Jetpack Compose でも共通の問題にはなりますが ModalBottomSheet の描画領域は親の Composable に強く制限されます.

極端な例ですが具体的に先ほどのサンプルコードの ModalBottomSheetLayoutScaffold の呼び出し順を入れ替え, 擬似的に BottomBar を表示させた場合で確認してみましょう.

ModalBottomSheetScreen
// ModalBottomSheetLayout と Scaffold の呼び出し順を入れ替える
Scaffold(
    modifier = modifier,
    bottomBar = {
        // 擬似的に BottomBar の領域を確保
        Box(modifier = Modifier
            .fillMaxWidth()
            .height(56.dp)
            .background(Color.DarkGray))
    }
) { insetsPadding ->
    ModalBottomSheetLayout(
        sheetContent = {
            LazyColumn {
                items(20) {
                    ListItem(
                        text = { Text("Item $it") },
                    )
                }
            }
        },
        modifier = Modifier.padding(insetsPadding),
        sheetState = state,
    ) {
        ModalBottomSheetPage(
            onClick = {
                scope.launch { state.show() }
            },
        )
    }
}

上記のコードの動作は次のようになります.

基本的に Jetpack Compose では子の Composable が親の Composable より大きく表示されることがありません.
そのため上記のような実装では ModalBottomSheet が BottomBar の上部から出るようになってしまいます.

そのため

  • Scrim (薄暗い背景) を StatusBar にも表示させたい
  • ModalBottomSheet を BottomBar の下部から表示させたい (BottomBarを覆い隠す用に表示させたい場合)

場合などを考慮するとケースバイケースですが ModalBottomSheet を表示する ModalBottomSheetLayout はできる限りトップで呼び出すことが望ましいです.

ModalBottomSheeetContent が呼び出されるタイミング

Material Design のライブラリの場合 ModalBottomSheetLayout の content が描画されるタイミングとほぼ同じタイミングで sheetContent も Composable されてしまいます.

次のコードのようにログを挿入すると ModalBottomSheet が UI で表示されていないタイミングでも内部では Compose されていることがわかります.

ModalBottomSheetScreen
ModalBottomSheetLayout(
    sheetContent = {
        LazyColumn {
            items(5) {
                LaunchedEffect(Unit) {
                    Log.d("TAG", "Item: $it")
                }
                ListItem(
                    text = { Text("Item $it") },
                )
            }
        }
    },
    modifier = modifier,
    sheetState = state,
) {
    LaunchedEffect(Unit) {
        Log.d("TAG", "ModalBottomSheetLayout#content")
    }
}


ModalBottomSheetLayout#content と Item: $it がほとんど同じタイミングで Compose される

この挙動はできる限りトップで呼び出したい ModalBottomSheetLayout との相性が悪く

  • ModalBottomSheet が状態をもつ
  • 状態に応じて ModalBottomSheet で表示する Composable を出し分ける

時の実装は複雑になることが考えられます.

Navigation Material for Jetpack Compose の場合の ModalBottomSheet の実装

上記の問題を踏まえ Navigation Material for Jetpack Compose の場合の実装を見ていきます.
最初にサンプルコードを示します.

ModalBottomSheetScreen.kt
@OptIn(
    ExperimentalMaterialApi::class,
    ExperimentalMaterial3Api::class,
    ExperimentalMaterialNavigationApi::class,
)
@Composable
fun ModalBottomSheetScreen(modifier: Modifier = Modifier) {
    // 1. rememberBottomSheetNavigator() で bottomSheetNavigator を作成し NavController に追加
    val bottomSheetNavigator = rememberBottomSheetNavigator()
    val navController = rememberNavController(bottomSheetNavigator)

    // 2. ModalBottomSheet を表示させたい画面を Navigation Material for Jetpack Compose の ModalBottomSheetLayout で囲む
    ModalBottomSheetLayout(
        bottomSheetNavigator = bottomSheetNavigator,
        modifier = modifier,
    ) {
        Scaffold { insetsPadding ->
            // 3. ModalBottomSheetLayout 内で NavHost を呼び出す
            NavHost(
                navController = navController,
                startDestination = "main",
                modifier = Modifier.padding(insetsPadding)
            ) {
                composable(route = "main") {
                    ModalBottomSheetPage(
                        onClick = {
                            // 5. bottomSheet の destination へ遷移
                            navController.navigate(route = "modalBottomSheet")
                        },
                    )
                }

                // 4. bottomSheet の destination を登録する
                bottomSheet(route = "modalBottomSheet") {
                    LazyColumn {
                        items(20) {
                            ListItem(
                                text = { Text("Item $it") },
                            )
                        }
                    }
                }
            }
        }
    }
}

こちらも簡単に要点をまとめると

  1. rememberBottomSheetNavigator()bottomSheetNavigator を作成し NavController に追加
  2. ModalBottomSheet を表示させたい画面を Navigation Material for Jetpack ComposeModalBottomSheetLayout で囲む
  3. ModalBottomSheetLayout 内で NavHost を呼び出す
  4. bottomSheetdestination を登録する
  5. bottomSheetdestination へ遷移

となっており MaterialDesign の ModalBottomSheetLayout と比較すると ModalBottomSheeetContent の呼び出し位置・呼び出しタイミングが異なります.

Navigation Material for Jetpack Compose では ModalBottomSheet で表示する Composable を NavGraph として扱うことができるため表示に必要な情報は argument を通して渡すことができ, かつ複数の bottomSheet の destination を登録することができるので使い勝手が良いですね.

composable と bottomSheet で状態を共有したい場合は ViewModel を共有する等の工夫が必要になりますが基本的には Navigation Material for Jetpack Compose を使うと良さそうです.

補足

最後に StatusBar の領域に Scrim を表示させる実装について紹介します.

また ModalBottomSheet が今回の例のように画面全体に表示されるような場合は通常通りに実装を行うと ModalBottomSheet が StatusBar にめり込んでしまいます.
そこで workaround の実装を検討しています.

StatusBar の領域に Scrim を表示させる

ModalBottomSheet が表示される時の薄暗い背景 (Scrim) を StatusBar の領域に描画するためには

  1. StatusBar の領域に Composable を表示できるようにする
  2. StatusBar を透明にする

必要があります.

具体的に考えると先述の通り Composable は親の描画領域を超えて表示することは難しいので Activity で WindowCompat.setDecorFitsSystemWindows(window, false) を呼び出し setContent 直下の Composable を StatusBar にも表示できるようにする必要があります.

MainActivity
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 1. StatusBar に Composable を表示できるようにする
        WindowCompat.setDecorFitsSystemWindows(window, false)

        // accompanist-systemuicontroller を使わない場合は Activity で StatusBar を透明にする
        // window.statusBarColor = Color.Transparent.toArgb()

        setContent {
            ModalBottomSheetSampleTheme {
                ModalBottomSheetScreen()
            }
        }
    }
}

そして次に StatusBar を透明にします.
ModalBottomSheet の閉開にあわせて StatusBar のアイコンを変化させたい場合は Accompanist の System UI Controller for Jetpack Compose を用いると便利です.

必要に応じて StatusBar の領域に白色以外の色をつける場合は トップの Composable に色をつけると良さそうです.

ModalBottomSheet が画面全体に表示されない場合は以上の対応で終わりです.

ModalBottomSheetSampleTheme
@Composable
fun ModalBottomSheetSampleTheme(
    content: @Composable () -> Unit
) {
    val colorScheme = LightColorScheme

    // 1. Accompanist の System UI Controller for Jetpack Compose を用いて StatusBar の色を制御
    val systemUiController = rememberSystemUiController()

    SideEffect {
        systemUiController.setSystemBarsColor(
            // 2. StatusBar を透明に
            color = Color.Transparent,
            darkIcons = true ,
        )
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

最後に ModalBottomSheetContent が画面全体に表示される場合を考え全体的に細かい調整をします.
下記の実装を行わない場合, ModalBottomSheetContent が StatusBar の領域にも描画されてしまいます (ModalBottomSheetContent が全画面で表示されることがない場合は不要です).

ModalBottomSheetLayout (Scrim も含む) は StatusBar の領域まで表示されるようにし, ModalBottomSheetContent は StatusBar の領域に表示されないように上部に StatusBar 分の高さを確保しておきます.

また ModalBottomSheetLayout で ModalBottomSheetContent の sheetElevation と sheetBackgroundColor が設定されてしまっているのでそれぞれ 0.dp, 透明に設定し bottomSheet 側で設定を行うようにします.

ModalBottomSheetScreen
@OptIn(
    ExperimentalMaterialApi::class,
    ExperimentalMaterial3Api::class,
    ExperimentalMaterialNavigationApi::class,
)
@Composable
fun ModalBottomSheetScreen(modifier: Modifier = Modifier) {
    // 1. ModalBottomSheetState の状態に応じて StatusBar のアイコンの色を変えるために rememberModalBottomSheetState を呼び出す
    val sheetState = rememberModalBottomSheetState(
        ModalBottomSheetValue.Hidden,
    )
    val bottomSheetNavigator = remember(sheetState) {
        BottomSheetNavigator(sheetState = sheetState)
    }
    val navController = rememberNavController(bottomSheetNavigator)

    val systemUiController = rememberSystemUiController()


    LaunchedEffect(sheetState.targetValue) {
        // 2. ModalBottomSheetState の状態に応じて StatusBar のアイコンの色を変える
        systemUiController.statusBarDarkContentEnabled = sheetState.targetValue == ModalBottomSheetValue.Hidden
    }

    ModalBottomSheetLayout(
        bottomSheetNavigator = bottomSheetNavigator,
        modifier = modifier.background(MaterialTheme.colorScheme.background),
        // 3. bottomSheet 内で値を設定するために 0.dp と Color.Transparent を指定
        sheetElevation = 0.dp,
        sheetBackgroundColor = Color.Transparent,
    ) {
        Scaffold(
            modifier = Modifier.systemBarsPadding(),
        ) { insetsPadding ->
            NavHost(
                navController = navController,
                startDestination = "main",
                modifier = Modifier.padding(insetsPadding)
            ) {
                composable(route = "main") {
                    ModalBottomSheetPage(
                        onClick = {
                            navController.navigate(route = "modalBottomSheet")
                        },
                    )
                }

                bottomSheet(route = "modalBottomSheet") {
                    // 4. StatusBar に表示されてしまわないように statusBarsPadding を呼び出し color と elevation も指定する
                    Surface(
                        modifier = Modifier.statusBarsPadding(),
                        color = MaterialTheme.colorScheme.background,
                        elevation = ModalBottomSheetDefaults.Elevation,
                    ) {
                        LazyColumn(modifier = Modifier.navigationBarsPadding()) {
                            items(20) {
                                ListItem(
                                    text = { Text("Item $it") },
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}

上記のコードでは 1 と 2 の部分で ModalBottomSheet の閉開状態に応じて StatusBar のアイコンの色を変え, 3 と 4 の部分で ModalBottomSheet が StatusBar の領域に表示されないようにしています.

まとめ

JetpackCompose において ModalBottomSheet を実装したい場合は Jetpack Navigation Compose Material を使うと良さそうです.

しかし Jetpack Navigation Compose Material は内部で MaterialDesgin に依存しているため将来的に MaterialDesign3 などに組み込まれる時は StatusBar などもう少し扱いやすくなっていると嬉しいなと個人的に思っています..

参考

Discussion