본문 바로가기

안드로이드/Compose Dialog

[안드로이드 Jetpack Compose Dialog 시리즈] BottomSheetDialog 구현하기 #2


안녕하세요. 이번 포스팅에서는 안드로이드 Jetpack Compose에서 BottomSheetDialog를 구현하는 방법에 대해서 설명하겠습니다. BottomSheetDialog는 하단에서 위로 올라오는 형태의 dialog로써 현대 앱에서 많이 볼 수 있습니다. 기존의 xml에서 구현할 때보다 많이 쉬워졌는데 아래 예제를 통해서 설명하겠습니다.

안드로이드 Jetpack Compose에서 구현한 BottomSheetDialog
안드로이드 Jetpack Compose에서 구현한 BottomSheetDialog

1. Compose에서 BottomSheetDialog 사용하기

안드로이드 Jetpack Compose에서 BottomSheetDialog를 사용하면 사용자에게 추가 정보를 제공하거나 선택을 요구하는 등의 상호작용을 제공하는 상황에서 효과적입니다. Compose에서는 Material3에서 제공하는 ModalBottomSheet 컴포저블을 활용하여 손쉽게 구현할 수 있습니다.

2. BottomSheetDialog 만들기

먼저, ModalBottomSheet 컴포저블을 활용하여 BottomSheetDialog의 레이아웃을 만듭니다. ModalBottomSheet은 하단에서 슬라이드 업되어 나타날 UI(컴포저블)를 정의할 수 있고, sheetState를 사용하면 BottomSheet의 상태(확장, 숨김)를 제어할 수도 있습니다. 아래 코드는 BottomSheetDialog의 높이값을 300dp로 고정하고 Text, Button, Column(ScrollView)를 구현한 예제입니다.

// CustomBottomSheetDialog.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomBottomSheetDialog(
    title: String,
    description: String,
    onClickCancel: () -> Unit,
    onClickConfirm: () -> Unit
) {
    val modalBottomSheetState = rememberModalBottomSheetState()
    val scope = rememberCoroutineScope()
    val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()

    ModalBottomSheet(
        onDismissRequest = { onClickCancel() },
        sheetState = modalBottomSheetState,
        dragHandle = { BottomSheetDefaults.DragHandle() },
    ) {
        Column(
            modifier = Modifier
                .padding(top = 10.dp, start = 10.dp, end = 10.dp, bottom = bottomPadding)
                .fillMaxWidth()
                .height(300.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = title,
                textAlign = TextAlign.Center,
                style = TextStyle(
                    color = Color.Black,
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Bold
                )
            )
            Spacer(modifier = Modifier.height(10.dp))

            Text(
                text = description,
                textAlign = TextAlign.Center,
                style = TextStyle(
                    color = Color.Gray,
                    fontSize = 14.sp,
                    fontWeight = FontWeight.Normal
                )
            )

            Spacer(modifier = Modifier.height(10.dp))

            // 고정 높이 200.dp의 컨테이너 내 스크롤 가능한 국기 이모지 표시
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(100.dp)
                    .padding(10.dp)
                    .verticalScroll(rememberScrollState()),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                CountryList()
            }

            Spacer(modifier = Modifier.height(10.dp))

            Button(onClick = {
                scope.launch {
                    modalBottomSheetState.hide()
                }.invokeOnCompletion {
                    onClickConfirm()
                }
            }) {
                Text("Confirm")
            }
        }
    }
}

