Article featured in Android Weekly #634 and Kotlin Weekly #420
Inspired by a
recent discussion on /r/androiddev
about Jetpack Compose
and its Snackbar component,
I want to share an approach that I find easy to use and highly reusable in other projects.
We’ll explore an implementation that allows Snackbars to be displayed not only from within the Compose UI tree, but also
from outside of it, such as from a ViewModel.
What’s the problem? #
To display a Snackbar in a Compose app, you’ll need a few components:
- A
Scaffold
component to render theSnackbarHost
- A
SnackbarHost
which is responsible for the UI presentation of the Snackbar - A
SnackbarHostState
that theSnackbarHost
uses to manage the display, hiding, and dismissal of Snackbars
The bare-bone setup looks something like this:
@Composable
fun App() {
val host = remember { SnackbarHostState() }
Scaffold(snackbarHost = { SnackbarHost(hostState = host) }) {
// rest of content
}
}
We can call showSnackbar
on the host
state instance to enqueue a new Snackbar. This is straightforwards, as long
as you have access to the host
used in rendered SnackbarHost
. But what if we need to access it deep
within the UI tree? Or if we want to display a confirmation message with an undo action from a ViewModel
?
Show me the code! #
I’m going to explain the implementation in details, but if you want to dive into the code already, it’s available at GitHub gist.
Breaking it down #
Let’s start by implementing necessary components to achieve our end goal
Snackbar action #
A Snackbar can also include an action alongside the message. Let’s define a data class
to represent this structure:
data class SnackbarAction(val title: String, val onActionPress: () -> Unit)
The controller #
The core functionality for displaying Snackbars will be handled by our SnackbarController
class. This class needs a
SnackbarHostState
to enqueue Snackbars and a CoroutineScope
to manage them, as the showSnackbar
method on
SnackbarHostState
is a suspendable function.
The SnackbarController
will provide a single method called showMessage
, which launches a coroutine to display the
Snackbar:
@Immutable
class SnackbarController(
private val host: SnackbarHostState,
private val scope: CoroutineScope,
) {
fun showMessage(
message: String,
action: SnackbarAction? = null,
duration: SnackbarDuration = SnackbarDuration.Short,
) {
scope.launch {
/**
* note: uncomment this line if you want snackbar to be displayed immediately,
* rather than being enqueued and waiting [duration] * current_queue_size
*/
// host.currentSnackbarData?.dismiss()
val result =
host.showSnackbar(
message = message,
actionLabel = action?.title,
duration = duration
)
if (result == SnackbarResult.ActionPerformed) {
action?.onActionPress?.invoke()
}
}
}
}
Accessing SnackbarController within the Composable tree #
To access the SnackbarController
throughout Composable tree, we will
use CompositionLocal
. This approach avoids the
need for prop drilling, where the same arguments must be passed down through multiple composable layers to reach
their intended destination.
Let’s start by defining a custom CompositionLocal
:
val LocalSnackbarController = staticCompositionLocalOf {
SnackbarController(
host = SnackbarHostState(),
scope = CoroutineScope(EmptyCoroutineContext)
)
}
Next, create a SnackbarControllerProvider
composable, which supplies a SnackbarController
via
CompositionLocal
. The composable accepts a single argument,@Composable content
which will
receive SnackbarHostState
as its argument.
@Composable
fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) {
val snackHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val snackController = remember(scope) {
SnackbarController(snackHostState, scope)
}
CompositionLocalProvider(LocalSnackbarController provides snackController) {
content(
snackHostState
)
}
}
Finally, let’s improve the developer experience by adding a property to SnackbarController
to access its instance in
composition in familiar way via SnackbarController.current
:
@Immutable
class SnackbarController(
private val host: SnackbarHostState,
private val scope: CoroutineScope,
) {
companion object {
val current
@Composable
@ReadOnlyComposable
get() = LocalSnackbarController.current
}
// omitted for brevity
}
Connecting it all together #
It’s time to put the pieces together. We do so by placing SnackbarControllerProvider
above Scaffold
in view
hierarchy, so that we can pass SnackbarHostState
instance to it:
// assuming this is top of composable tree
@Composable
fun App() {
SnackbarControllerProvider { host ->
Scaffold(snackbarHost = { SnackbarHost(hostState = host) }) {
// rest of content
}
}
}
Usage within Compose context #
With our controller available via SnackbarControllerProvider
, we can access it from anywhere within the Composable
tree:
@Composable
fun MyContent() {
val controller = SnackbarController.current
Button(onClick = {
controller.showMessage("World!")
}) {
Text("Hello")
}
}
Usage outside Compose context #
To display a Snackbar from outside the Compose hierarchy, we need a way to communicate
with SnackbarController
within it. One way is to use
Kotlin’s Channels
to send a message
to the SnackbarController
to trigger the Snackbar display.
First, create a channel with unlimited capacity
data class SnackbarChannelMessage(
val message: String,
val action: SnackbarAction?,
val duration: SnackbarDuration = SnackbarDuration.Short,
)
val channel = Channel<SnackbarChannelMessage>(capacity = Int.MAX_VALUE)
Next, we need to listen for incoming messages to the channel. We can achieve this in our SnackbarControllerProvider
by
using DisposableEffect
@Composable
fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) {
val snackHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val snackController = remember(scope) { SnackbarController(snackHostState, scope) }
DisposableEffect(snackController, scope) {
val job = scope.launch {
for (payload in channel) {
snackController.showMessage(
message = payload.message,
duration = payload.duration,
action = payload.action
)
}
}
onDispose {
job.cancel()
}
}
// omitted for brevity
}
Finally, add a function to SnackbarController
’s companion object to send message to the channel:
@Immutable
class SnackbarController(
private val host: SnackbarHostState,
private val scope: CoroutineScope,
) {
companion object {
// omitted for brevity
fun showMessage(
message: String,
action: SnackbarAction? = null,
duration: SnackbarDuration = SnackbarDuration.Short,
) {
channel.trySend(
SnackbarChannelMessage(
message = message,
duration = duration,
action = action
)
)
}
}
// omitted for brevity
}
With the setup complete, we can now show a Snackbar from outside Compose tree, such as ViewModel:
// method on ViewModel
fun deleteItem(itemId: Long) {
repo.deleteItem(itemId)
val action = SnackbarAction(
title = "Undo",
onActionPress = { repo.undoDelete(itemId) }
)
SnackbarController.showMessage(message = "Item deleted", action = action)
}
Conclusion #
I hope you found this article useful in simplifying the use of the Snackbar. The implementation can be extended further, but I think it’s good for now.
Happy coding!