티스토리 뷰

# 개요

바텀시트는 모바일 앱에서는 흔하게 볼 수 있는 컴포넌트 입니다. 당연하게도 Compose는 BottomSheet를 제공하고 있습니다. 구현 시 아래 3가지 컴포저블 중 하나를 사용하면 됩니다.

  1. ModalBottomSheet
  2. ModalBottomSheetLayout
  3. BottomSheetScaffold

Android 개발자 Compose 가이드로 소개되어 있는 ModalBottomSheet부터 살펴보겠습니다.

 

 

#1> ModalBottomSheet 를 사용해 바텀시트 노출하기

가장 간단한 형태로, 바텀시트가 노출되어야 하는 시점에 이 Composable이 노출되게 하면 됩니다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModalBottomSheetScreen() {
    var isShowBottomSheet by remember { mutableStateOf(false) }

    Box(modifier = Modifier.fillMaxSize()) {
        Button(
            modifier = Modifier.align(Alignment.Center),
            onClick = { isShowBottomSheet = !isShowBottomSheet }
        ) {
            if (isShowBottomSheet) {
                ModalBottomSheet(
                    scrimColor = Color.Cyan.copy(alpha = 0.3f),
                    onDismissRequest = { isShowBottomSheet = !isShowBottomSheet },
                    content = { BottomSheetContent() }
                )
                Text(text = "Hide Bottom Sheet")
            } else {
                Text(text = "Show Bottom Sheet")
            }
        }
    }
}

 

심플하죠? 간단하지만 쓸 수 없습니다. 왜냐하면 이녀석, 하단 네비게이션 바를 가려버리거든요.

 

 

스크린의 완전 바닥부터 노출이 됩니다. 아래와 같이 인셋을 설정하면 조금 낫습니다.

 

WindowCompat.setDecorFitsSystemWindows(window, false)

 

 

그래도 내려갈 때 하단 네비게이션 바를 가립니다.

 

create by Chat GPT 4o

 

아직 포기하긴 이릅니다. 총알이 두발 남았거든요.

 

#2> ModalBottomSheetLayout 를 사용해 바텀시트 노출하기

화면의 나머지 부분과의 상호작용을 차단하는 바텀시트를 제공한다. [링크]

 

모달 바텀시트를 제공하는 컨테이너 형태의 Composable 입니다. 바텀시트와 컨텐츠를 분리해서 선언할 수 있습니다. 이녀석, 네비게이션 바를 가리지 않습니다.

 

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ModalBottomSheetLayoutScreen() {
    var skipHalfExpanded by remember { mutableStateOf(false) }
    val state = rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
        skipHalfExpanded = skipHalfExpanded
    )
    val scope = rememberCoroutineScope()

    ModalBottomSheetLayout(
        sheetState = state,
        sheetContent = {
            LazyColumn {
                items(50) {
                    ListItem(
                        text = { Text("Item $it") },
                        icon = {
                            Icon(
                                Icons.Default.Favorite,
                                contentDescription = "Localized description"
                            )
                        }
                    )
                }
            }
        }
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Row(
                Modifier.toggleable(
                    value = skipHalfExpanded,
                    role = Role.Checkbox,
                    onValueChange = { checked -> skipHalfExpanded = checked }
                )
            ) {
                Checkbox(checked = skipHalfExpanded, onCheckedChange = null)
                Spacer(Modifier.width(16.dp))
                Text("Skip Half Expanded State")
            }
            Spacer(Modifier.height(20.dp))
            Button(
                onClick = {
                    scope.launch {
                        state.show()
                    }
                }
            ) {
                Text("Click to show sheet")
            }
        }
    }
}

 

위 코드를 실행하시면 샘플 동작을 확인하실 수 있습니다. 주요 특징은 다음과 같습니다.

  • 반만 열림 상태 허용여부 설정 가능.
  • 바텀시트 핸들 제스쳐를 허용할 것인지 설정 가능.

