🦀

Jetpack Composeのモーダルって直感的じゃないですよね

2022/12/09に公開

スペースマーケットでAndroidエンジニアをしていますseoです。
本日はJetpack ComposeでBottomSheetModalを実装する機会があったので、そのときの工夫についてシェアしたいと思います。

ModalBottomSheetLayout

「Jetpack Compose bottom sheet」などでググれば、真っ先に出てくる実装方法がこちらです。

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MainScreen() {
    val sheetState = rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
        confirmStateChange = { it != ModalBottomSheetValue.HalfExpanded }
    )
    val coroutineScope = rememberCoroutineScope()

    BackHandler(sheetState.isVisible) {
        coroutineScope.launch { sheetState.hide() }
    }

    ModalBottomSheetLayout(
        sheetState = sheetState,
        sheetContent = { BottomSheet() },
        modifier = Modifier.fillMaxSize()
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(top = 24.dp)
                .padding(horizontal = 24.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = "メインスクリーンです",
                modifier = Modifier.fillMaxWidth(),
                style = MaterialTheme.typography.h4,
                textAlign = TextAlign.Center
            )
            Spacer(modifier = Modifier.height(32.dp))
            Button(
                onClick = {
                    coroutineScope.launch {
                        if (sheetState.isVisible) sheetState.hide()
                        else sheetState.show()
                    }
                }
            ) {
                Text(text = "bottomSheetが出ます")
            }
        }
    }
}

@Composable
fun BottomSheet() {
    Column(
        modifier = Modifier.padding(32.dp)
    ) {
        Text(
            text = "Bottom sheet",
            style = MaterialTheme.typography.h6
        )
        Spacer(modifier = Modifier.height(32.dp))
        Text(
            text = "外側をタップすると閉じます",
            style = MaterialTheme.typography.body1
        )
    }
}

BottomSheetを出したい画面をModalBottomSheetLayoutでwrapし、sheetStateによって、出したり、閉じたりするオーソドックスな方法です。
ただ、どんどんViewが複雑化していき、さまざまな機能追加がある場合、モーダルはあくまでメイン画面の補助的な画面の位置付けが多いと思います。

例えば、弊社のアプリで言うと、予約詳細画面に設置してある予約延長機能がこちらにあたります。

*アプリのダウンロードはこちらから

すでにあるViewをModalBottomSheetLayoutでwrapするのが、直感的でなく何か嫌な感じだな、と思ったので、ボタンを押すとModalが出現してくれるような実装を紹介したいと思います。

実装

今回は、Activityの拡張関数として、showAsBottomSheetメソッドを定義し、ActivityからBottomSheetを呼び出すようにしてみたいと思います。

// ActivityからModalを呼び出すメソッド
fun Activity.showAsBottomSheet(content: @Composable (() -> Unit) -> Unit) {
    // 後述
}

次にModalBottomSheetLayoutを使って、Modalの原型となるBottomSheetWrapperを作っていきます。

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun BottomSheetWrapper(
    parent: ViewGroup,
    composeView: ComposeView,
    content: @Composable (() -> Unit) -> Unit
) {
    val coroutineScope = rememberCoroutineScope()
    val modalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
    var isSheetOpened by remember { mutableStateOf(false) }

    ModalBottomSheetLayout(
        sheetBackgroundColor = Color.Transparent,
        sheetState = modalBottomSheetState,
        sheetContent = {
            content {
                coroutineScope.launch {
                    modalBottomSheetState.hide()
                }
            }
        }
    ) {}

    BackHandler {
        coroutineScope.launch {
            modalBottomSheetState.hide()
        }
    }

    LaunchedEffect(modalBottomSheetState.currentValue) {
        when (modalBottomSheetState.currentValue) {
            ModalBottomSheetValue.Hidden -> {
                when {
                    isSheetOpened -> parent.removeView(composeView)
                    else -> {
                        isSheetOpened = true
                        modalBottomSheetState.show()
                    }
                }
            }
            else -> {}
        }
    }
}

最後に、BottomSheetWrapperをaddViewするメソッドを定義し、Activity.showAsBottomSheetメソッドでコールします。

private fun addContentToView(
    viewGroup: ViewGroup,
    content: @Composable (() -> Unit) -> Unit
) {
    viewGroup.addView(
        ComposeView(viewGroup.context).apply {
            setContent {
                BottomSheetWrapper(viewGroup, this, content)
            }
        }
    )
}

fun Activity.showAsBottomSheet(content: @Composable (() -> Unit) -> Unit) {
    val viewGroup = this.findViewById(android.R.id.content) as ViewGroup
    addContentToView(viewGroup, content)
}

使ってみましょう

@Composable
fun MainScreen() {
    val activity = LocalContext.current as Activity
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(top = 24.dp)
            .padding(horizontal = 24.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "メインスクリーンです",
            modifier = Modifier.fillMaxWidth(),
            style = MaterialTheme.typography.h4,
            textAlign = TextAlign.Center
        )
        Spacer(modifier = Modifier.height(32.dp))
        Button(
            onClick = {
                activity.showAsBottomSheet { hideModal ->
                    BottomSheet()
                }
            }
        ) {
            Text(text = "bottomSheetが出ます")
        }
    }
}

activityからshowAsBottomSheetを呼び出し、ラムダ式の中はModalのComposeを記述します。
Modal内に、「閉じる」ボタンがある場合は、内引数のhideModalを渡してあげると、Modalを閉じることができます。

公式の通りに実装せえ、と言われそうですが、なんか直感的じゃない感じがするんですよね...
お好みで使ってみてください!!

最後に

スペースマーケットでは、パーティー用途から1人用の作業ワークスペースなど、さまざまな用途のスペースが15分単位で借りられます。

実は弊社オフィス自体もこのように貸し出ししてます!!

https://www.spacemarket.com/spaces/spm_sharespace_l/rooms/eI-d2aqc3lExsk0R/

会議室や個室ブースも貸し出しています。

https://www.spacemarket.com/spaces/spm_sharespace/rooms/oFaQ2lpJwp6fY1F5/

https://www.spacemarket.com/spaces/spm_sharespace/rooms/-07atbgLb838J_PX/

いつも自宅でコーディング飽きたな、と思ったそこのあなた、
スペースマーケットのオフィスで気分転換してみてはいかがでしょうか?

また、アプリエンジニアも絶賛募集中なので、興味ある方はご応募お待ちしています!

スペースマーケット Engineer Blog

Discussion