
Lightweight, lifecycle-safe snackbar event dispatcher solves common pitfalls with StateFlow and SharedFlow, ensuring reliable event emission without duplicates or manual state management. Offers a simple API and is lifecycle-aware.
⚠️ Limitation (and Upcoming Fix)
Thanks to the community feedback, I learned that this solution can lose the Snackbar if a configuration change happens right after the event is delivered to the collector but before the Snackbar is dismissed. As a result, the Snackbar won't be shown again even if the user hasn't grasped the message yet. Therefore, I'll switch back toStateFlow<List<Event>>but will abstract out the details to make it easy for the developers. The update should be available before September 2025.
A lightweight, lifecycle-safe snackbar event dispatcher for Compose Multiplatform that addresses common pitfalls of using SharedFlow and StateFlow.
⚠️ Using Jetpack Compose for Android only?
This library relies onStringResourceandgetStringfromorg.jetbrains.compose.resources, which are not supported in pure Android projects. Please refer to the Android-specific version instead: AndroidSnackbarChannel.
SnackbarChannel addresses the common pitfalls of using StateFlow, SharedFlow, or even StateFlow<List<Event>>.
StateFlow re-emits on config changes, leading to duplicate snackbars.SharedFlow(replay = 0) may drop events when no collector is active.SharedFlow(replay = 1) can treat each lifecycle change as a new subscription, re-emitting events.StateFlow<List<String>>) require manual state updates to remove consumed items, adding extra complexity and visual noise to the ViewModel.SnackbarChannel avoids these issues:
It’s a focused solution that keeps your snackbar logic clean, lifecycle-aware, and easy to use.
ViewModel
SnackbarHostState.showSnackbar(...)
commonMain.dependencies {
implementation("io.github.aungthiha:snackbar-channel:1.0.7")
}By default, SnackbarChannel uses Channel.UNLIMITED and BufferOverflow.SUSPEND to ensure no snackbar events are dropped.
import io.github.aungthiha.snackbar.SnackbarChannel
import io.github.aungthiha.snackbar.SnackbarChannelOwner
class MyViewModel(
private val snackbarChannel: SnackbarChannel = SnackbarChannel() // Default: Channel.UNLIMITED
) : ViewModel(), SnackbarChannelOwner by snackbarChannel {
fun showSimpleSnackbar() {
showSnackBar(message = Res.string.hello_world)
}
}While the defaults are recommended for most use cases, both the channel capacity and onBufferOverflow strategy are configurable:
SnackbarChannel(
capacity = Channel.RENDEZVOUS, // Or Channel.BUFFERED, etc.
onBufferOverflow = BufferOverflow.DROP_OLDEST, // Or BufferOverflow.DROP_LATEST
) import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import io.github.aungthiha.snackbar.collectWithLifecycle
import io.github.aungthiha.snackbar.showSnackbar
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val snackbarHostState = remember { SnackbarHostState() }
viewModel.snackbarFlow.observeWithLifecycle {
snackbarHostState.showSnackbar(it)
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
Button(onClick = { viewModel.showSimpleSnackbar() }) {
Text("Show Snackbar")
}
}
}Use showSnackBar(...) from your ViewModel. You can pass string resources, string literals, or even mix both using SnackbarString.
// All parameters
showSnackBar(
message = Res.string.hello_world, // can be either StringResource or String
actionLabel = "ok", // can be either StringResource or String
withDismissAction = true,
duration = SnackbarDuration.Indefinite,
onActionPerform = { /* handle action */ },
onDismiss = { /* handle dismiss */ }
)
// Using a string resource
showSnackBar(
message = Res.string.hello_world,
actionLabel = Res.string.ok
)
// Using a raw string (e.g., from backend or dynamic input)
showSnackBar(
message = "Something went wrong!",
actionLabel = "Retry"
)
// Mixing string types
showSnackBar(
message = "မင်္ဂလာပါ",
actionLabel = Res.string.ok
)
showSnackBar(
message = Res.string.hello_world,
actionLabel = "ok"
)All parameters are optional except the message.
For more example usages, see AppViewModel.kt
You can test snackbar emissions using runTest and collecting from snackbarFlow.
class MyViewModelTest {
private val viewModel = MyViewModel()
@Test
fun snackbar_is_emitted() = runTest {
viewModel.showSimpleSnackbar()
val snackbarModel = viewModel.snackbarFlow.first()
assertEquals(
SnackbarString(Res.string.hello_world),
snackbarModel.message
)
}
}Tested with:
(Other targets are available but not tested yet)
PRs and feedback welcome!
MIT
⚠️ Limitation (and Upcoming Fix)
Thanks to the community feedback, I learned that this solution can lose the Snackbar if a configuration change happens right after the event is delivered to the collector but before the Snackbar is dismissed. As a result, the Snackbar won't be shown again even if the user hasn't grasped the message yet. Therefore, I'll switch back toStateFlow<List<Event>>but will abstract out the details to make it easy for the developers. The update should be available before September 2025.
A lightweight, lifecycle-safe snackbar event dispatcher for Compose Multiplatform that addresses common pitfalls of using SharedFlow and StateFlow.
⚠️ Using Jetpack Compose for Android only?
This library relies onStringResourceandgetStringfromorg.jetbrains.compose.resources, which are not supported in pure Android projects. Please refer to the Android-specific version instead: AndroidSnackbarChannel.
SnackbarChannel addresses the common pitfalls of using StateFlow, SharedFlow, or even StateFlow<List<Event>>.
StateFlow re-emits on config changes, leading to duplicate snackbars.SharedFlow(replay = 0) may drop events when no collector is active.SharedFlow(replay = 1) can treat each lifecycle change as a new subscription, re-emitting events.StateFlow<List<String>>) require manual state updates to remove consumed items, adding extra complexity and visual noise to the ViewModel.SnackbarChannel avoids these issues:
It’s a focused solution that keeps your snackbar logic clean, lifecycle-aware, and easy to use.
ViewModel
SnackbarHostState.showSnackbar(...)
commonMain.dependencies {
implementation("io.github.aungthiha:snackbar-channel:1.0.7")
}By default, SnackbarChannel uses Channel.UNLIMITED and BufferOverflow.SUSPEND to ensure no snackbar events are dropped.
import io.github.aungthiha.snackbar.SnackbarChannel
import io.github.aungthiha.snackbar.SnackbarChannelOwner
class MyViewModel(
private val snackbarChannel: SnackbarChannel = SnackbarChannel() // Default: Channel.UNLIMITED
) : ViewModel(), SnackbarChannelOwner by snackbarChannel {
fun showSimpleSnackbar() {
showSnackBar(message = Res.string.hello_world)
}
}While the defaults are recommended for most use cases, both the channel capacity and onBufferOverflow strategy are configurable:
SnackbarChannel(
capacity = Channel.RENDEZVOUS, // Or Channel.BUFFERED, etc.
onBufferOverflow = BufferOverflow.DROP_OLDEST, // Or BufferOverflow.DROP_LATEST
) import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import io.github.aungthiha.snackbar.collectWithLifecycle
import io.github.aungthiha.snackbar.showSnackbar
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val snackbarHostState = remember { SnackbarHostState() }
viewModel.snackbarFlow.observeWithLifecycle {
snackbarHostState.showSnackbar(it)
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
Button(onClick = { viewModel.showSimpleSnackbar() }) {
Text("Show Snackbar")
}
}
}Use showSnackBar(...) from your ViewModel. You can pass string resources, string literals, or even mix both using SnackbarString.
// All parameters
showSnackBar(
message = Res.string.hello_world, // can be either StringResource or String
actionLabel = "ok", // can be either StringResource or String
withDismissAction = true,
duration = SnackbarDuration.Indefinite,
onActionPerform = { /* handle action */ },
onDismiss = { /* handle dismiss */ }
)
// Using a string resource
showSnackBar(
message = Res.string.hello_world,
actionLabel = Res.string.ok
)
// Using a raw string (e.g., from backend or dynamic input)
showSnackBar(
message = "Something went wrong!",
actionLabel = "Retry"
)
// Mixing string types
showSnackBar(
message = "မင်္ဂလာပါ",
actionLabel = Res.string.ok
)
showSnackBar(
message = Res.string.hello_world,
actionLabel = "ok"
)All parameters are optional except the message.
For more example usages, see AppViewModel.kt
You can test snackbar emissions using runTest and collecting from snackbarFlow.
class MyViewModelTest {
private val viewModel = MyViewModel()
@Test
fun snackbar_is_emitted() = runTest {
viewModel.showSimpleSnackbar()
val snackbarModel = viewModel.snackbarFlow.first()
assertEquals(
SnackbarString(Res.string.hello_world),
snackbarModel.message
)
}
}Tested with:
(Other targets are available but not tested yet)
PRs and feedback welcome!
MIT