Jetpack Compose로 구글 맵스 스타일 바텀 시트 만들기
Jetpack Compose로 구글 맵스 스타일 바텀 시트 만들기
구글 맵스(Google Maps)는 대부분의 안드로이드 개발자가 즉시 알아볼 수 있는 바텀 시트(bottom sheet) 패턴을 대중화했습니다. 화면 하단에서 살짝 올라온 작은 패널이 중간 높이까지 확장되어 간략한 정보를 보여주고, 전체 화면으로 드래그하면 상세 정보를 확인할 수 있는 형태입니다. 이 과정에서 시트 뒤의 지도는 항상 터치 가능한 상태를 유지합니다. 겉으로 보기에는 단순해 보이지만, 올바르게 구현하려면 다단계 앵커링(multi-state anchoring), 비모달(non-modal) 상호작용, 동적 콘텐츠 적응, 중첩 스크롤 조율 등 여러 문제를 해결해야 합니다. Jetpack Compose의 표준 ModalBottomSheet는 확장(expanded)과 숨김(hidden) 두 가지 상태만 지원하고, 스크림(scrim)으로 배경 상호작용을 차단하기 때문에 이런 사용 사례에는 적합하지 않습니다.
이 아티클에서는 FlexibleBottomSheet를 활용하여 구글 맵스 스타일 바텀 시트를 구현하는 방법을 살펴봅니다. 커스텀 높이 비율로 3단계 확장 상태를 설정하는 방법, 비모달 모드를 활성화하여 시트 뒤의 콘텐츠와 자유롭게 상호작용하는 방법, 시트의 현재 상태에 따라 UI를 동적으로 적응시키는 방법, 프로그래밍 방식으로 상태 전환을 제어하는 방법, 시트 내부의 중첩 스크롤을 처리하는 방법, 그리고 가변 높이 시트를 위해 콘텐츠 크기에 맞게 동적으로 래핑하는 방법까지 폭넓게 다룹니다.
ModalBottomSheet의 한계
먼저 표준 Material 3 바텀 시트를 살펴보겠습니다.
@Composable
fun StandardBottomSheet() {
ModalBottomSheet(
onDismissRequest = { /* dismiss */ },
sheetState = rememberModalBottomSheetState(),
) {
Text("Content here")
}
}
위의 코드는 확장(expanded)과 숨김(hidden) 두 가지 상태만 제공합니다. 시트가 배경을 스크림으로 덮어 뒤쪽의 모든 상호작용을 차단하므로, 확인 다이얼로그나 액션 메뉴에는 적합합니다. 하지만 구글 맵스 스타일의 경험을 구현하려면 다음 요소가 필요합니다.
- 3단계 표시 상태: 요약 정보를 보여주는 peek 높이, 상세 정보를 위한 중간 높이, 전체 콘텐츠를 위한 최대 높이가 각각 필요합니다.
- 스크림 없음: 시트 뒤의 지도가 완전히 상호작용 가능한 상태를 유지해야 합니다.
- 동적 콘텐츠: 현재 확장 상태에 따라 콘텐츠가 적응적으로 변해야 합니다.
- 중첩 스크롤: 완전히 확장된 시트 내부의 스크롤 가능한 콘텐츠가 자연스럽게 스크롤되어야 하며, 스크롤 최상단에서 아래로 드래그하면 시트가 접혀야 합니다.
FlexibleBottomSheet는 이러한 모든 요구 사항을 충족합니다.
3단계 바텀 시트 설정하기
구글 맵스 패턴의 핵심은 3가지 확장 단계를 갖는 것입니다. FlexibleBottomSheet는 이를 FlexibleSheetValue 열거형(enum)으로 모델링합니다.
SlightlyExpanded: peek 상태로, 화면 하단에 요약 정보를 보여주는 작은 패널입니다.IntermediatelyExpanded: 간략한 상세 정보를 위한 중간 높이 상태입니다.FullyExpanded: 전체 콘텐츠를 표시하는 최대 높이 상태입니다.Hidden: 시트가 보이지 않는 상태입니다.
3가지 표시 상태를 모두 활성화하려면, rememberFlexibleBottomSheetState에서 skipSlightlyExpanded = false로 설정하고(기본값은 true), FlexibleSheetSize를 통해 크기 비율을 정의합니다.
@Composable
fun GoogleMapsSheet(onDismissRequest: () -> Unit) {
FlexibleBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = rememberFlexibleBottomSheetState(
flexibleSheetSize = FlexibleSheetSize(
fullyExpanded = 0.9f,
intermediatelyExpanded = 0.5f,
slightlyExpanded = 0.18f,
),
isModal = false,
skipSlightlyExpanded = false,
),
containerColor = Color.Black,
) {
Text("Sheet content", color = Color.White)
}
}
각 FlexibleSheetSize 값은 화면 높이에 대한 비율을 나타냅니다. fullyExpanded = 0.9f로 설정하면 최대 확장 시 시트가 화면의 90%를 차지하여, 상단에 지도가 살짝 보이는 형태가 됩니다. slightlyExpanded = 0.18f는 화면의 18%를 차지하는 작은 peek 패널을 만들어 줍니다.
isModal = false 플래그가 바로 구글 맵스처럼 동작하게 만드는 핵심 설정입니다. 이 설정이 없으면 시트가 배경 위에 스크림을 렌더링하고 터치 이벤트를 차단합니다.
확장 높이 커스터마이징
기본 FlexibleSheetSize는 1.0, 0.5, 0.25의 비율을 사용합니다. 구글 맵스 스타일의 경험을 위해서는 콘텐츠에 맞게 이 값을 조정하는 것이 좋습니다.
자주 사용되는 몇 가지 설정 예시를 살펴보겠습니다.
// 구글 맵스 스타일: peek, 절반, 거의 전체
FlexibleSheetSize(
fullyExpanded = 0.9f,
intermediatelyExpanded = 0.5f,
slightlyExpanded = 0.18f,
)
// 음악 플레이어 스타일: 작은 바, 절반, 전체
FlexibleSheetSize(
fullyExpanded = 1.0f,
intermediatelyExpanded = 0.5f,
slightlyExpanded = 0.1f,
)
// 2단계만 사용 (중간 상태 건너뛰기)
FlexibleSheetSize(
fullyExpanded = 0.85f,
slightlyExpanded = 0.15f,
)
2단계 구성의 경우, skipIntermediatelyExpanded = true를 설정하면 시트가 peek 상태와 전체 확장 상태 사이에서 직접 전환됩니다.
rememberFlexibleBottomSheetState(
flexibleSheetSize = FlexibleSheetSize(
fullyExpanded = 0.85f,
slightlyExpanded = 0.15f,
),
isModal = false,
skipSlightlyExpanded = false,
skipIntermediatelyExpanded = true,
)
skipHiddenState = true를 설정하면 숨김 상태를 완전히 건너뛰어 시트가 항상 화면에 표시됩니다. 이 옵션은 시트가 UI의 영구적인 구성 요소인 경우, 가령 절대로 완전히 닫히지 않아야 하는 지도 상세 패널 같은 상황에서 유용합니다.
시트 상태에 따른 콘텐츠 적응
구글 맵스는 각 확장 단계마다 서로 다른 콘텐츠를 표시합니다. peek 상태에서는 장소 이름, 중간 확장 시에는 사진과 평점, 완전 확장 시에는 리뷰와 상세 정보를 보여주는 방식입니다. FlexibleBottomSheet는 onTargetChanges 콜백을 통해 이러한 동적 콘텐츠 적응을 지원합니다.
@Composable
fun GoogleMapsSheet(onDismissRequest: () -> Unit) {
var targetValue by remember {
mutableStateOf(FlexibleSheetValue.IntermediatelyExpanded)
}
FlexibleBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = rememberFlexibleBottomSheetState(
flexibleSheetSize = FlexibleSheetSize(
fullyExpanded = 0.9f,
intermediatelyExpanded = 0.5f,
slightlyExpanded = 0.18f,
),
isModal = false,
skipSlightlyExpanded = false,
),
onTargetChanges = { targetValue = it },
containerColor = Color.Black,
) {
when (targetValue) {
FlexibleSheetValue.SlightlyExpanded -> CompactSummary()
FlexibleSheetValue.IntermediatelyExpanded -> DetailedInfo()
FlexibleSheetValue.FullyExpanded -> FullContent()
FlexibleSheetValue.Hidden -> {}
}
}
}
이 콜백은 currentValue가 아닌 targetValue가 변경될 때 호출됩니다. 이 차이는 중요합니다. targetValue는 드래그 제스처나 애니메이션 도중 시트가 향하고 있는 목표 상태를 나타내며, currentValue는 제스처가 시작되기 전의 상태를 나타냅니다. targetValue를 사용하면 애니메이션이 완료될 때까지 기다리지 않고, 새로운 상태를 향해 드래그하기 시작하는 순간 콘텐츠가 즉시 업데이트되어 반응성 높은 사용 경험을 제공합니다.
또한, 전체 컴포저블을 교체하지 않고도 targetValue를 활용하여 더 세밀한 적응을 구현할 수 있습니다.
Text(
text = "Central Park, New York",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = if (targetValue == FlexibleSheetValue.SlightlyExpanded) 1 else 3,
overflow = TextOverflow.Ellipsis,
color = Color.White,
)
peek 상태에서는 제목이 한 줄로 잘리고, 확장 상태에서는 전체 텍스트가 표시됩니다. 이 패턴은 가볍고 레이아웃 변동을 최소화할 수 있어 실무에서 자주 활용되는 기법입니다.
프로그래밍 방식으로 상태 제어하기
드래그 제스처 외에도, 프로그래밍 방식으로 시트 상태를 제어할 수 있습니다. FlexibleSheetState는 각 전환을 위한 suspend 함수를 제공합니다.
val sheetState = rememberFlexibleBottomSheetState(
isModal = false,
skipSlightlyExpanded = false,
)
val scope = rememberCoroutineScope()
// 전체 높이로 확장
Button(onClick = { scope.launch { sheetState.fullyExpand() } }) {
Text("Expand")
}
// peek 상태로 축소
Button(onClick = { scope.launch { sheetState.slightlyExpand() } }) {
Text("Collapse")
}
// 완전히 숨기기
Button(onClick = { scope.launch { sheetState.hide() } }) {
Text("Hide")
}
// 가장 적절한 상태로 표시
Button(onClick = { scope.launch { sheetState.show() } }) {
Text("Show")
}
show() 함수는 특히 유용합니다. 대상 매개변수 없이 호출하면 가장 적절한 상태를 자동으로 선택합니다. IntermediatelyExpanded가 사용 가능하면 이를 선택하고, 그다음으로 SlightlyExpanded, 마지막으로 FullyExpanded 순서로 선택합니다. 특정 상태를 직접 지정할 수도 있습니다.
scope.launch { sheetState.show(FlexibleSheetValue.SlightlyExpanded) }
현재 상태를 읽어 UI의 다른 부분을 업데이트하는 것도 가능합니다.
val isSheetVisible = sheetState.isVisible
val currentState = sheetState.currentValue
val targetState = sheetState.targetValue
상태 변경 거부(Vetoing)
confirmValueChange 콜백을 사용하면 특정 상태 전환을 방지할 수 있습니다. 가령, 시트가 숨겨지는 것을 막으려면 다음과 같이 설정합니다.
rememberFlexibleBottomSheetState(
confirmValueChange = { newValue ->
newValue != FlexibleSheetValue.Hidden
},
)
사용자가 시트를 가장 낮은 앵커 아래로 드래그하려고 하면, 제스처가 거부되고 시트가 원래 위치로 스냅됩니다. 이 기능은 시트가 항상 화면에 표시되어야 하는 경우에 유용합니다. skipHiddenState = true와 비슷하지만, 제스처 기반 숨김은 차단하면서 프로그래밍 방식 숨김은 허용할 수 있다는 점에서 더 유연한 제어가 가능합니다.
중첩 스크롤 처리
시트 내부에 LazyColumn이나 다른 스크롤 가능한 콘텐츠를 배치하면, FlexibleBottomSheet가 스크롤 조율을 자동으로 처리합니다. 리스트를 위로 드래그하면 먼저 시트가 확장되고, 시트가 완전히 확장된 이후에야 리스트 스크롤이 시작됩니다.
FlexibleBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = rememberFlexibleBottomSheetState(
flexibleSheetSize = FlexibleSheetSize(
fullyExpanded = 0.9f,
intermediatelyExpanded = 0.5f,
slightlyExpanded = 0.18f,
),
isModal = false,
skipSlightlyExpanded = false,
allowNestedScroll = true, // 기본적으로 활성화됨
),
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Fixed(2),
) {
items(items = posters, key = { it.name }) { poster ->
PosterImage(poster = poster)
}
}
}
allowNestedScroll 매개변수(기본값 true)가 이 조율 동작을 활성화합니다. false로 설정하면 내부 콘텐츠가 독립적으로 스크롤되며, 시트는 드래그 핸들이나 스크롤 가능 영역 바깥에서만 드래그할 수 있습니다.
스크롤 우선순위는 다음과 같이 동작합니다. 위로 드래그하면 항상 시트가 먼저 확장됩니다. 완전히 확장된 후에는 위로 드래그할 때 리스트가 스크롤됩니다. 완전히 확장되지 않은 시트에서 아래로 드래그하면 시트가 접힙니다. 완전히 확장된 시트에서 아래로 드래그하면 먼저 리스트가 최상단까지 스크롤된 후 시트가 접히기 시작합니다. 이러한 동작 방식은 사용자에게 직관적이고 자연스러운 경험을 제공합니다.
콘텐츠 래핑 모드 사용하기
고정된 높이 비율이 항상 적합한 것은 아닙니다. 가변적인 콘텐츠를 가진 시트의 경우, FlexibleSheetSize.WrapContent를 사용하면 콘텐츠에 맞게 시트 크기가 자동으로 조절됩니다.
rememberFlexibleBottomSheetState(
flexibleSheetSize = FlexibleSheetSize(
fullyExpanded = FlexibleSheetSize.WrapContent,
intermediatelyExpanded = 0.5f,
slightlyExpanded = FlexibleSheetSize.WrapContent,
),
isModal = false,
skipSlightlyExpanded = false,
)
콘텐츠 래핑과 고정 비율을 혼합하여 사용하는 것도 가능합니다. 위의 예제에서는 peek 상태와 전체 확장 상태가 콘텐츠에 맞게 크기가 조절되고, 중간 상태는 화면의 50%로 유지됩니다. peek 상태에서 길이가 다양한 짧은 요약을 표시하고, 전체 확장 상태에서 항목에 따라 다른 상세 정보를 보여주는 경우에 유용합니다.
콘텐츠 래핑 모드에서도 시트는 화면 높이를 초과하지 않습니다. 콘텐츠가 화면보다 큰 경우, 시트는 전체 화면 높이로 제한되고 내부 콘텐츠가 스크롤됩니다.
초기 상태 설정하기
기본적으로 시트는 FlexibleSheetValue.Hidden 상태에서 시작하여 첫 번째 사용 가능한 상태로 애니메이션됩니다. initialValue를 설정하면 이 동작을 변경할 수 있습니다.
rememberFlexibleBottomSheetState(
initialValue = FlexibleSheetValue.SlightlyExpanded,
isModal = false,
skipSlightlyExpanded = false,
)
시트가 진입 애니메이션 없이 peek 높이에서 즉시 나타납니다. 시트가 사용자 액션에 대한 응답이 아니라 화면의 핵심 구성 요소인 경우에 유용합니다.
초기값은 skip 플래그와 일관성이 있어야 합니다. skipSlightlyExpanded = true로 설정한 상태에서 initialValue = FlexibleSheetValue.SlightlyExpanded를 설정하면, 컴포지션 시점에 IllegalArgumentException이 발생하여 잘못된 설정을 조기에 발견할 수 있습니다. 이러한 설계 덕분에 런타임 오류를 방지하고 안정적인 코드를 작성할 수 있습니다.
종합 예제
지금까지 살펴본 모든 개념을 결합한 구글 맵스 스타일 구현 예제를 살펴보겠습니다.
@Composable
fun PlaceDetailScreen() {
var targetValue by remember {
mutableStateOf(FlexibleSheetValue.SlightlyExpanded)
}
Box(modifier = Modifier.fillMaxSize()) {
// 시트 뒤의 지도 콘텐츠
MapView(modifier = Modifier.fillMaxSize())
// 3단계 비모달 바텀 시트
FlexibleBottomSheet(
onDismissRequest = { /* dismiss 처리 */ },
sheetState = rememberFlexibleBottomSheetState(
flexibleSheetSize = FlexibleSheetSize(
fullyExpanded = 0.9f,
intermediatelyExpanded = 0.5f,
slightlyExpanded = 0.15f,
),
isModal = false,
skipSlightlyExpanded = false,
skipHiddenState = true,
),
onTargetChanges = { targetValue = it },
containerColor = Color.White,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
PlaceContent(targetValue = targetValue)
}
}
}
@Composable
private fun ColumnScope.PlaceContent(targetValue: FlexibleSheetValue) {
// 헤더: 항상 표시
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Central Park",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Text(
text = "4.8 stars - Park",
fontSize = 14.sp,
color = Color.Gray,
)
}
}
// 상세 정보: 중간 확장 또는 전체 확장 시 표시
if (targetValue != FlexibleSheetValue.SlightlyExpanded) {
ActionButtons()
Spacer(modifier = Modifier.height(8.dp))
}
// 전체 콘텐츠: 전체 확장 시 스크롤 가능한 리스트
if (targetValue == FlexibleSheetValue.FullyExpanded) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { PlacePhotos() }
item { PlaceReviews() }
item { PlaceInfo() }
}
}
}
skipHiddenState = true로 설정하면 시트가 항상 표시되며, 접을 수는 있지만 완전히 닫을 수 없는 구글 맵스의 장소 상세 패널과 동일한 동작을 구현합니다. 콘텐츠는 각 단계에서 적응적으로 변합니다. peek 상태에서는 간결한 헤더, 중간 확장 시에는 액션 버튼, 완전 확장 시에는 사진, 리뷰, 정보가 포함된 스크롤 가능한 전체 리스트가 표시됩니다.
결론
이 아티클에서는 FlexibleBottomSheet를 활용하여 구글 맵스 스타일의 다단계 바텀 시트를 구현하는 방법을 살펴보았습니다. 높이 비율을 지정하는 FlexibleSheetSize, 배경과의 상호작용을 가능하게 하는 isModal = false, peek 상태를 활성화하는 skipSlightlyExpanded = false, 이 세 가지 핵심 설정 매개변수를 조합하면 표준 바텀 시트를 유연한 다단계 패널로 변환할 수 있습니다. onTargetChanges 콜백은 동적 콘텐츠 적응을 지원하여, 사용자가 상태 간에 드래그할 때 시트가 반응성 높게 동작하도록 만들어 줍니다. 또한 프로그래밍 방식 상태 제어, 중첩 스크롤 조율, 콘텐츠 래핑 모드 등의 기능은 실제 프로덕션 환경에서 마주할 수 있는 다양한 엣지 케이스를 처리하는 데 도움이 됩니다.
지도 오버레이, 영구적인 미니 플레이어가 있는 미디어 플레이어, 접을 수 있는 트립 패널이 있는 라이드 셰어링 앱, 또는 인터랙티브 콘텐츠 위에 영구적이고 다단계로 동작하는 패널이 필요한 어떤 인터페이스든, FlexibleBottomSheet는 최소한의 설정과 깔끔한 Compose API로 이러한 패턴을 구현할 수 있는 기반을 제공합니다.

