Android Jetpack Compose Root-Level Snackbar 구현하기

MSU·2026년 6월 26일

Android

목록 보기
38/42

1. 일반적인 스낵바 구현 방식

Android Compose에서 스낵바를 구현하는 일반적인 패턴은 다음과 같다.

// 루트에서 SnackbarHostState 생성
val snackbarHostState = remember { SnackbarHostState() }

Scaffold(
    snackbarHost = { SnackbarHost(snackbarHostState) }
) {
    // 하위 스크린에 파라미터로 전달
    MyScreen(snackbarHostState = snackbarHostState)
}
// 각 스크린에서 직접 사용
@Composable
fun MyScreen(snackbarHostState: SnackbarHostState) {
    LaunchedEffect(event) {
        snackbarHostState.showSnackbar(message)
    }
}

ScaffoldsnackbarHost 파라미터는 @Composable (SnackbarHostState) -> Unit 타입으로,
SnackbarHostState만 받을 수 있다. 전역 스낵바를 위해 루트에서 생성한 SnackbarHostState
하위 스크린까지 파라미터로 전달(prop drilling)하는 것이 기본 패턴이다.


2. 기본 패턴의 문제점

문제 1 — Prop Drilling

SnackbarHostStateMainActivity → Navigation → Screen으로 단계마다 파라미터로 전달된다.
새 화면을 추가할 때마다 Navigation에 파라미터를 추가하고 전달하는 보일러플레이트가 반복된다.

문제 2 — 큐잉(Queuing) 동작

SnackbarHostState.showSnackbar()는 suspend 함수로, 호출하면 스낵바가 사라질 때까지 suspend된다.
새 메시지가 도착하면 기존 스낵바가 사라질 때까지 기다렸다가 표시되는 큐잉 동작이 기본이다.
빠르게 연속으로 에러가 발생하면 메시지가 쌓여 오래된 메시지가 지연 표시되는 문제가 생긴다.

즉시 교체(cancel & replace)를 원한다면 각 스크린에서 직접 Job을 관리해야 한다.

// 각 스크린마다 이 글루 코드를 중복 구현해야 함
var snackbarJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(event) {
    snackbarJob?.cancel()
    snackbarJob = launch { snackbarHostState.showSnackbar(message) }
}

3. 이 프로젝트의 해결 방식

해결 1 — SnackbarMessenger로 캡슐화

cancel & replace 글루 코드를 SnackbarMessenger로 캡슐화해 각 스크린에서 중복 구현하지 않도록 했다.
또한 액션 버튼이 필요한 경우를 위해 actionLabelonAction도 선택적으로 전달할 수 있다.

class SnackbarMessenger(
    private val hostState: SnackbarHostState,
    private val scope: CoroutineScope,
) {
    private var job: Job? = null

    fun show(message: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) {
        job?.cancel()  // 기존 스낵바 즉시 취소
        job = scope.launch {
            val result = hostState.showSnackbar(message = message, actionLabel = actionLabel)
            if (result == SnackbarResult.ActionPerformed) onAction?.invoke()
        }
    }

    fun dismiss() {
        job?.cancel()
        job = null
        hostState.currentSnackbarData?.dismiss()
    }
}

dismiss()는 Navigation에서 목적지가 변경될 때 현재 스낵바를 즉시 닫기 위해 추가했다.

해결 2 — CompositionLocal로 전달

ScaffoldsnackbarHostSnackbarHostState만 받을 수 있어 SnackbarMessenger를 직접 전달할 수 없다.
따라서 렌더링은 MainActivityBox 안에서 MainSnackbarHost(snackbarHostState)로 직접 오버레이하고,
SnackbarMessengerCompositionLocal로 별도 제공해 prop drilling을 제거했다.

staticCompositionLocalOf를 사용한 이유: SnackbarMessenger 인스턴스는 Activity 재생성 시에만 바뀌므로
값이 바뀔 때 전체 subtree를 recompose하는 static 방식이 적합하다.
값이 자주 바뀌는 경우에 유리한 compositionLocalOf(dynamic)의 오버헤드가 불필요하다.


4. 변경 전후 아키텍처

Before

MainActivity
  ├─ SnackbarHostState 생성
  └─ Box
       ├─ MainNavigation(snackbarHostState)              ← 파라미터 전달
       │    └─ RandomPictureListScreen(snackbarHostState) ← 파라미터 전달
       │         └─ var snackbarJob: Job? (cancel & replace 글루 코드)
       │              └─ snackbarHostState.showSnackbar(message)
       └─ MainSnackbarHost(snackbarHostState)            ← 렌더링

After

MainActivity
  ├─ SnackbarHostState 생성
  ├─ rememberSnackbarMessenger(snackbarHostState)   ← 앱 레벨에서 한 번만 생성
  ├─ CompositionLocalProvider(LocalSnackbarMessenger provides messenger)
  └─ Box
       ├─ MainNavigation()                          ← 파라미터 없음
       │    └─ RandomPictureListScreen()            ← 파라미터 없음
       │         └─ LocalSnackbarMessenger.current.show(message)  ← 바로 사용
       └─ MainSnackbarHost(snackbarHostState)       ← 렌더링 (변경 없음)

