Android Compose에서 스낵바를 구현하는 일반적인 패턴은 다음과 같다.
// 루트에서 SnackbarHostState 생성
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
// 하위 스크린에 파라미터로 전달
MyScreen(snackbarHostState = snackbarHostState)
}
// 각 스크린에서 직접 사용
@Composable
fun MyScreen(snackbarHostState: SnackbarHostState) {
LaunchedEffect(event) {
snackbarHostState.showSnackbar(message)
}
}
Scaffold의 snackbarHost 파라미터는 @Composable (SnackbarHostState) -> Unit 타입으로,
SnackbarHostState만 받을 수 있다. 전역 스낵바를 위해 루트에서 생성한 SnackbarHostState를
하위 스크린까지 파라미터로 전달(prop drilling)하는 것이 기본 패턴이다.
SnackbarHostState가 MainActivity → Navigation → Screen으로 단계마다 파라미터로 전달된다.
새 화면을 추가할 때마다 Navigation에 파라미터를 추가하고 전달하는 보일러플레이트가 반복된다.
SnackbarHostState.showSnackbar()는 suspend 함수로, 호출하면 스낵바가 사라질 때까지 suspend된다.
새 메시지가 도착하면 기존 스낵바가 사라질 때까지 기다렸다가 표시되는 큐잉 동작이 기본이다.
빠르게 연속으로 에러가 발생하면 메시지가 쌓여 오래된 메시지가 지연 표시되는 문제가 생긴다.
즉시 교체(cancel & replace)를 원한다면 각 스크린에서 직접 Job을 관리해야 한다.
// 각 스크린마다 이 글루 코드를 중복 구현해야 함
var snackbarJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(event) {
snackbarJob?.cancel()
snackbarJob = launch { snackbarHostState.showSnackbar(message) }
}
cancel & replace 글루 코드를 SnackbarMessenger로 캡슐화해 각 스크린에서 중복 구현하지 않도록 했다.
또한 액션 버튼이 필요한 경우를 위해 actionLabel과 onAction도 선택적으로 전달할 수 있다.
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에서 목적지가 변경될 때 현재 스낵바를 즉시 닫기 위해 추가했다.
Scaffold의 snackbarHost는 SnackbarHostState만 받을 수 있어 SnackbarMessenger를 직접 전달할 수 없다.
따라서 렌더링은 MainActivity의 Box 안에서 MainSnackbarHost(snackbarHostState)로 직접 오버레이하고,
SnackbarMessenger는 CompositionLocal로 별도 제공해 prop drilling을 제거했다.
staticCompositionLocalOf를 사용한 이유: SnackbarMessenger 인스턴스는 Activity 재생성 시에만 바뀌므로
값이 바뀔 때 전체 subtree를 recompose하는 static 방식이 적합하다.
값이 자주 바뀌는 경우에 유리한 compositionLocalOf(dynamic)의 오버헤드가 불필요하다.
MainActivity
├─ SnackbarHostState 생성
└─ Box
├─ MainNavigation(snackbarHostState) ← 파라미터 전달
│ └─ RandomPictureListScreen(snackbarHostState) ← 파라미터 전달
│ └─ var snackbarJob: Job? (cancel & replace 글루 코드)
│ └─ snackbarHostState.showSnackbar(message)
└─ MainSnackbarHost(snackbarHostState) ← 렌더링
MainActivity
├─ SnackbarHostState 생성
├─ rememberSnackbarMessenger(snackbarHostState) ← 앱 레벨에서 한 번만 생성
├─ CompositionLocalProvider(LocalSnackbarMessenger provides messenger)
└─ Box
├─ MainNavigation() ← 파라미터 없음
│ └─ RandomPictureListScreen() ← 파라미터 없음
│ └─ LocalSnackbarMessenger.current.show(message) ← 바로 사용
└─ MainSnackbarHost(snackbarHostState) ← 렌더링 (변경 없음)
core/ui/.../SnackbarMessenger.kt (신규)cancel & replace를 담당하는 SnackbarMessenger 클래스와 LocalSnackbarMessenger CompositionLocal을 새로 추가했다.
| 추가 항목 | 내용 |
|---|---|
show(message, actionLabel?, onAction?) | cancel & replace + 액션 버튼 지원 |
dismiss() | Navigation 목적지 변경 시 스낵바 즉시 닫기 |
LocalSnackbarMessenger | staticCompositionLocalOf로 앱 전역 제공 |
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) // 변경 없음
...
}
}
MainSnackbarHost는 SnackbarHostState를 계속 직접 받는다.
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,
)
}
}
}
}
스낵바가 필요한 새 화면을 추가할 때 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() },
))