Introduction
A few days ago I received the task — display the Snackbar in case of the API call is finished with the error, like InternalServiceError, BadGateway or NotFound.
As I didn’t work with the Snackbar before in Jetpack Compose, I started to search the best practices of its implementation. The most helpful tutorial was this as it fully described how to show a Snackbar.
Now it’s time to describe the architecture of the app.
UI-side arch looks like this:
- I use single activity architecture (since it is Compose it’s easy to follow this principle)
- My Compose app looks like this
fun FitnestApp() { | |
val navController = rememberAnimatedNavController(AnimatedComposeNavigator()) | |
FitnestTheme { | |
Scaffold( | |
bottomBar = { BottomBar(navController) }, | |
topBar = { TopBar(navController) }, | |
) { | |
AnimatedNavHost( | |
navController = navController, | |
startDestination = Route.Splash.screenName | |
) { | |
composable(route = Route.Splash.screenName) { | |
SplashScreen(navController = navController) | |
} | |
... | |
} | |
} | |
} | |
} |
- Screens are displayed in NavHost, here’s an example of the screen, which can receive the server error
@Composable | |
fun SplashScreen(navController: NavController) { | |
... | |
LaunchedEffect(key1 = null) { | |
launch { | |
viewModel.failureSharedFlow.collect { | |
// TODO - show snackbar with error message | |
} | |
} | |
} | |
Box { | |
... | |
} | |
} |
As you can see, the error which I should show to the user is coming from the ViewModel via SharedFlow.
Implementation
To show the Snackbar we should set the ScaffoldState to the Scaffold. ScaffoldState contains 2 fields — drawerState (not interesting for us now) and snackbarHostState. As it is said in documentation:
SnackbarHostState — State of the SnackbarHost, controls the queue and the current Snackbar being shown inside the SnackbarHost.
In code it looks like this:
@Composable | |
fun SnackbarDemo() { | |
val scaffoldState: ScaffoldState = rememberScaffoldState() | |
Scaffold(scaffoldState = scaffoldState) { | |
Button(onClick = { | |
scaffoldState.snackbarHostState.showSnackbar( | |
message = "This is your message", | |
actionLabel = "Do something" | |
) | |
}) { | |
Text(text = "Click me!") | |
} | |
} | |
} |
And now we face the first problem — our screen (i.e. SplashScreen) doesn’t know anything about Scaffold’s state, since the Scaffold is placed in the root of the App, not at the screen level.
First solution, which came into my mind is to pass the state as a param to all Composable methods which should handle the exception. But this solution doesn’t seem to be perfect, because we have to pass state to almost all composables and sometimes we need to pass it not because our function need it, but because some inner function need it.
The second solution was to define a global variable, which will keep the state, and composables, which need to show a Snackbar will access it. I used this approach, but a bit modified it.
Here its code:
internal class SnackbarDelegate( | |
var snackbarHostState: SnackbarHostState? = null, | |
var coroutineScope: CoroutineScope? = null | |
) { | |
fun showSnackbar( | |
message: String, | |
actionLabel: String? = null, | |
duration: SnackbarDuration = SnackbarDuration.Short | |
) { | |
coroutineScope?.launch { | |
snackbarHostState?.showSnackbar(message, actionLabel, duration) | |
} | |
} | |
} |
As you can see, SnackbarDelegate is just a wrapper over SnackbarHostState and CoroutineScope (we need it to show a Snackbar, because showSnackbar is a suspend function). Object of this class is registered as singleton in DI container, so all the classes and methods, which will have an access to this object will work with the same SnackbarHostState (the state of the SnackbarHost of the root Scaffold. You can see it in the code snippet below).
fun FitnestApp() { | |
val snackbarDelegate: SnackbarDelegate by rememberInstance() | |
val scaffoldState = rememberScaffoldState() | |
snackbarDelegate.apply { | |
snackbarHostState = scaffoldState.snackbarHostState | |
coroutineScope = rememberCoroutineScope() | |
} | |
FitnestTheme { | |
Scaffold( | |
scaffoldState = scaffoldState, | |
) { | |
} | |
} | |
} |
Job Offers
And now we face the next problem — different states of Snackbar. For example, in my App there are 2 states — Error and Default Snackbar. Error Snackbar should have Red background, while Default snackbar should have a Blue color. Scaffold provides us an option to customise a snackbar by a snackbarHost param. It allows us to display any Composable as a Snackbar, but it doesn’t know anything about our internal Snackbar states. I solved this problem with the help of SnackbarDelegate. Here’s it modified version.
enum class SnackbarState { | |
DEFAULT, | |
ERROR | |
} | |
internal class SnackbarDelegate( | |
var snackbarHostState: SnackbarHostState? = null, | |
var coroutineScope: CoroutineScope? = null | |
) { | |
private var snackbarState: SnackbarState = SnackbarState.DEFAULT | |
val snackbarBackgroundColor: Color | |
@Composable | |
get() = when (snackbarState) { | |
SnackbarState.DEFAULT -> SnackbarDefaults.backgroundColor | |
SnackbarState.ERROR -> ErrorColor | |
} | |
fun showSnackbar( | |
state: SnackbarState, | |
message: String, | |
actionLabel: String? = null, | |
duration: SnackbarDuration = SnackbarDuration.Short | |
) { | |
this.snackbarState = state | |
coroutineScope?.launch { | |
snackbarHostState?.showSnackbar(message, actionLabel, duration) | |
} | |
} | |
} |
There’re several differences between this and previous version:
- I defined a variable, which will keep current SnackbarState (my App’s internal state)
- Before the showing of Snackbar I save this state (line 26)
- Provided a getter for a Snackbar’s background.
And that’s the way how it is used in the App’s Composable
fun FitnestApp() { | |
val snackbarDelegate: SnackbarDelegate by rememberInstance() | |
val scaffoldState = rememberScaffoldState() | |
snackbarDelegate.apply { | |
snackbarHostState = scaffoldState.snackbarHostState | |
coroutineScope = rememberCoroutineScope() | |
} | |
FitnestTheme { | |
Scaffold( | |
scaffoldState = scaffoldState, | |
snackbarHost = { | |
SnackbarHost(hostState = it) { | |
val backgroundColor = snackbarDelegate.snackbarBackgroundColor | |
Snackbar(snackbarData = it, backgroundColor = backgroundColor) | |
} | |
} | |
) | |
} | |
} |
To use the delegate you should inject the delegate to the composable and call showSnackbar method. Here’s an example:
fun SplashScreen(navController: NavController) { | |
val viewModelFactory: ViewModelProvider.Factory by rememberInstance() | |
val errorHandlerDelegate: ErrorHandlerDelegate by rememberInstance() | |
val viewModel = viewModel( | |
factory = viewModelFactory, | |
modelClass = SplashViewModel::class.java | |
) | |
LaunchedEffect(key1 = null) { | |
launch { | |
viewModel.failureSharedFlow.collect(errorHandlerDelegate::defaultHandleFailure) | |
} | |
} | |
Box {} | |
} | |
internal class ErrorHandlerDelegate( | |
private val context: Context, | |
private val snackbarService: SnackbarDelegate | |
) { | |
fun defaultHandleFailure(failure: Failure) { | |
when (failure) { | |
is Failure.ServerError -> snackbarService.showSnackbar( | |
SnackbarState.ERROR, | |
context.getString(R.string.error_server_error) | |
) | |
else -> {} | |
} | |
} | |
} | |
In my case I added a mediator — ErrorHandlerDelegate. It allows me to handle errors in the simple way all over the application.
This solution is scalable, so if I will need to specify some other params in Snackbar, I can put them to the delegate class and will handle their logic inside the class.
And that’s it!
Conclusion
In this article I described a solution, which allows to work with the Snackbar in the big Jetpack Compose app and handle its different states.
You can find the source code at the Github.
Thank you for reading! Feel free to ask questions and leave the feedback in comments or Linkedin.
This article was originally published on proandroiddev.com on October 9, 2022