grep

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,
)
  1. text : 스낵바에 표시될 문구
  2. leadingIcon : 스낵바의 왼쪽에 표시될 아이콘
  3. leadingIconColor : leadingIcon 색상
  4. actionStyle : 스낵바에 포함된 액션 스타일(텍스트 버튼 또는 아이콘 버튼)
  5. snackBarAlignment : 화면에 스낵바가 보일 위치

StateHolder 설계 및 동작

스낵바 컴포넌트를 개발할 때 중요한 것은 복잡한 UI 로직 과 상태(State) 를 효율적으로 관리하는 것입니다. 이를 위해 SnackBarState 클래스를 설계하여 스낵바의 표시 여부 , 드래그 동작 , UI 데이터 업데이트 등을 한 곳에서 처리할 수 있도록 구성했습니다.

State and Jetpack Compose | Android Developers

State in an app is any value that can change over time. This is a very broad definition and encompasses everything from…developer.android.com

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
    }
}

상태 관리

드래그 동작 처리

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
}

상태 초기화

UI 데이터 업데이트 (LaunchedEffect)

화면 상태와 동기화 (LifecycleResumeEffect)

자동 사라짐 (DisposableEffect)

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 생략
}

애니메이션

액션 버튼 처리

드래그 제스처

스낵바 애니메이션 & 드래그 제스처 감지

기대효과

  1. 범용적 활용 가능성 다양한 화면 구성과 시나리오에서 적용할 수 있는 유연한 컴포넌트를 제작하여, 유지보수와 확장 작업이 더욱 용이해졌습니다.
  2. 의도치 않은 표시 방지 Jetpack Compose Material3 버전 업데이트로 인해 발생할 수 있는 의도치 않은 스낵바 표시 문제를 사전에 방지함으로써, 서비스 전반에서 디자인 시스템의 일관성을 유지할 수 있습니다.
  3. 재사용 가능한 구조 스낵바 로직과 상태 관리를 SnackBarState로 통합하고, rememberSnackBarState를 활용해 간편하게 재사용할 수 있는 구조를 구현하여 개발 복잡도를 효과적으로 낮췄습니다.

지금까지 여기어때 디자인 시스템(YDS) 6.0을 기반으로 한 SnackBar 개발 과정을 살펴보았습니다. 이번 커스텀 SnackBar 컴포넌트는 기존 Material3 SnackBar의 한계를 보완하며, 유연성과 확장성을 갖춘 구조로 설계되었습니다.

Part 2 에서는 이번에 개발한 Compose SnackBar 컴포넌트를 XML 기반 레거시 화면에서도 유연하게 활용할 수 있는 방법을 소개하려고 합니다. 많은 기대 부탁드립니다.

감사합니다! 😊