Jetpack Compose と Accompanist で ModalBottomSheet を実装する
概要
Jetpack Compose で ModalBottomSheet
を実装する方法について紹介していきます.
現在 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:
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 の色を透明にする必要があります.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ModalBottomSheetSampleTheme {
// 1. ModalBottomSheet の実装がある ModalBottomSheetScreen を呼び出す
ModalBottomSheetScreen()
}
}
}
}
ModalBottomSheetScreen
ModalBottomSheetScreen
では ModalBottomSheet
を呼び出す基本的な実装を記述しています.
@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)
)
}
}
}
簡単に要点をまとめると,
-
rememberModalBottomSheetState()
でModalBottomSheetState
を作成 -
ModalBottomSheet
を表示させたい画面をModalBottomSheetLayout
で囲む -
ModalBottomSheet
として表示させたいComposable
をsheetContent
に記述 -
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 に強く制限されます.
極端な例ですが具体的に先ほどのサンプルコードの ModalBottomSheetLayout
と Scaffold
の呼び出し順を入れ替え, 擬似的に BottomBar を表示させた場合で確認してみましょう.
// 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 されていることがわかります.
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 の場合の実装を見ていきます.
最初にサンプルコードを示します.
@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") },
)
}
}
}
}
}
}
}
こちらも簡単に要点をまとめると
-
rememberBottomSheetNavigator()
でbottomSheetNavigator
を作成しNavController
に追加 -
ModalBottomSheet
を表示させたい画面をNavigation Material for Jetpack Compose
のModalBottomSheetLayout
で囲む -
ModalBottomSheetLayout
内でNavHost
を呼び出す -
bottomSheet
のdestination
を登録する -
bottomSheet
のdestination
へ遷移
となっており 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 の領域に描画するためには
- StatusBar の領域に Composable を表示できるようにする
- StatusBar を透明にする
必要があります.
具体的に考えると先述の通り Composable は親の描画領域を超えて表示することは難しいので Activity で WindowCompat.setDecorFitsSystemWindows(window, false)
を呼び出し setContent 直下の Composable を StatusBar にも表示できるようにする必要があります.
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 が画面全体に表示されない場合は以上の対応で終わりです.
@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 側で設定を行うようにします.
@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