@Composable
fun CountryList() {
    val countries = listOf(
        Pair("United States", "\uD83C\uDDFA\uD83C\uDDF8"),
        Pair("Canada", "\uD83C\uDDE8\uD83C\uDDE6"),
        Pair("India", "\uD83C\uDDEE\uD83C\uDDF3"),
        Pair("Germany", "\uD83C\uDDE9\uD83C\uDDEA"),
        Pair("France", "\uD83C\uDDEB\uD83C\uDDF7"),
        Pair("Japan", "\uD83C\uDDEF\uD83C\uDDF5"),
        Pair("China", "\uD83C\uDDE8\uD83C\uDDF3"),
        Pair("Brazil", "\uD83C\uDDE7\uD83C\uDDF7"),
        Pair("Australia", "\uD83C\uDDE6\uD83C\uDDFA"),
        Pair("Russia", "\uD83C\uDDF7\uD83C\uDDFA"),
        Pair("United Kingdom", "\uD83C\uDDEC\uD83C\uDDE7"),
    )
    Column {
        countries.forEach {
            Text(text = it.second)
        }
    }
}

3. BottomSheetDialog 사용하

안드로이드 Jetpack Compose의 BottomSheetDialog를 사용하기 위해서는 상태 관리가 필요합니다. 해당 dialog를 공통으로 사용하기 위해서 CustomBottomSheetDialogState 타입을 만들어주었고, MutableState를 통해 표시 여부를 제어했습니다. dialog의 노출여부를 title값이 공백인지 아닌지로 판단했는데, 좀 더 명확한 boolean값을 사용하면 더 좋을 것 같습니다.

// CustomBottomSheetDialogState.ke
data class CustomBottomSheetDialogState(
    val title: String = "",
    val description: String = "",
    val onClickConfirm: () -> Unit = {},
    val onClickCancel: () -> Unit = {},
)

// MainViewModel.kt
@HiltViewModel
class MainViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle?,
) : ViewModel() {
    val customBottomSheetDialogState: MutableState<CustomBottomSheetDialogState> = mutableStateOf(
        CustomBottomSheetDialogState()
    )

    fun showBottomSheetDialog() {
        customBottomSheetDialogState.value = CustomBottomSheetDialogState(
            title = "국기 이모지",
            description = "나라별 국기 이모지를 확인해보세요.",
            onClickConfirm = {
                resetBottomSheetDialogState()
            },
            onClickCancel = {
                resetBottomSheetDialogState()
            }
        )
    }

    fun resetBottomSheetDialogState() {
        customBottomSheetDialogState.value = CustomBottomSheetDialogState()
    }
}
// MainScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
    mainNavController: NavHostController,
    viewModel: MainViewModel
) {

    val customBottomSheetDialogState = viewModel.customBottomSheetDialogState.value

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(text = "메인화면") },
                navigationIcon = {
                    IconButton(onClick = { mainNavController.popBackStack() }) {
                        Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null)
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier.padding(paddingValues)
        ) {
        
        	...
            
            Button(
                onClick = { viewModel.showBottomSheetDialog() },
                shape = RectangleShape,
            ) {
                Text(
                    text = "2. Show BottomSheetDialog",
                    textAlign = TextAlign.Center,
                    style = TextStyle(
                        fontSize = 14.sp,
                        fontWeight = FontWeight.Bold
                    )
                )
            }

            if (customBottomSheetDialogState.title.isNotBlank()) {
                CustomBottomSheetDialog(
                    title = customBottomSheetDialogState.title,
                    description = customBottomSheetDialogState.description,
                    onClickCancel = { customBottomSheetDialogState.onClickCancel() },
                    onClickConfirm = { customBottomSheetDialogState.onClickConfirm() }
                )
            }
        }
    }
}

4. BottomSheetDialog 정리

이번 포스팅에서는 안드로이드 Jetpack Compose의  ModalBottomSheet를 사용해서 BottomSheetDialog를 구현했습니다. Compose에서 제공하는 ModalBottomSheetState를 활용하면 BottomSheetDialog의 상태를 확인할 수 있고, show(), hide() 등의 함수를 호출해서 dialog의 출현을 제어할 수도 있습니다.

위에 작성한 예제처럼 컴포저블을 자유롭게 배치해서 원하는 형태의 BottomSheetDialog를 구현하는데 도움이 됐으면 좋겠습니다.