Design
Part 1. 여기어때 디자인 시스템 기반 커스텀 SnackBar 개발기
wayne / Android 개발팀여기어때
2024년 12월 31일
원문에서 보기 ↗안녕하세요, 여기어때컴퍼니 Android개발팀의 웨인입니다.
현재 여기어때 앱은 YDS(여기어때 디자인 시스템) 6.0 버전 을 기반으로 개발되고 있으며, 이번에는 YDS 6.0 버전에서 새롭게 추가된 SnackBar 컴포넌트를 개발하고 적용한 과정을 공유하고자 합니다.

YDS 6.0 SnackBar
Google Material3 SnackBar(SnackBarHost)와 다른점
구성 요소


(좌) Google Material3 , (우) 여기어때
중복 호출 지원 여부
구글 Material3에서 제공하는 SnackBarHost 같은 경우 스낵바가 이미 표시되어 있는 상태에서 새로운 스낵바를 호출하면, 기존 스낵바가 사라질 때까지 새로운 스낵바의 표시를 지연시킵니다.

Material3 SnackBarHost showSnackBar 함수 내 주석
반면, 여기어때 스낵바는 새로운 스낵바가 호출되면 기존 스낵바는 즉시 사라지고, 새로운 스낵바가 화면에 바로 표시됩니다

(좌) Material3 SnackBarHost (우) 여기어때 YDS SnackBar
노출 시간 & 애니메이션

커스텀 스낵바
UiData 설계
SnackBarUiData 클래스는 스낵바 컴포넌트가 표시할 UI 데이터를 캡슐화하여 관리할 수 있도록 설계되었습니다. 이 클래스는 스낵바의 텍스트, 아이콘, 화면 배치 방식 등 커스텀 속성을 포함합니다.
@Immutable
class SnackBarUiData(
val text: String? = null,
val leadingIcon: SnackBarLeadingIcon? = null,
val leadingIconColor: SnackBarLeadingIconColor? = null,
val actionStyle: ActionStyle? = null,
val snackBarAlignment: SnackBarAlignment = SnackBarAlignment.Top,
)
text: 스낵바에 표시될 문구leadingIcon: 스낵바의 왼쪽에 표시될 아이콘leadingIconColor: leadingIcon 색상actionStyle: 스낵바에 포함된 액션 스타일(텍스트 버튼 또는 아이콘 버튼)snackBarAlignment: 화면에 스낵바가 보일 위치
StateHolder 설계 및 동작
스낵바 컴포넌트를 개발할 때 중요한 것은 복잡한 UI 로직 과 상태(State) 를 효율적으로 관리하는 것입니다. 이를 위해 SnackBarState 클래스를 설계하여 스낵바의 표시 여부 , 드래그 동작 , UI 데이터 업데이트 등을 한 곳에서 처리할 수 있도록 구성했습니다.
State and Jetpack Compose | Android Developers
SnackBarState 설계 및 동작
SnackBarState는 스낵바의 동작과 상태를 캡슐화하여, UI 컴포넌트가 단순히 상태를 읽고 반영할 수 있도록 도와주는 클래스입니다.
@Stable
class SnackBarState(
uiData: SnackBarUiData,
density: Density,
) {
var job: Job? = null
var snackBarUiData by mutableStateOf(uiData)
private set
private var dragAmount by mutableFloatStateOf(0f)
internal var isShow by mutableStateOf(false)
internal fun updateDragAmount(value: Float) {
dragAmount += value
}
internal fun onDragEnd() {
when (snackBarUiData.snackBarAlignment) {
// 스낵바 높이의 30% 이상을 위로 스와이프 했을 때
SnackBarAlignment.Top -> if (dragAmount < -viewHeight * 0.3f) {
isShow = false
}
// 스낵바 높이의 30% 이상을 아래로 스와이프 했을 때
is SnackBarAlignment.Bottom -> if (dragAmount > viewHeight * 0.3f) {
isShow = false
}
}
dragAmount = 0f
}
}
상태 관리
snackBarUiData: 스낵바의 UI 데이터를 동적으로 업데이트합니다.isShow: 스낵바의 표시 여부를 제어합니다.
드래그 동작 처리
dragAmount: 사용자가 스낵바를 드래그한 거리를 누적하여 추적합니다.onDragEnd: 드래그가 끝났을 때 스낵바의 30% 이상 이동 여부를 기준으로 숨김으로 처리합니다.
rememberSnackBarState
rememberSnackBarState는 스낵바의 상태를 관리하고, 동작을 조율하는 핵심 컴포저블 함수입니다. 이 함수는 상태를 초기화하고, 동적으로 업데이트하며, 필요한 리소스를 자동으로 관리합니다.
@Composable
fun rememberSnackBarState(
uiData: SnackBarUiData,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): SnackBarState {
val density = LocalDensity.current
val state = remember {
SnackBarState(uiData = uiData, density = density)
}
LaunchedEffect(uiData) {
state.updateUiData(uiData)
if (state.isShow) {
state.isShow = false
state.job?.cancel()
} else {
state.isShow = true
}
}
// 화면을 벗어나면 snackbar 사라지도록 설정
LifecycleResumeEffect(state) {
onPauseOrDispose {
state.isShow = false
state.job?.cancel()
}
}
// 스낵바가 일정 시간 후에 사라지도록 설정
DisposableEffect(state.isShow) {
if (state.isShow) {
state.job = coroutineScope.launch {
delay(3000L)
state.isShow = false
}
}
onDispose {
state.job?.cancel()
}
}
return state
}
상태 초기화
remember를 통해SnackBarState객체를 Compose 트리에서 유지합니다. 이를 통해 컴포넌트가 재구성(recomposition)되어도 상태가 초기화되지 않습니다.
UI 데이터 업데이트 (LaunchedEffect)
uiData가 변경될 때,LaunchedEffect가 실행됩니다.- 기존 스낵바가 표시 중이면 숨기고, 새로운 데이터를 반영하여 다시 표시합니다.
화면 상태와 동기화 (LifecycleResumeEffect)
- 화면이 비활성화되거나 사용자가 애플리케이션을 떠날 때 스낵바를 숨깁니다.
- 리소스를 정리하고 불필요한 동작을 방지합니다.
자동 사라짐 (DisposableEffect)
- 스낵바가 표시되면 설정된 시간만큼 대기 후 자동으로 숨깁니다.
job객체를 활용하여 동작을 관리하고, 컴포넌트가 해제될 때 작업을 취소합니다.
SnackBar UI 컴포넌트
위에서 설명한 SnackBarUiData와 SnackBarState를 활용하여 디자인 요구사항을 충족하는 SnackBar 컴포저블 함수를 구현했습니다. 이 컴포넌트는 애니메이션, 드래그 제스처, 사용자 이벤트 처리를 통합적으로 지원하며, 상태 기반의 유연한 UI 관리를 가능하게 합니다.
@Composable
fun SnackBar(
modifier: Modifier = Modifier,
snackBarState: SnackBarState,
) {
AnimatedVisibility(
modifier = modifier
.pointerInput(Unit) {
detectDragGestures(
onDrag = { _, offset -> snackBarState.updateDragAmount(offset.y) },
onDragEnd = snackBarState::onDragEnd
)
}
.padding(horizontal = 10.dp)
.fillMaxWidth(),
visible = snackBarState.isShow,
enter = expandVertically(
animationSpec = ...,
expandFrom = Alignment.Top,
) + fadeIn(animationSpec = ...),
exit = shrinkVertically(
animationSpec = ...,
shrinkTowards = Alignment.Top
) + fadeOut(...),
) {
SnackBarContents(
uiData = snackBarState.snackBarUiData,
onClick = {
snackBarState.snackBarUiData.actionStyle?.action?.invoke()
snackBarState.isShow = false
}
)
}
}
@Composable
private fun SnackBarContents(
modifier: Modifier = Modifier,
uiData: SnackBarUiData,
onClick: () -> Unit,
) {
// SnackBarUiData 정보를 이용해 내부 컴포넌트를 그림.
// ... UI 생략
}
애니메이션
AnimatedVisibility컴포넌트를 활용하여 스낵바가 등장하고 사라질 때의 애니메이션 효과를 부여했습니다.expandVertically와shrinkVertically를 사용해 **화면의 배치 방향(snackBarAlignment)**에 맞춰 위 혹은 아래로 사라지도록 애니메이션을 적용했습니다.
액션 버튼 처리
- 스낵바의 영역 전체를 버튼처럼 동작하도록 설계했습니다. 사용자가 스낵바를 클릭했을 때,
SnackBarUiData의actionStyle에 포함된 동작이 실행되며, 스낵바가 닫히도록 구현했습니다.
드래그 제스처
- 사용자가 스낵바를 드래그하여 제거할 수 있도록 제스처를 감지합니다.
pointerInput과detectDragGestures를 통해 드래그 동작을 처리하며, 스낵바의 **표시 방향(Top 또는 Bottom)**에 따라 스낵바를 숨깁니다.SnackBarState의updateDragAmount와onDragEnd메서드를 호출해 드래그 동작이 끝났을 때의 상태를 처리합니다.

