Jetpack Compose: Custom Snackbar
Snackbar를 커스터마이징하는 방법을 정리합니다.️
정리 목적으로 Snackbar를 커스터마이징하는 짧은 글을 올립니다.
이 글에서 다루는 Snackbar 구성요소는 Material3 입니다.
Snackbar는 사용자에게 간단한 알림을 전달하는 구성요소입니다.

Default Snackbar
Button 또는 FAB를 클릭하면 Snackbar를 표시하는 간단한 코드입니다.
@Composable
fun DefaultSnackbarScreen() {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val onClick: () -> Unit = {
coroutineScope.launch {
snackbarHostState.showSnackbar("Hello, Snackbar!")
}
}
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
floatingActionButton = {
FloatingActionButton(onClick = onClick)
},
) { paddingValues ->
Content(
onClick = onClick,
contentPadding = paddingValues,
)
}
}
SnackbarHostState
는 상태를 관리하고, SnackbarHost
는 Snackbar UI를 렌더링합니다. 그리고 보통 Scaffold
와 함께 사용합니다. 메세지를 표시하는 showSnackbar()
는 suspend fun
여서 CoroutineScope가 필요합니다.
기본 Snackbar 모습입니다.

기본으로 충분하다면 다행이지만, 대부분은 디자이너가 원하는 것과 다를 것 같습니다. 다양한 요구사항에 맞게 Snackbar를 커스터마이징 해봅시다. ✂️
Color, Shape 변경하기
먼저 “Snackbar 색상과 모양을 바꿔주세요” 라는 요구사항입니다. 다행히 SnackbarHost
는 snackbar
slot을 제공하여, UI를 변경할 수 있습니다.


Snackbar
는 색상과 모양을 변경할 수 있게 다양한 slot을 제공합니다.
이를 이용하면, Snackbar 색상과 모양을 원하는대로 변경할 수 있습니다.
...
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
+ snackbar = {
+ Snackbar(
+ snackbarData = it,
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ shape = CircleShape,
+ )
+ },
)
},
...

완성! 🎉
Text Style 변경하기
다음은 “Snackbar 메세지 스타일을 바꿔주세요” 라는 요구사항입니다. 이번에도 다행히 Snackbar
가 Text
를 변경할 수 있게 content
slot을 제공합니다.

그래서 Snackbar
코드를 복사하지 않고도, Text Style을 변경할 수 있습니다. 다만 padding은 적절히 추가해줘야 합니다.
...
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
snackbar = {
+ Snackbar(modifier = Modifier.padding(12.dp)) {
+ Text(
+ text = it.visuals.message,
+ fontStyle = FontStyle.Italic,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ )
+ )
}
)
},
...

완성! 🎉
새로운 Message 바로바로 표시하기
어떤 항목을 클릭할 때마다 Snackbar를 띄우는 시나리오가 있으면 “여러번 클릭할 때, 메세지를 바로바로 표시해주세요” 라는 요구사항을 받습니다.
Snackbar 메세지를 표시하는 원리를 살펴봅시다. 👀
1️⃣ showSnackbar()
에서 SnackbarData를 변경하는 부분에 mutex
가 걸려 있습니다. 그래서 여러번 호출하면, 이전 요청이 끝날 때까지 대기합니다.

2️⃣ currentSnackbarData
가 변경될 때, SnackbarHost
에서 duration 만큼 delay()
로 지연시킵니다. 이후에 dismiss()
를 호출합니다.

3️⃣ dismiss()
에서는 resume()
을 호출하여 작업을 마무리합니다. 이 시점에 showSnackbar()
의 mutex
가 해제되어, 다음 메세지를 표시할 수 있습니다.

그래서 showSnackbar()
를 호출하기 전, Snackbar의 현재 메세지를 dismiss()
하는 방법으로 요구사항을 간단히 구현할 수 있습니다.
...
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val onClick: () -> Unit = {
coroutineScope.launch {
+ snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar("Hello, Snackbar!")
}
}
...

완성! 🎉
Snackbar 뒤로 클릭 허용하기
Snackbar는 보통 다른 UI 위에 표시되고, 겹친 부분은 클릭되지 않습니다. 하지만 하단에 자주 클릭하는 버튼이 위치하면 “Snackbar 메세지 뒤로도 클릭되도록 해주세요” 라는 요구사항을 받기도 합니다.