5. 파일별 변경 내용

core/ui/.../SnackbarMessenger.kt (신규)

cancel & replace를 담당하는 SnackbarMessenger 클래스와 LocalSnackbarMessenger CompositionLocal을 새로 추가했다.

추가 항목내용
show(message, actionLabel?, onAction?)cancel & replace + 액션 버튼 지원
dismiss()Navigation 목적지 변경 시 스낵바 즉시 닫기
LocalSnackbarMessengerstaticCompositionLocalOf로 앱 전역 제공
rememberSnackbarMessenger()Composable에서 인스턴스 생성용 헬퍼

app/.../MainActivity.kt

// Before
val snackbarHostState = remember { SnackbarHostState() }
Box(modifier = Modifier.fillMaxSize()) {
    MainNavigation(snackbarHostState = snackbarHostState)
    MainSnackbarHost(snackbarHostState = snackbarHostState)
    ...
}

// After
val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessenger = rememberSnackbarMessenger(snackbarHostState)
CompositionLocalProvider(LocalSnackbarMessenger provides snackbarMessenger) {
    Box(modifier = Modifier.fillMaxSize()) {
        MainNavigation()                                         // 파라미터 제거
        MainSnackbarHost(snackbarHostState = snackbarHostState)  // 변경 없음
        ...
    }
}

MainSnackbarHostSnackbarHostState를 계속 직접 받는다.
host 렌더링은 :app 레이어의 책임이고 feature screen이 접근할 필요가 없으므로 CompositionLocal로 노출하지 않는다.


app/.../Navigation.kt

// Before
@Composable
fun MainNavigation(snackbarHostState: SnackbarHostState) {
    val backStack = rememberNavBackStack(RandomPictureListRoute)
    LaunchedEffect(backStack.lastOrNull()) { snackbarHostState.currentSnackbarData?.dismiss() }
    ...
    entry<RandomPictureListRoute> {
        RandomPictureListScreen(
            onPictureClick = { id -> backStack.add(RandomPictureDetailRoute(id)) },
            snackbarHostState = snackbarHostState,
        )
    }
}

// After
@Composable
fun MainNavigation() {
    val backStack = rememberNavBackStack(RandomPictureListRoute)
    val messenger = LocalSnackbarMessenger.current
    LaunchedEffect(backStack.lastOrNull()) { messenger.dismiss() }  // SnackbarMessenger에 위임
    ...
    entry<RandomPictureListRoute> {
        RandomPictureListScreen(
            onPictureClick = { id -> backStack.add(RandomPictureDetailRoute(id)) },
        )
    }
}

feature/.../RandomPictureListScreen.kt

// Before
@Composable
fun RandomPictureListScreen(
    onPictureClick: (String) -> Unit,
    snackbarHostState: SnackbarHostState,   // 파라미터
    modifier: Modifier = Modifier,
    viewModel: RandomPictureListViewModel = hiltViewModel(),
) {
    ...
    var snackbarJob by remember { mutableStateOf<Job?>(null) }  // 직접 Job 관리
    LaunchedEffect(viewModel.events, lifecycle) {
        viewModel.events.flowWithLifecycle(lifecycle).collect { event ->
            when (event) {
                is RandomPictureListEvent.ShowMessage -> {
                    snackbarJob?.cancel()  // 기존 스낵바 즉시 취소
                    snackbarJob = launch {
                        snackbarHostState.showSnackbar(context.getString(event.messageRes))
                    }
                }
            }
        }
    }
}

// After
@Composable
fun RandomPictureListScreen(
    onPictureClick: (String) -> Unit,       // 파라미터 단순화
    modifier: Modifier = Modifier,
    viewModel: RandomPictureListViewModel = hiltViewModel(),
) {
    ...
    val snackbarMessenger = LocalSnackbarMessenger.current  // CompositionLocal에서 획득
    LaunchedEffect(viewModel.events, lifecycle) {
        viewModel.events.flowWithLifecycle(lifecycle).collect { event ->
            when (event) {
                is RandomPictureListEvent.ShowMessage ->
                    snackbarMessenger.show(
                        message = context.getString(event.messageRes),
                        actionLabel = event.actionLabelRes?.let { context.getString(it) },
                        onAction = event.onAction,
                    )
            }
        }
    }
}

6. 새 화면 추가 시 패턴

스낵바가 필요한 새 화면을 추가할 때 Navigation에 파라미터를 추가할 필요 없이 바로 사용한다.

@Composable
fun NewFeatureScreen(...) {
    val snackbarMessenger = LocalSnackbarMessenger.current

    // 텍스트만
    snackbarMessenger.show(message)

    // 액션 버튼 포함
    snackbarMessenger.show(
        message = message,
        actionLabel = getString(R.string.retry),
        onAction = { viewModel.retry() },
    )
}

ViewModel에서 이벤트로 전달하는 경우:

// ViewModel
_events.trySend(ShowMessage(
    messageRes = R.string.load_failed,
    actionLabelRes = R.string.retry,   // null이면 버튼 미표시
    onAction = { refresh() },
))
profile
안드로이드공부

0개의 댓글