Compose 환경에서 개발을 진행하다 보니, BottomSheet Component를 사용하는 경우가 상당히 많다.
특정 버튼 및 아이템을 눌렀을 때, 페이지보다 가볍게 해당 아이템에 대한 디테일한 정보 혹은 다음 Depth의 정보를 보여주기 위해 많이 사용한다.
UX 관점에서 생각했을 때 BottomSheet를 사용하는 다양한 이유가 존재하겠지만, 정말 단순하고 직관적으로 위와 같은 이유로 모바일 환경에서 많이 사용한다고 느낀다.
그리고 그 자주 사용하는 BottomSheet를 구현하는 것에 있어서 다양한 방법이 존재했고,
필요에 따라서 선택해서 구현하기 위해 조금 더 디테일하게 공부하고 정리하기 위해 이 글을 작성하게 되었다.
글의 제목으로 작성했듯이 필자는 이 3가지 방법으로 BottomSheet를 구현해 보았기 때문에 이에 대해 정리하고 비교해보고자 한다.
더 많은 방법이 존재하겠지만, 쉽게 찾고 사용할 수 있는 방법이 이 3가지였기 때문에 실무를 진행하면서 쉽게 가져와서 사용했던 것 같다.
BottomSheetScaffold, ModalBottomSheet는 Compose가 제공해주는 Component를 사용한 것이고,
CustomBottomSheet는 BottomSheet와 같은 UI와 애니메이션을 직접 만들어 해당 Component를 사용한 것과 같은 UI를 보여주는 방법이다.
혹여 BottomSheet가 어떻게 생긴 Component인지 확인하고자 하면 Material Design3 페이지에서 확인하면 된다.
그럼, 여기서 가장 기본적인 BottomSheetScaffold 부터 설명하도록 하겠다.
BottomSheetScaffold는
Jetpack Compose Material Design 라이브러리에서 제공하는 Component 중 하나이다.
이름에서 알 수 있듯이 BottomSheet Componenet를 쉽게 구현할 수 있는 Scaffold이다.
그럼 여기서, Scaffold란 무엇인가?
Material Design layout. Scaffold implements the basic material design visual layout structure.
This component provides API to put together several material components to construct your screen, by ensuring proper layout strategy for them and collecting necessary data so these components will work together correctly.
라고 한다.
간단하게 생각해서, 앱의 기본적인 레이아웃을 정의하고 구조화하는데 사용하는 API로,
Scaffold를 사용하기 위해 데이터를 넣으면, 앱의 기본적인 레이아웃 구조로 다양한 기능들을 쉽게 구현할 수 있다는 것이다.
더욱 간단하게 생각하면 템플릿 같은 개념이라고 보면 된다.
코드를 보면 더 쉽게 이해가 가능할 것이다.
@Composable
fun Scaffold(
modifier: Modifier = Modifier,
scaffoldState: ScaffoldState = rememberScaffoldState(),
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
isFloatingActionButtonDocked: Boolean = false,
drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
drawerGesturesEnabled: Boolean = true,
drawerShape: Shape = MaterialTheme.shapes.large,
drawerElevation: Dp = DrawerDefaults.Elevation,
drawerBackgroundColor: Color = MaterialTheme.colors.surface,
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
drawerScrimColor: Color = DrawerDefaults.scrimColor,
backgroundColor: Color = MaterialTheme.colors.background,
contentColor: Color = contentColorFor(backgroundColor),
content: @Composable (PaddingValues) -> Unit
)
해당 API를 들어가보면 다음과 같은 코드를 볼 수 있는데, 각각의 파라미터에 필요한 데이터를 넣어주면 하나의 화면이 완성 되게 된다.
필자가 맨 처음 Compose를 공부할 때 Scaffold를 사용하여 아주 간단한 화면을 만든 적이 있는데,
이런 식으로 구현하였다.
자세한 코드는 이곳에서 확인하면 된다.
다시 본론으로 돌아와서,
BottomSheetScaffold는 Scaffold 형태로 템플릿을 제공해 BottomSheet를 쉽게 구현할 수 있도록 도와주는 API라고 생각하면 된다.
BottomSheetScaffold의 코드를 확인해 보자.
@Composable
@ExperimentalMaterialApi
fun BottomSheetScaffold(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
topBar: (@Composable () -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
floatingActionButton: (@Composable () -> Unit)? = null,
floatingActionButtonPosition: FabPosition = FabPosition.End,
sheetGesturesEnabled: Boolean = true,
sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = BottomSheetScaffoldDefaults.SheetElevation,
sheetBackgroundColor: Color = MaterialTheme.colors.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
sheetPeekHeight: Dp = BottomSheetScaffoldDefaults.SheetPeekHeight,
backgroundColor: Color = MaterialTheme.colors.background,
contentColor: Color = contentColorFor(backgroundColor),
content: @Composable (PaddingValues) -> Unit
)
위에서 확인했던 Scaffold와 상당히 비슷한 것을 확인할 수 있다.
눈에 띄는 차이점은 BottomBar가 없고, SheetContent가 존재한다는 것이다.
즉, 이런 식으로 간단하게 원하는 컨텐츠와 데이터만 넣어주면 BottomSheet가 존재하는 한 화면을 쉽게 만들 수 있다.
이렇게 구현하는 방식을 사용했을 때 취할 수 있는 장점은,
- Scaffold를 기반으로 그 안에 BottomSheet를 포함시키는 것이기 때문에 Component의 교환이 간단하다
- 간단하게 TopBar, FloatingActionButton 등 다른 Material Design Component와 함께 사용할 수 있다.
- Scaffold 안에 들어가 있는 Component이기 때문에, 다른 Scaffold에 들어있는 Component와 이벤트가 충돌되지 않고 쉽게 컨트롤이 가능하다.
이라고 생각하며,
단점은
- Scaffold 내부에 들어가 있기 때문에, 단순히 BottomSheet만 구현하려고 하는 경우 불필요하게 많은 코드를 추가 및 수정해야 한다.
- Scaffold 내부에 들어가있기 때문에, 전체 앱의 레이아웃에 종속되어 있다. 따라서 별도의 Component로 사용하기에 어려움이 있어 Custom에 제한적인 요소가 많다.
이라고 생각한다.
간단하게,
템플릿을 제공하기 때문에 쉽게 사용할 수 있으나,
템플릿 안에서만 사용하기 때문에 자유로운 커스텀에 제한이 있으며, 템플릿을 반드시 구현해야 사용이 가능하다.
라는 게 장, 단점이라고 생각한다.
장, 단점에 대해서도 알아보았으니, 실제 구현 코드를 살펴보자.
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(BottomSheetValue.Collapsed)
)
BottomSheetScaffold(
modifier = Modifier.fillMaxSize(),
scaffoldState = scaffoldState,
topBar = {
// Header Component
},
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
sheetContent = {
Box {
Column {
AnimatedVisibility(
visible = true,
enter = slideInVertically(
initialOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
),
exit = slideOutVertically(
targetOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
),
) {
// 열려있을 때 UI
ExpandedBottomSheet()
// 닫혀있을 때 UI
CollapsedBottomSheet()
}
}
}
},
sheetPeekHeight = heightSample.value,
) {
// Main Contents
BottomSheetDataCheckUI(
scaffoldState = scaffoldState,
onToggle = {
// Button Event
},
)
}
설명에 불필요한 Click Event 등을 제거한 코드이다.
위에서 보여줬던 BottomSheetScaffold의 전체 코드라고 생각하면 된다.
여기서 중요한 것은,
- scaffoldState
- sheetContent
- sheetPeekHeight
- MainContent
이 4가지만 생각하면 된다.
scaffoldState는 remember를 사용하여 데이터를 관리하는 BottomSheetScaffold의 상태 값을 의미한다.
BottomSheet에 대한 다양한 값을 가지고 있으며, Default로 설정된 값을 Expand로 선언하면 초기 상태에 BottomSheet를 열어둘 수 있다.
sheetContent는 Scaffold에서 지정한 BottomSheet의 위치에 그려줄 BottomSheet UI를 넣어주면 된다.
필자는 Expanded 상태일 때와 Collapsed 상태일 때의 UI를 다르게 그려주기 위해서 UI를 두 개 넣어주었다.
물론, 그냥 넣으면 두 개가 나뉘는 게 아닌 내부 코드를 보면 State에 따라서 다르게 보여주도록 구현해 두었다.
sheetPeekHeight는 BottomSheet의 최소 Height를 지정해 주는 것이다.
이 값에 대해서는 화면을 보면 한 번에 이해될 것이다.
하단의 보라색 영역이 보이는가?
현재 BottomSheet의 상태는 Collapsed 상태로 BottomSheet는 안 보여야 한다.
하지만, 그 상태에서도 일정 영역이 보이게 하고 싶은 경우 sheetPeekHeight를 지정해 주면 된다.
이때, 저 보라색 영역을 드래그해서 위로 올리게 되면 BottomSheet는 Exapanded 상태로 변한다.
즉, BottomSheet를 사용하면서 기본적으로 데이터를 보여주고 사용자가 해당 BottomSheet를 위로 드래그하길 원하는 경우 이와 같이 구현하면 된다.
물론, 이와 같은 경우 드래그해서 위로 올릴 수 있음을 알려주는 Grabber UI가 필요하겠지만 말이다.
Grabber UI란, BottomSheet의 최상단에 보이는 - 표시가 Grabber이다.
마지막으로 MainContent는 위의 사진에서 볼 수 있는 Button과 Text가 보이는 영역으로, TopBar, BottomSheet를 제외한 메인 아이템들을 보여줄 영역이다.
해당 영역은 뭐 없어도 되지만, 실제로 사용할 때는 비어있지는 않을 테니 넘어가도록 하겠다.
다음으로 넘어가기 전에,
MainContent 영역에 넣어둔 scaffoldState의 값들에 대해서 설명하고자 한다.
해당 값들은 필자가 실무에서 사용할 때 데이터를 확인하고 커스텀하기 위해 작성해 둔 부분인데, 해당 데이터를 보면서 공부하는 게 Component를 이해하는데 도움이 많이 될 것 같아서 놔두었다.
scaffoldState에서 확인할 수 있는 다양한 값들이 있는데, BottomSheet의 상태 값과 해당 View가 보이는 크기 등을 알 수 있다. 이 값을 토대로 커스텀하여 실무에서 다양한 이벤트를 컨트롤할 수 있을 것이다.
다음으로는 ModalBottomSheetLayout이다.
ModalBottomSheetLayout도 BottomSheetScaffold와 마찬가지로 Jetpack Compose Material Design 라이브러리에서 제공하는 Component 중 하나이다.
이름 그대로, BottomSheetLayout을 제공하는데 이를 Modal 형태로 제공해 주는 Component이다.
BottomSheetScaffold를 적용하려고 찾아보고 문서를 읽어본 사람이라면 ModalBottomSheetLayout에 대해서도 많이 확인해 보았을 것이다.
해당 BottomSheetScaffold Compoenent의 가장 큰 단점인 Scaffold 기반이기 때문에 커스텀이 제한적인 부분에 대한 보완 설명으로 확인할 수 있는 Component이다.
라이브러리에 있는 ModalBottomSheetLayout 코드를 확인해 보자.
@Composable
@ExperimentalMaterialApi
fun ModalBottomSheetLayout(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
sheetState: ModalBottomSheetState =
rememberModalBottomSheetState(Hidden),
sheetGesturesEnabled: Boolean = true,
sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
sheetBackgroundColor: Color = MaterialTheme.colors.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
content: @Composable () -> Unit
)
해당 Component를 사용하는데 필요한 값들은 content를 제외하고 모두 BottomSheet에 대한 것 밖에 존재하지 않는다.
파라미터를 BottomSheetScaffold와 비교해 보면,
BottomSheetScffold는 앱의 전반적인 레이아웃을 모두 작성해줘야 하고 덤으로 BottomSheet Component를 쉽게 추가할 수 있게 되어있는 반면,
ModalBottomSheetLayout은 앱의 전반적인 레이아웃은 Content 영역에 알아서 작성하고, BottomSheet Component를 더 자유롭게 커스텀하여 사용할 수 있도록 되어있다.
실 사용 코드를 보기 앞서, ModalBottomSheetLayout의 장단점에 대해서 생각해 보자.
장점으로는
- 다른 디자인 요소와 독립적으로 사용되기 때문에 커스텀하기 편하다.
- 독립적으로 사용이 가능하기 때문에 간단하게 구성하기 쉽다.
이라고 생각하며,
단점으로는
- 상호 작용과 이벤트에 대한 제어를 직업 해줘야 하기 때문에 코드의 복잡성이 높아지고 유지보수가 힘들어진다.
- 앱의 전반적인 레이아웃에 종속되지 않기 때문에, 앱 전반적으로 동일한 Component를 사용하기 위해선 여러 번 구현해주어야 한다.
이것만 보고 생각하면
BottomSheetScaffold는 제한적으로 사용하는 대신, 구현이 쉽고
ModalBottomSheetLayout은 자유도가 높은 대신, 구현에 어려움이 생긴다.
라는 차이점이 있는 것 같다.
이렇기 때문에 BottomSheetScaffold를 찾는 사람들은 ModalBottomSheetLayout에 대한 정보를 같이 얻게 되고,
반대로 ModalBottomSheetLayout을 찾는 사람들은 BottomSheetScaffold에 대한 정보를 같이 얻게 되어 이 사이에서 필요에 따라 선택하여 사용하게 된다.
다음으로 실 구현 코드를 확인해 보자.
여기서 중요한 부분은
- modalBottomSheetState (sheetState)
- SheetContent
- MainContent
이 3가지 밖에 없다.
물론 여기서도 2,3번의 경우 UI 부분이기 때문에 설명할 것도 없지만 말이다.
처음으로 modalBottomSheetState (sheetState) 부분이다.
scaffoldState와 마찬가지로 bottomSheet에 대한 State 값과 애니메이션 처리 등을 저장하고 있다.
하지만 여기서 직관적으로 볼 수 있는 차이점은,
@ExperimentalMaterialApi
enum class BottomSheetValue {
Collapsed,
Expanded
}
@ExperimentalMaterialApi
enum class ModalBottomSheetValue {
Hidden,
Expanded,
HalfExpanded
}
이렇게 State의 차이가 존재한다는 점이다.
기본적으로 BottomSheet을 열리고, 닫힌 상태만 컨트롤하는 것이 아니라 halfExpanded라는 값을 통해 절반만 열려있는 상태가 추가된다.
이 차이는 다음 영상을 보면 확실히 이해할 수 있을 것이다.
BottomSheetScffold는 ModalBottomSheetLayout의 halfExpanded의 느낌을 줄 수 없을까 싶어서 비슷하게 구현해두긴 했는데, 사용자 액션을 보면 다른 점을 알 수 있을 것이다.
halfExpanded의 경우 드래그 정도에 따라 절반의 위치에 멈추지만, BottomSheetScffold의 경우 해당 state가 없기 때문에 전체가 보이거나(expanded), 안 보이거나(collapsed)로 나뉜다.
이것들 외에도 차이점이 있지만, 지금은 BottomSheet에 대해서만 말하고 있으므로 넘어가도록 한다.
2번과 3번은 위에서 설명한 대로, 2번은 BottomSheet에 대한 UI를 넣어주고, 3번은 그 외 앱의 전반적인 UI를 넣어주면 된다.
이렇게 작성을 해두고 비교하자니, 정말로 이 두 개의 Component는 엄청난 차이가 존재하는 것이 아니라, 서로의 단점을 보완하는 형태로 구현이 되어있는 것으로 보인다.
필자는 개인적으로 실무에 사용할 때, ModalBottomSheetLayout과 다음에 설명할 CustomBottomSheet를 많이 사용한다.
마지막으로 CustomBottomSheet에 대해서 설명하도록 하겠다.
말이야 CustomBottomSheet라고 하긴 했지만,
그냥 단순하게 생각해서 BottomSheet처럼 UI를 구성하고, 애니메이션을 추가하여 BottomSheet처럼 보이게 만들어 둔 UI이다.
바로 구현 코드를 보고 생각하자.
val customBottomSheetVisible = remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.zIndex(1f),
contentAlignment = Alignment.Center
) {
// Main Contents
BackgroundScreen(
onButtonClick = {
customBottomSheetVisible.value = true
},
onBackButtonClick = onBackButtonClick
)
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) {
// BottomSheet
CustomBottomSheetComponent(
visible = customBottomSheetVisible
)
}
}
Main Contents 부분은 말 그대로 다른 쪽에서 구현한 사용자가 볼 화면이고,
하단의 Box영역에 Alignment.BottomCenter로 되어있는 부분이 CustomBottomSheet이다.
CustomBottomSheet UI를 보면 더 정확히 알겠지만,
단순히 UI를 다 그려두고, 보이지 않게 해 둔 다음에 flag 값 (customBottomSheetVisible)에 따라서 이를 보여주고 감춘다.
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun BoxScope.CustomBottomSheetComponent(
visible: MutableState<Boolean>,
) {
AnimatedVisibility(
visible = visible.value,
modifier = Modifier
.background(color = Color(0x33000000))
.fillMaxSize()
.noRippleSingleClickable {
visible.value = false
},
enter = fadeIn(),
exit = fadeOut(animationSpec = tween(durationMillis = AnimationConstants.DefaultDurationMillis / 2)),
) {
// BottomSheet Contents
Box {
...
}
}
}
BottomSheet의 전체 Layout은 AnimatedVisibility API를 사용하여 이하 View를 보여줄 때 애니메이션 효과를 넣어서 보여준다.
즉, 이하 Box의 Layout을 fadeIn, fadeOut 애니메이션을 사용하여 보여주고 감춘다.
따라서
enter = slideInVertically(
initialOffsetY = { height -> height },
animationSpec = tween()
),
exit = slideOutVertically(
targetOffsetY = { height -> height },
animationSpec = tween(durationMillis = AnimationConstants.DefaultDurationMillis / 2)
)
이와 같이 애니메이션 부분을 수정하게 되면, 다른 BottomSheet Component처럼 아래에서 위로 올라오면서 보이고, 위에서 아래로 사라지게 된다.
이렇게 구현했을 때의 장점은.
- 아무런 제약사항 없이 Flag 값과 Box Layout을 선언하여 BottomSheet를 구현할 수 있다.
- 커스텀이 가장 자유롭다.
이라고 생각하며,
단점으로는
- 모든 이벤트를 제어해야 하기 때문에, 복잡한 View와 이벤트가 있다면 구현이 어렵다.
- 자연스러운 Drag 이벤트를 줄려면 애니메이션을 전체 커스텀해야 한다.
라고 생각한다.
간단하게 다른 구조를 변경하지 않고 구현할 수 있다는 것은 큰 장점이라고 생각하는데,
자연스럽게 drag 되는 이벤트를 주는 것이 상당히 어렵기 때문에 우리가 상상하는 BottomSheet의 모습을 구현하기까지 상당한 시간이 걸린다.
따라서, 정보를 주고 입력하는, 다양한 아이템을 보여주고받아야 하는 경우에 사용하기보다는
정말 간단한 정보를 주거나, 뒤로 가기를 누르는 등 특정 이벤트를 주었을 때 "정말 이거 할 거야?"라는 확인하는 View를 BottomSheet 형태로 보여주고자 할 때 사용하면 될 것 같다.
이와 같이 말이다.
이렇게 쉽게 구현이 되기 때문에,
위에 CustomBottomSheet에 대한 설명에 들어가기 전에 언급했지만
필자는 일반적인 BottomSheet를 생각하는 부분에는 ModalBottomSheetLayout을,
이렇게 간단한 팝업 형태의 BottomSheet를 생각하는 부분에는 CustomBottomSheet를 자주 사용한다.
이것으로 BottomSheet를 구현할 수 있는 3가지 방법에 대해서 알아보았다.
설명을 곁들이다 보니 글이 좀 길어지긴 했지만 이 3가지 방법 모두 쉽게 적용하여 사용이 가능하다.
설명도 잘 되어있기 때문에 이해하고 사용하기도 편하고, UI도 커스텀하기는 쉽다.
하지만, 생각보다 애니메이션을 커스텀하거나 State를 기반으로 이벤트를 커스텀하는 부분이 귀찮기 때문에
실무에서 사용할 때 보다 복잡한 이벤트가 존재하는 BottomSheet를 구현하게 된다면 조금 생각은 해봐야 할 것이다.
State 기반으로 데이터를 컨트롤할 때, Recompose가 발생하여 View가 여러 번 다시 그려지는 케이스가 존재하기 때문에 이를 방지하기 위해 잘 생각해봐야 할 것이다.
이 글을 작성하기 위해 여러 가지 테스트도 해보았고, 글을 작성하면서 명확하게 하기 위해 찾아보다 보니 정말로 BottomSheet에 대한 이해도가 많이 높아진 것 같다.
보통 그냥 "이렇게 기억하고 있지"라는 느낌으로 대충 사용하던 부분을 보다 더 자세히 알아보게 되니, 생각보다 놓치고 있던 부분들도 많았고 알았으면 더 쉽게 사용할 수 있었겠다.라는 부분도 있었다.
역시 컴포넌트들은 사용하기 전에 꼼꼼하게 찾아보고, 공부하고 사용해봐야 하는 것인데.. 그렇게 잘 안된다는 게 아쉬울 따름이다.
나중에 또 이렇게 자주 사용하지만 자세하게 안 보던 것들을 알게 되면, 공부하고 비교하고 샘플을 만들어서 글을 작성해 보아야겠다.
해당 게시글에 사용한 예제는 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
'Android > Jetpack Compose' 카테고리의 다른 글
[Compose] SwipeToDismiss를 사용하여 스와이프 이벤트를 추가해보자. (1) | 2024.04.27 |
---|---|
[Compose] 다양한 방법으로 Drawer를 구현해보자 - Scaffold, ModalDrawer (0) | 2024.04.16 |
[Compose] LazyList에서 스크롤을 커스텀하기 위해 FlingBehavior를 사용해보자. (0) | 2024.03.03 |
[Compose] Compose 환경에서 Circular Scroll Pager와 다른 UI를 가지는 Pager를 구현해보자. (2) | 2024.02.24 |
[Compose] Compose 환경에서 Pull To Refresh를 구현해보자. (2) | 2024.02.12 |