Skip to main content

Hassle-free Snackbar in Jetpack Compose

·1006 words·5 mins
kotlin jetpack compose kotlin multiplatform compose multiplatform snackbar
Table of Contents

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 the SnackbarHost
  • A SnackbarHost which is responsible for the UI presentation of the Snackbar
  • A SnackbarHostState that the SnackbarHost 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!