Android Advent Calendar 2022の10日目の記事です。
本記事では、Compose Material のライブラリで提供されている ModalBottomSheetLayout の使い方とTipsを紹介します。
※ 2022年12月10日(ver 1.3.1)時点で ExperimentalMaterialApi であることから動作は今後変更される可能性があります。
ModalBottomSheetLayoutについて
マテリアルデザインにおけるBottomSheetとしては、Standard, Modal, Expanded の3種類が紹介されており、その中のModalが本記事で紹介するLayoutに該当します。
Android Viewの中では BottomSheetDialogFragment に相当する機能がComposeで提供されたもので、BottomSheetの中でも背景が暗転してモーダルの機能のみにフォーカスして伝えるような使い方をするコンポーネントとなります。
https://m2.material.io/components/sheets-bottom#modal-bottom-sheet
現状、ModalBottomSheetLayoutは androidx.compose.material
でのみ提供されており、androidx.compose.material3
ではまだ提供されていません。m3のドキュメントでは、Expandedの記載が消えたものの、StandardとModalは引き続き残っていくようです。
https://m3.material.io/components/bottom-sheets/overview
実装
Composable function を作成
BottomSheetDialogFragmentとの大きな違いとして、ModalBottomSheetLayoutは表示したいComposableをwrapする形で呼び出す必要があります。
下記のように sheetContent
へBottomSheet内で表示したいコンテンツのComposableを渡し、content
へ本来のその画面のコンテンツを渡します。
多くの場合、画面全体を暗転しつつモーダルを表示する使い方となると思いますので、その画面のScaffoldなどをcontent
に対して渡すことになります。
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
ModalBottomSheetLayout(
modifier = modifier,
sheetState = sheetState,
sheetContent = {
// BottomSheet内に表示するコンテンツ
},
content = {
// BottomSheet自体を表示したい画面のコンテンツ
},
)
表示・非表示の呼び出し
ModalBottomSheetを表示/非表示にする際には、ModalBottomSheetState.show()
または hide()
を呼び出します。
suspend functionとなっているため、ボタンクリックなどの関数内で実行したい場合には、rememberCoroutineScope()
、UiStateなどの変更をトリガーとしてComposable内で起動したい場合には LaunchedEffect()
を利用して実行します。
ボタンタップなど関数内で実行
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
Button(
onClick = {
coroutineScope.launch {
sheetState.show()
}
}
) {
Text(text = "Show BottomSheet")
}
UiStateの変更などでComposable内で実行
val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
LaunchedEffect(uiState.showModal) {
if (uiState.showModal) {
sheetState.show()
} else {
sheetState.hide()
}
}
Tips・トラブルシューティング
BottomSheet内のコンテンツの高さで最初から表示したい
デフォルトでは、画面の半分の高さで開かれるため、BottomSheetのコンテンツの高さが画面の半分よりも大きい場合は若干スクロールが必要になってしまいます。
最初からコンテンツの高さ最大で表示してほしい場合には、rememberModalBottomSheetState
の引数に isSkipHalfExpanded
が用意されているため、こちらをtrueにすることで可能です。
sheetContentに高さがないとIllegalArgumentExceptionになる
ModalBottomSheetLayoutの引数であるsheetContentには、BottomSheet内で表示したいコンテンツを渡す必要がありますが、この際に高さのあるコンテンツを渡さないとIllegalArgumentExceptionとなりクラッシュします。
java.lang.IllegalArgumentException: The initial value must have an associated anchor.
例えば、以下のようにsheetContentの描画のために、APIなどで取得したなんらかのデータ(ここではModalSheetData)が必要となる場合に下記のようにnullチェックしたとします。
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MainModalBottomSheetLayout(
modifier: Modifier = Modifier,
sheetState: ModalBottomSheetState,
modalSheetData: ModalSheetData?,
content: @Composable () -> Unit,
) {
ModalBottomSheetLayout(
modifier = modifier,
sheetState = sheetState,
sheetContent = {
if (modalSheetData != null) {
ModalSheetContent(modalSheetData = modalSheetData)
}
},
content = content,
)
}
この場合、nullでModalSheetContentが渡らなかった場合に、IllegalArgumentExceptionとなりクラッシュします。(ModalSheetContent内で早期リターンなどした場合も同様)
呼び出し前にチェックを行い、nullableではなくnonnullで最初から受け付ければ良いのではと思うかもしれませんが、ModalBottomSheetLayoutはcontent(本来の画面で表示するためのComposable)をwrapする必要があるため、ModalBottomSheet自体を描画しないということはできません。この方法で回避しようとするとmodalSheetDataがあるときだけ画面のcontentをwrapするという実装になりやや冗長になります。
これを回避するワークアラウンドとしては、nullの場合のはSpacerで高さのあるコンテンツを挿入する方法が紹介されています。ややハッキーなコードのため、stableでは解消されると嬉しく思います。
@Composable
fun ModalBottomSheetEmptyContent() {
Spacer(modifier = Modifier.size(1.dp))
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MainModalBottomSheetLayout(
modifier: Modifier = Modifier,
sheetState: ModalBottomSheetState,
modalSheetData: ModalSheetData?,
content: @Composable () -> Unit,
) {
ModalBottomSheetLayout(
modifier = modifier,
sheetState = sheetState,
sheetContent = {
if (modalSheetData != null) {
ModalSheetContent(modalSheetData = modalSheetData)
} else {
ModalBottomSheetEmptyContent()
}
},
content = content,
)
}
- https://stackoverflow.com/questions/68623965/jetpack-compose-modalbottomsheetlayout-throws-java-lang-illegalargumentexception
- https://stackoverflow.com/questions/66511309/jetpack-compose-bottom-sheet-initialization-error
ステータスバーの位置も暗転してほしい
下記のように何もしないとステータスバーだけ暗転が適用されない挙動になります。
ステータスバー領域まで暗転するには、WindowCompat.setDecorFitsSystemWindows
を利用してシステムバー(ステータスバーとナビゲーションバー)領域まで画面を広げ、ステータスバーを透過することで対応できます。
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
AppTheme {
MainScreen(viewModel)
}
}
}
}
ステータスバーやナビゲーションバーの調整には、AccompanistのSystemUiControllerが利用できます。
@ExperimentalLifecycleComposeApi
@Composable
fun MainScreen(viewModel: MainViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val systemUiController = rememberSystemUiController()
SideEffect {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = true,
)
}
MainScreen(uiState = uiState)
}
@Composable
private fun MainScreen(
uiState: UiState,
) {
...
}
これだけだと、ステータスバーにめり込んだような見た目になるので、下記のようにModifier.systemBarsPadding()
を適用することで、適切に表示されます。
Modifier.systemBarsPadding()
ではステータスバーとナビゲーションバーのインセットに合わせてpaddingが適用されます。個別に適用したい場合には、Modifier.statusBarsPadding()
やModifier.navigationBarsPadding()
にて適用できます。
MainModalBottomSheetLayout(
sheetState = sheetState,
) {
Scaffold(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
) { paddingValue ->
MainContent(modifier = Modifier.padding(paddingValue))
}
}
シートの状態変化をハンドリングしたい
シート外がタップされてキャンセルされたイベントをフックしたいケースなど、BottomSheetの状態変化を検知したいケースがあります。
方法1 confirmStateChangeを利用する
下記のように rememberModalBottomSheetState
の引数には confiromStateChange
を渡すことができます。
val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
confirmStateChange = {
if (it == ModalBottomSheetValue.Hidden) {
// 実行したい処理
}
true
}
)
ModalBottomSheetLayoutでは下記のように状態が変わるタイミングで confirmStateChange
が呼び出され、trueを返している場合には、hideやexpandなどの後続の処理が呼び出されるようになっています。
// ModalBottomSheet.kt より抜粋
.semantics {
if (sheetState.isVisible) {
dismiss {
if (sheetState.confirmStateChange(Hidden)) {
scope.launch { sheetState.hide() }
}
true
}
if (sheetState.currentValue == HalfExpanded) {
expand {
if (sheetState.confirmStateChange(Expanded)) {
scope.launch { sheetState.expand() }
}
true
}
} else if (sheetState.hasHalfExpandedState) {
collapse {
if (sheetState.confirmStateChange(HalfExpanded)) {
scope.launch { sheetState.halfExpand() }
}
true
}
}
}
},
注意点として、recomposeが大量に走るケースなどでconfirmStateChangeにViewModelのメソッドを実行する処理などを直接記述すると、都度ModalBottomSheetStateが別インスタンスとして生成されてしまい意図しない挙動になります。
そのような処理の場合に備え、下記のように rememberUpdatedState
を利用しておくと良いでしょう。
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MainScreen(
uiState: UiState,
onModalBottomSheetCanceled: () -> Unit,
) {
val cancelModalBottomSheet = rememberUpdatedState(newValue = onModalBottomSheetCanceled)
val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
confirmStateChange = {
if (it == ModalBottomSheetValue.Hidden) {
cancelModalBottomSheet.value.invoke()
}
true
}
)
方法2 snapshotFlowを利用する
別の方法としては、 ModalBottomSheetState.currentValue
を snapshotFlow
で監視して特定の状態の時のみ実行するという方法もあります。
ModalBottomSheetStateに対して直接影響を与えないので、こちらの方が便利に使える場面が多いかと思います。
LaunchedEffect(modalBottomSheetState) {
snapshotFlow { modalBottomSheetState.currentValue }
.filter { it == ModalBottomSheetValue.Hidden }
.collect {
// 実行したい処理
}
}
所感
BottomSheetDialogFragmentがある程度グローバルに表示できる仕組みだったのに対し、ModalBottomSheetLayoutはレイアウトであるため、各画面でそれぞれ実装する必要が出てきました。
RecyclerViewに対してはLazyColumnの方が圧倒的に実装が楽だと思いますが、BottomSheetDialogFragmentに対してはModalBottomSheetLayoutの方が実装がやや複雑になる印象がありました。
複数の画面で共通のBottomSheetを利用したい場合、特定機能のModalBottomSheetLayoutとして共通化してしまうと、将来複数種類のBottomSheetを1画面で表示する必要が出たときに、複数のModalBottomSheetLayoutでwrapすることになってしまいます。
このため、ModalBottomSheetLayoutは画面単位でComposableを作っておき、sheetContentで渡す中身のComposableを共通化するような使い方が良いかと思いました。
記載内容について誤りやより良い実装方法がありましたら、コメント等で教えていただけると嬉しいです。