스낵바 애니메이션 & 드래그 제스처 감지
기대효과
- 범용적 활용 가능성 다양한 화면 구성과 시나리오에서 적용할 수 있는 유연한 컴포넌트를 제작하여, 유지보수와 확장 작업이 더욱 용이해졌습니다.
- 의도치 않은 표시 방지 Jetpack Compose Material3 버전 업데이트로 인해 발생할 수 있는 의도치 않은 스낵바 표시 문제를 사전에 방지함으로써, 서비스 전반에서 디자인 시스템의 일관성을 유지할 수 있습니다.
- 재사용 가능한 구조
스낵바 로직과 상태 관리를
SnackBarState로 통합하고,rememberSnackBarState를 활용해 간편하게 재사용할 수 있는 구조를 구현하여 개발 복잡도를 효과적으로 낮췄습니다.
지금까지 여기어때 디자인 시스템(YDS) 6.0을 기반으로 한 SnackBar 개발 과정을 살펴보았습니다. 이번 커스텀 SnackBar 컴포넌트는 기존 Material3 SnackBar의 한계를 보완하며, 유연성과 확장성을 갖춘 구조로 설계되었습니다.
Part 2 에서는 이번에 개발한 Compose SnackBar 컴포넌트를 XML 기반 레거시 화면에서도 유연하게 활용할 수 있는 방법을 소개하려고 합니다. 많은 기대 부탁드립니다.
감사합니다! 😊