output
Expanding Compose Snackbars
Context
Snackbars are a very common way to provide the user feedback about their actions or app state, and although Material 3 already comes with a very good implementation of Snackbars, but, when it comes the time to customise the UI and behaviour it feels pretty limited.
But luckily there’s a way to not have to re-invent the wheel and take advantage of all the foundation work that is already in place and just extend functionality and appearance.
If you want copy-paste code, you can skip to “Building on top of Material3 SnackBars”
Exploring Material3 SnackBars
The compose implementation of Snackbars in material3, has two main components:
SnackbarHostState: Manages the snackbar state, que and exposes methods to show a snackbar.SnackbarHost: Displays the Snackbar and optionally defines its style, it receives aSnackbarHostState
Essentially, a basic usage of the snackbars would be the following.
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("Show snackbar") },
icon = { Icon(Icons.Filled.Image, contentDescription = "") },
onClick = {
scope.launch {
snackbarHostState.showSnackbar("Snackbar")
}
}
)
}
) { contentPadding ->
}
But, how do we define a custom style?
Well, if we look closely, the SnackbarHostState has two main methods to show a snackbar, one that receives parameters, like message actionLabel , etc. and other overload that uses **SnackbarVisuals** , this is the one that we’ll use.
Building on top of Material3 Snackbars
Defining our Styles
We will first start by defining a custom interface that describes the general UI of our custom Snackbar, here we can customise whatever we want to pass to our composable.
interface CusomSnackBarVisuals : SnackbarVisuals {
val title: String
val description: String?
val backgroundColor: Color
val textColor: Color
val icon: Int?
override val message: String
override val actionLabel: String?
override val duration: SnackbarDuration
override val withDismissAction: Boolean
}
Then, in order to have a set of snackbar styles (in this case Success and Error) we will create a sealed class that will contain a default implementation of CustomSnackBarVisuals that adapts to our design system.
sealed class CustomSnackBarVariants(
override val message: String = "",
override val actionLabel: String? = null,
override val withDismissAction: Boolean = false,
) : SnackbarVisuals {
data class Success(
override val title: String,
override val description: String? = null,
override val backgroundColor: Color = Color(0xFF91E8C8),
override val textColor: Color = Color(0xFF36454F),
override val icon: Int? = R.drawable.ic_success_snackbar,
override val duration: SnackbarDuration = SnackbarDuration.Short,
) : CustomSnackBarVariants(), CusomSnackBarVisuals
data class Error(
override val title: String,
override val description: String? = null,
override val backgroundColor: Color = Color(0xFFA81B52),
override val textColor: Color = Color.White,
override val icon: Int? = R.drawable.ic_alert_snackbar,
override val duration: SnackbarDuration = SnackbarDuration.Short,
) : CustomSnackBarVariants(), CusomSnackBarVisuals
}
Creating our UI Composable
Once we have our styles in place, we will create a custom composable that will receive the SnackbarData and cast it to our custom styles/properties. Once we have all of our data, we can just access it and create our custom UI, in this case a swipable composable.
@Composable
fun CusomSnackBar(
snackbarData: SnackbarData,
modifier: Modifier = Modifier,
) {
val customVisuals = snackbarData.visuals as? CustomSnackBarVisuals ?: return
val dismissSnackbarState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it != SwipeToDismissBoxValue.Settled) {
snackbarData.dismiss()
}
true
}
)
SwipeToDismissBox(
state = dismissSnackbarState,
backgroundContent = {
Box(Modifier.fillMaxSize())
}
) {
Surface(
modifier = modifier
.padding(13.dp)
.widthIn(min = 500.dp),
color = customVisuals.backgroundColor,
shape = MaterialTheme.shapes.medium,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier.padding(13.dp),
verticalAlignment = Alignment.CenterVertically
) {
customVisuals.icon?.let {
Icon(
painterResource(it),
contentDescription = null,
tint = Color.Unspecified
)
Spacer(modifier = Modifier.width(16.dp))
}
Column {
Text(
text = customVisuals.title,
style = MaterialTheme.typography.bodyLarge,
color = customVisuals.textColor
)
customVisuals.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = customVisuals.textColor
)
}
}
}
}
}
}
Usage
Then we can just use it as if it we were using the material3 snackbars, but passing our custom variants to it!
@Composable
fun HomeScreen() {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
snackbarHostState.showSnackbar(
CustomSnackBarVariants.Success(
"Title",
"Description"
)
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = {
SnackbarHost(snackbarHostState) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
CustomSnackBar(it)
}
}
}
) { safePadding ->
Text("Your UI...")"
}
}
Nice! Now we have our custom snackbars! But it is common that the snackbars are used just on the UI, they are commonly needed in the ViewModel and other places in the app, so, we can go a step further and create a global snackbar event bus.
Using SnackBar Globally
Let’s create a singleton object that will expose a single-live event that our UI will then consume.
object SnackBarEvents {
private val events = Channel<SnackbarVisuals>(Channel.BUFFERED)
val snackBarEvents: Flow<SnackbarVisuals> = events.receiveAsFlow()
suspend fun showSnackbar(snackbarVisuals: SnackbarVisuals) {
events.send(snackbarVisuals)
}
}
Now, let’s just observe that singleton events at the root of our App and show our custom snackbar.
@Composable
fun HostSnackBar(scope: SnackBarScope = SnackBarScope.Global) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
SnackBarEvents.snackBarEvents.collect { snackBarEvent ->
val snackBarData = snackBarEvent as? CustomSnackBarVisuals
if(snackBarData?.scope == scope) {
snackbarHostState.showSnackbar(snackBarEvent)
}
}
}
SnackbarHost(snackbarHostState) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
SnackBar(it)
}
}
}
// App.kt
fun App() {
Scaffold(
snackbarHost = { HostSnackBar() }
) {
// our whole app/navigation
}
}
Conclusion
Material UI SnackBars are a great foundation, but they lack customisability, thankfully we can extend their style and behaviour while keeping all Google’s Foundation work.