Snackbar는 어디서 클릭을 가로채고 있을까요? 🤔
Snackbar
는 Surface
를 사용하는데요.

Surface
에서 pointerInput()
으로 모든 포인터 입력을 가로채고 있습니다.

이 부분은 변경할 수 없어서, Surface를 사용하지 않는 Custom Snackbar를 직접 구현해야 합니다.
@Composable
private fun CustomSnackbar(
- Surface(
- modifier = modifier.padding(12.dp),
- shape = shape,
- color = containerColor,
- contentColor = contentColor,
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ Box(
+ modifier = modifier.padding(12.dp)
+ .background(color = containerColor, shape = shape)
+ .clip(shape),
+ propagateMinConstraints = true,
...

조금 까다롭지만, 완성! 🎉
전환 애니메이션 변경하기
디자인에는 애니메이션도 포함됩니다. 보통은 “Snackbar 애니메이션을 가이드대로 적용해주세요” 라는 요구사항이겠지만, 여기서는 임의로 기본 애니메이션에서 Scale 효과만 제거하는 것으로 설정해보겠습니다.
Snackbar 전환 애니메이션이 어떻게 구현되어 있는지 살펴봅시다.
결론만 얘기하면, SnackbarHost
에서 사용하는 FadeInFadeOutWithScale
에 구현되어 있습니다.

FadeInFadeOutWithScale
은 이름처럼 alpha
와 scale
애니메이션을 구현합니다. 코드는 Crossfade
와 거의 같습니다.
Crossfade
분석은 Material Motion for Jetpack Compose 글을 참고하세요.

그럼 Scale 효과만 제거하면 되니까, Crossfade
를 사용해도 되겠죠?
@Composable
private fun CustomSnackbarHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier,
snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) }
) {
...
- FadeInFadeOutWithScale(
- current = hostState.currentSnackbarData,
- modifier = modifier,
- content = snackbar
- )
+ Crossfade(
+ targetState = hostState.currentSnackbarData,
+ modifier = modifier,
+ content = { current ->
+ if (current != null) snackbar(current)
+ }
+ )
}

표시/숨김되는 애니메이션은 원하는대로 되었지만, 연달아 노출하는 경우에는 부자연스럽게 보입니다. 😅
Crossfade
코드를 살펴보면, 쉽게 이해할 수 있는데요.
전환하는 2개의 레이아웃에 alpha 애니메이션이 동시 적용되기 때문입니다.

