Jetpack Compose: Custom Snackbar

Snackbar를 커스터마이징하는 방법을 정리합니다.️

Sungyong An
18 min readJan 2, 2025

--

정리 목적으로 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 모습입니다.

Default Snackbar

기본으로 충분하다면 다행이지만, 대부분은 디자이너가 원하는 것과 다를 것 같습니다. 다양한 요구사항에 맞게 Snackbar를 커스터마이징 해봅시다. ✂️

Color, Shape 변경하기

먼저 “Snackbar 색상과 모양을 바꿔주세요” 라는 요구사항입니다. 다행히 SnackbarHostsnackbar slot을 제공하여, UI를 변경할 수 있습니다.

SnackbarHost + Snackbar
SnackbarHost + Snackbar

Snackbar는 색상과 모양을 변경할 수 있게 다양한 slot을 제공합니다.
이를 이용하면, Snackbar 색상과 모양을 원하는대로 변경할 수 있습니다.

  ...
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
+ snackbar = {
+ Snackbar(
+ snackbarData = it,
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ shape = CircleShape,
+ )
+ },
)
},
...
Snackbar with different colors

완성! 🎉

Text Style 변경하기

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

Snackbar(s)

그래서 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,
+ )
+ )
}
)
},
...
Snackbar with different text styles

완성! 🎉

새로운 Message 바로바로 표시하기

어떤 항목을 클릭할 때마다 Snackbar를 띄우는 시나리오가 있으면 “여러번 클릭할 때, 메세지를 바로바로 표시해주세요” 라는 요구사항을 받습니다.

Snackbar 메세지를 표시하는 원리를 살펴봅시다. 👀

1️⃣ showSnackbar()에서 SnackbarData를 변경하는 부분에 mutex가 걸려 있습니다. 그래서 여러번 호출하면, 이전 요청이 끝날 때까지 대기합니다.

SnackbarHostState # showSnackbar

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

SnackbarHost

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

SnackbarDataImpl # dismiss

그래서 showSnackbar()를 호출하기 전, Snackbar의 현재 메세지를 dismiss()하는 방법으로 요구사항을 간단히 구현할 수 있습니다.

  ...
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val onClick: () -> Unit = {
coroutineScope.launch {
+ snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar("Hello, Snackbar!")
}
}
...
Dismiss previous Snackbar before showing the new one.

완성! 🎉

Snackbar 뒤로 클릭 허용하기

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

Default Snackbar

Snackbar는 어디서 클릭을 가로채고 있을까요? 🤔

SnackbarSurface를 사용하는데요.

Snackbar

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

Surface

이 부분은 변경할 수 없어서, 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에 구현되어 있습니다.

SnackbarHost

FadeInFadeOutWithScale은 이름처럼 alphascale 애니메이션을 구현합니다. 코드는 Crossfade와 거의 같습니다.

Crossfade 분석은 Material Motion for Jetpack Compose 글을 참고하세요.

FadeInFadeOutWithScale

그럼 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 + Snackbar

표시/숨김되는 애니메이션은 원하는대로 되었지만, 연달아 노출하는 경우에는 부자연스럽게 보입니다. 😅

Crossfade 코드를 살펴보면, 쉽게 이해할 수 있는데요.
전환하는 2개의 레이아웃에 alpha 애니메이션이 동시 적용되기 때문입니다.

Crossfade

결국에는 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
)
}
Custom Animation + Snackbar

많이 까다롭지만, 어쨌든 완성! 🎉🎉

Snackbar 위치 고정하기

마지막 요구사항입니다. 디자인 가이드를 적용하다 보면, FloatingActionButton과 Bottom NavigationBar를 함께 사용하기도 합니다.

  Scaffold(
snackbarHost = { ... },
floatingActionButton = { ... },
+ bottomBar = {
+ NavigationBar { ... }
+ },
...

Bottom NavigationBar를 추가하면, Snackbar 위치가 달라집니다.

Snackbar with FAB and NavigationBar

이런 오묘한 위치에 Snackbar가 뜨는 걸 좋아하는 디자이너는 없을 것 같습니다. 당연히 “Snackbar 위치를 화면 하단에 고정해주세요” 와 같은 요구사항이 나옵니다. 간단히 수정할 수 있을까요? 🤔

1️⃣ Scaffoldcontent 내부로 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) },
+ )
+ }
}
Attempt #1

위치가 내려갔지만 FAB 아래에 깔리고, bottomBar보다 위에 있습니다. 🧐

2️⃣ SnackbarHostcontentPadding이 적용되지 않도록 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) },
)
}
}
Attempt #2

이제는 아예 보이지도 않습니다. 🤯

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

ScaffoldLayout

그렇다면 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) },
+ )
+ }
Attempt #3

해치웠나…? 👀

Attempt #3 with SystemUI

기기에서 실행해보면, SystemUI와 겹쳐지는 문제가 발생합니다. 😱

ScaffoldInsets을 직접 처리하는 컴포넌트입니다. place()를 호출하기 전에 Insets과 다른 slot들을 고려하여 표시되어야 할 위치를 계산합니다. snackbarOffsetFromBottom를 계산할 때, Insets의 bottom을 고려합니다.

snackbarOffsetFromBottom

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 = { ... }
...
Attempt #4 with SystemUI

많이 더 복잡해졌지만, 어쨌든 완성! 🎉🎉🎉

이렇게 직접 구현하는 경우에는 상단에 붙이는 것처럼, 전혀 다른 위치로 옮기는 요구사항도 구현할 수 있습니다. 🙈

Top Snackbar

지금까지 5가지 요구사항을 기반으로 Snackbar를 커스터마이징하는 방법을 살펴봤구요. 전체 코드는 fornewid/details-in-compose에서 볼 수 있습니다.

Action, Dismiss 버튼은 다루지 않았는데요. 이 부분도 코드가 크게 다르지 않아서 직접 살펴보기 어렵지 않을 것 같습니다. 😄

마지막으로 “Swipe 제스처로 닫는 동작”은 Swipe and Savor: Building a swipeable Snackbar in Compose 글을 읽어보시면 도움될 것 같습니다.

이만 글을 마무리하며 “새해 복 많이 받으세요!”

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Sungyong An
Sungyong An

Written by Sungyong An

Android Developer in South Korea, Android GDE

No responses yet

Write a response