이렇게 해답을 찾은 것 같았으나, 역시 아웃입니다. 딤 영역 터치 제어가 불가능하기 때문이죠.

 

다시말해 [ 바텀시트의 확인 버튼 클릭 시에만 닫힘 ] 과 같은 동작을 구현하기 어렵습니다. 왜인지 모르겠으나 구글에서 관련 프로퍼티를 제공하지 않습니다.

 

그렇다고 불가능 하진 않습니다. 시트 상태값 변경 Callback 을 받아 트릭을 사용해 처리가 가능합니다.

 

 

#3> BottomSheetScaffold 를 사용해 바텀시트 노출하기

화면의 메인 UI 영역과 공존하며 두 영역을 동시에 보고 상호작용 할 수 있다. [링크]

 

모달이 아닌 바텀시트를 제공하는 컨테이너 형태의 Composable 입니다. 네이버 지도와 같은 서비스에서 마커 클릭 시 올라오는 형태의 바텀시트를 생각하시면 좋습니다.

 

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomSheetScaffoldScreen() {
    val state = rememberBottomSheetScaffoldState(
        bottomSheetState = rememberStandardBottomSheetState(
            initialValue = SheetValue.Hidden,
            skipHiddenState = false
        )
    )
    val scope = rememberCoroutineScope()

    BottomSheetScaffold(
        scaffoldState = state,
        sheetContent = { BottomSheetContent() },
        // if set below setting, skip PartialExpand State.
        //sheetPeekHeight = 0.dp
    ) {
        Box {
            Column(
                modifier = Modifier.align(Alignment.Center),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Text(text = "Current State is ${state.bottomSheetState.currentValue}")
                Button(
                    onClick = {
                        scope.launch {
                            state.bottomSheetState.show()
                        }
                    }
                ) {
                    Text(text = "Show Bottom Sheet")
                }

                Button(
                    onClick = {
                        scope.launch {
                            state.bottomSheetState.hide()
                        }
                    }
                ) {
                    Text(text = "Hide Bottom Sheet")
                }

                Button(
                    onClick = {
                        scope.launch {
                            state.bottomSheetState.expand()
                        }
                    }
                ) {
                    Text(text = "Expand Bottom Sheet")
                }

                Button(
                    onClick = {
                        scope.launch {
                            state.bottomSheetState.partialExpand()
                        }
                    }
                ) {
                    Text(text = "PartialExpand Bottom Sheet")
                }
            }
        }
    }
}

 

바텀시트는 아래 3가지 상태를 가집니다.

  • Hidden
  • Expanded
  • PariallyExpanded

기본적으로 Hidden 상태는 제공되지 않으며, state.hide() 호출시 Exception이 발생합니다. Hidden 상태를 쓰고자 하는 경우, 시트 상태 초기화 시 skipHiddenState를 명시적으로 false 로 초기화 해줘야 합니다.

 

또한, 반만 열림상태를 건너뛰고 싶은 경우 sheetPeekHeight 값을 0.dp 로 설정해주시면 됩니다.

 

이녀석을 사용하면, 물론 몇가지 트릭이 필요하지만, View 기반의 바텀시트와 동일한 기능을 제공할 수 있습니다.

 

[샘플코드 Github]

 


 

# 마치며

 

Compose 를 사용하면서 자주 하게되는 말이 있습니다.

 

" 어? 이게 안된다고? " 혹은 " 어? 이게 된다고? "

 

이번 BottomSheet 도입 작업은 위 두 감탄의 연속이었습니다. 하지만 이겨 내야합니다. Compose가 주는 생산성이 너무 강력해서 다시 View 기반으로 돌아갈 수 없거든요.

 

이 포스팅이 Compose로 바텀시트를 구현하겠다는 생각을 가진 분들에게 조금이나마 도움이 되길 바랍니다.

 

내용에 오류가 있거나 궁금한 점은 댓글로 남겨주세요. 감사합니다

댓글