결국에는 FadeInFadeOutWithScale
또는 Crossfade
를 참고하여, Custom Animation을 직접 구현해야 합니다. 🥲
@Composable
private fun CustomSnackbarHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier,
snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) }
) {
...
- FadeInFadeOutWithScale(
+ FadeInFadeOut(
current = hostState.currentSnackbarData,
modifier = modifier,
content = snackbar
)
}

많이 까다롭지만, 어쨌든 완성! 🎉🎉
Snackbar 위치 고정하기
마지막 요구사항입니다. 디자인 가이드를 적용하다 보면, FloatingActionButton과 Bottom NavigationBar를 함께 사용하기도 합니다.
Scaffold(
snackbarHost = { ... },
floatingActionButton = { ... },
+ bottomBar = {
+ NavigationBar { ... }
+ },
...
Bottom NavigationBar를 추가하면, Snackbar 위치가 달라집니다.

이런 오묘한 위치에 Snackbar가 뜨는 걸 좋아하는 디자이너는 없을 것 같습니다. 당연히 “Snackbar 위치를 화면 하단에 고정해주세요” 와 같은 요구사항이 나옵니다. 간단히 수정할 수 있을까요? 🤔
1️⃣ Scaffold
의 content
내부로 SnackbarHost
를 옮겨봅시다.
Scaffold(
- snackbarHost = {
- SnackbarHost(
- hostState = snackbarHostState,
- snackbar = { CustomSnackbar_B(it) },
- )
- },
floatingActionButton = { ... },
bottomBar = { ... },
) { paddingValues ->
+ Box(modifier = Modifier.padding(paddingValues)) {
Content(
onClick = onClick,
- contentPadding = paddingValues,
)
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ snackbar = { CustomSnackbar_B(it) },
+ )
+ }
}

위치가 내려갔지만 FAB 아래에 깔리고, bottomBar보다 위에 있습니다. 🧐
2️⃣ SnackbarHost
는 contentPadding
이 적용되지 않도록 Box
로 한번 더 감싸면 어떨까요?
Scaffold(
floatingActionButton = { ... },
bottomBar = { ... },
) { paddingValues ->
- Box(modifier = Modifier.padding(paddingValues)) {
+ Box {
Content(
onClick = onClick,
+ contentPadding = paddingValues,
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
snackbar = { CustomSnackbar_B(it) },
)
}
}

이제는 아예 보이지도 않습니다. 🤯
Scaffold
를 살펴봅시다. ScaffoldLayout
에서 slot들을 배치하는 부분이 있는데요. place()
를 호출한 순서를 보면, content > topBar > snackbar > bottomBar > fab 순으로 배치됩니다. 따라서 content
에 있는 SnackbarHost
가 항상 아래에 깔려 보이게 됩니다. 😵💫

그렇다면 content
에는 SnackbarHost
를 둘 수 없겠죠?
3️⃣ 그럼 Scaffold
밖으로 SnackbarHost
를 옮겨봅시다.
+ Box {
Scaffold(
floatingActionButton = { ... },
bottomBar = { ... },
) { paddingValues ->
- Box {
Content(
onClick = onClick,
contentPadding = paddingValues,
)
- SnackbarHost(
- hostState = snackbarHostState,
- modifier = Modifier.align(Alignment.BottomCenter),
- snackbar = { CustomSnackbar_B(it) },
- )
- }
}
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ snackbar = { CustomSnackbar_B(it) },
+ )
+ }

해치웠나…? 👀

기기에서 실행해보면, SystemUI와 겹쳐지는 문제가 발생합니다. 😱
Scaffold
는 Insets을 직접 처리하는 컴포넌트입니다. place()
를 호출하기 전에 Insets과 다른 slot들을 고려하여 표시되어야 할 위치를 계산합니다. snackbarOffsetFromBottom
를 계산할 때, Insets의 bottom을 고려합니다.

Insets을 직접 다루는 방법을 여러가지로 고민해봤으나, 결론은 Scaffold
를 복사하여 일부분만 수정하는 것이 가장 간단해 보입니다. 😵
...
val snackbarOffsetFromBottom =
if (snackbarHeight != 0) {
snackbarHeight +
- (fabOffsetFromBottom
- ?: bottomBarHeight
- ?: contentWindowInsets.getBottom(this@SubcomposeLayout))
+ contentWindowInsets.getBottom(this@SubcomposeLayout)
} else {
0
}
...
layout(layoutWidth, layoutHeight) {
bodyContentPlaceables.fastForEach { ... }
topBarPlaceables.fastForEach { ... }
- snackbarPlaceables.fastForEach { ... }
bottomBarPlaceables.fastForEach { ... }
fabPlacement?.let { placement -> ... }
+ snackbarPlaceables.fastForEach { ... }
}
...
앱에 반복해서 사용하기에도 Custom Scaffold를 만드는 것이 좋아 보입니다.
- Scaffold(
+ CustomScaffold(
snackbarHost = { ... },
floatingActionButton = { ... },
bottomBar = { ... }
...

많이 더 복잡해졌지만, 어쨌든 완성! 🎉🎉🎉
이렇게 직접 구현하는 경우에는 상단에 붙이는 것처럼, 전혀 다른 위치로 옮기는 요구사항도 구현할 수 있습니다. 🙈

지금까지 5가지 요구사항을 기반으로 Snackbar를 커스터마이징하는 방법을 살펴봤구요. 전체 코드는 fornewid/details-in-compose에서 볼 수 있습니다.
Action, Dismiss 버튼은 다루지 않았는데요. 이 부분도 코드가 크게 다르지 않아서 직접 살펴보기 어렵지 않을 것 같습니다. 😄
마지막으로 “Swipe 제스처로 닫는 동작”은 Swipe and Savor: Building a swipeable Snackbar in Compose 글을 읽어보시면 도움될 것 같습니다.
이만 글을 마무리하며 “새해 복 많이 받으세요!”