About a new way to implement one-time events in Jetpack Compose using the Compose State Events Library
State and event handling is as essential as building the actual layout when implementing Android apps not only with Jetpack Compose but in general.
In most apps, one will come to a point where we need to implement one-time or “one-off” events. Those events are actions that should get executed only once by the UI. An example of such an event is the response to a successful API call which can get visualized by presenting a Snackbar for instance.
In the legacy way of building our UI with XML layouts, we often used the legendary SingleLiveEvent
which arose from a Model-View-ViewModel (MVVM) sample project and got used in many projects for executing such one-time events using LiveData
.
With the rise of Kotlin’s hot flow classes, the SharedFlow often got introduced as a replacement for the SingleLiveEvent
.
However, with the declarative UI approach of Jetpack Compose, things changed. For example, as
Not only in that talk but also in another article about “ViewModel: One-off event antipatterns” he also states that these one-time events should also get represented through that view state.
Furthermore, the view model itself should not be responsible for determining if the event has been handled. If such a one-time event is called and the UI is inactive, the event could be lost when using a SharedFlow
with no replay value or SingleLiveEvent
.
By holding the event in the view state as long as the UI doesn’t tell the view model that the event got consumed, we can avoid that problem.
The implementation of one-time events with this approach, however, not only leads to a lot of standard code but also makes it difficult to determine which variables from the view state should be one-time events that need to be consumed or just simple states.
A new approach to facilitate this process and make our view states a lot more maintainable is the Compose State Events library by my colleague Leonard Palm.
In this article, we will take a look at a practical example. We will see how we can use this library to implement our one-time events in a way that is orientated to the opinionated architecture guidelines proposed by the Now in Android team.
Preparing the example UI
Let’s talk about a quick example. We have a simple user interface with two buttons. Each of them triggers a dummy process in our view model, which at the end triggers a one-time event that implies the process is finished.
The event is then shown in a form of a Snackbar
by the UI layer.
To get a better impression, the visualized app flow can be seen in the .gif below:
Compose State Events test app flow
The content composable code for the screen can be found in the code snippet below:
class AntiPatternViewModel : ViewModel() { private val _viewState = MutableStateFlow(MainViewState()) val viewState = _viewState.asStateFlow() private val _onProcessSuccessWithTimestamp = MutableSharedFlow<String>() val onProcessSuccessWithTimestamp = _onProcessSuccessWithTimestamp.asSharedFlow() private val _onProcessSuccess = MutableSharedFlow<Unit>() val onProcessSuccess = _onProcessSuccess.asSharedFlow() fun startProcess(useTimestamp: Boolean) { viewModelScope.launch { _viewState.update { currentState -> currentState.copy(isLoading = true) } delay(3_000) _viewState.update { currentState -> currentState.copy(isLoading = false) } if (useTimestamp) { _onProcessSuccessWithTimestamp.emit(SimpleDateFormat.getTimeInstance().format(Date())) } else { _onProcessSuccess.emit(Unit) } } } }
As you can see it’s just a nested Column
layout that holds a TopAppBar
, two buttons and a CircularProgressIndicator
that dynamically gets shown when the loading process is running.
We call this MainContent
composable by using state hoisting. The code for the calling composable looks like the following:
@Composable private fun MainScreen(viewModel: MainViewModel = viewModel()) { val viewState: MainViewState by viewModel.viewState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } .. Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { innerPadding: PaddingValues -> MainContent( modifier = Modifier .fillMaxSize() .padding(innerPadding), onStartProcessWithoutTimestamp = { viewModel.startProcess(useTimestamp = false) }, onStartProcessWithTimestamp = { viewModel.startProcess(useTimestamp = true) }, isLoading = viewState.isLoading ) } }
We will get to the one-time event state handling in a second. The main takeaway from this code snippet is the collection of the MainViewState
held by the MainViewModel
.
Additionally to mention is that we wrap the MainContent
in a Scaffold
to introduce a SnackbarHost
that will be later used to invoke the Snackbar
on each of the one-time events.
One-Time-Event Anti-Pattern
Before we come to the Compose State Events way of implementing one-time events, let’s have a look at what the anti-pattern looks like that Manuel Vivo talked about in the article mentioned at the beginning.
As explained in the introduction, the anti-pattern is to call your unique events without ensuring that they are actually consumed, which can lead to unexpected behavior.
To showcase this, let’s take a look at an implementation for our screen with that anti-pattern:
class AntiPatternViewModel : ViewModel() { private val _viewState = MutableStateFlow(MainViewState()) val viewState = _viewState.asStateFlow() private val _onProcessSuccessWithTimestamp = MutableSharedFlow<String>() val onProcessSuccessWithTimestamp = _onProcessSuccessWithTimestamp.asSharedFlow() private val _onProcessSuccess = MutableSharedFlow<Unit>() val onProcessSuccess = _onProcessSuccess.asSharedFlow() fun startProcess(useTimestamp: Boolean) { viewModelScope.launch { _viewState.update { currentState -> currentState.copy(isLoading = true) } delay(3_000) _viewState.update { currentState -> currentState.copy(isLoading = false) } if (useTimestamp) { _onProcessSuccessWithTimestamp.emit(SimpleDateFormat.getTimeInstance().format(Date())) } else { _onProcessSuccess.emit(Unit) } } } }
As you can see, for calling the one-time events we make use of SharedFlow
. We have two separate flows. One for the process finished without and one for the case with a timestamp.
Of course, you could merge both into one, but for the sake of clarity we leave that away for now.
The startProcess(..)
function is called from each of our button callbacks with the respective input parameter for either delivering a timestamp on process finish or not.
To consume these events, we extend the previously shown MainScreen
composable with a new LaunchedEffect
that on the other hand collects both of our SharedFlow
streams in a repeatOnLifecycle(..)
body.
@Composable private fun MainScreen(viewModel: AntiPatternViewModel = viewModel()) { val viewState: MainViewState by viewModel.viewState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val lifecycle = LocalLifecycleOwner.current.lifecycle LaunchedEffect(key1 = Unit) { lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { launch { viewModel.onProcessSuccess.collectLatest { snackbarHostState.showSnackbar("Event success") } } launch { viewModel.onProcessSuccessWithTimestamp.collectLatest { timestamp: String -> snackbarHostState.showSnackbar("Event success at: $timestamp") } } } } .. }
Job Offers
The respective Snackbar
is then shown by calling the SnackbarHostState
which acts as input for the previously shown Scaffold
that wraps the MainContent
.
Using Compose State Events
Now that we saw the anti-pattern, let’s take a look at how we can implement the suggested way by using the Compose State Events library.
Setup
To use the library make sure to include the following dependency in your app-level build.gradle
file. At the time of writing the library, version is at 1.1.0
:
dependencies { .. implementation 'com.github.leonard-palm:compose-state-events:1.1.0' }
Compose State Events
The purpose of the library is essential to facilitate the process of moving these one-time events away from their own streams into the view state of the respective ViewModel
class.
Therefore the library introduces two new classes.
StateEvent
: Can be used for simple one-time event state representations, in our sample use case if the process has finished without further state implications.StateEventWithContent<T>
: Can be used for one-time event state representations in which you need to pass a result object. In our sample use case that is if the process has finished and we want to know the timestamp.
Each of the classes can be in either the consumed
or triggered
state.
By using the approach of integrating one-time events into your view state object, you always have the case of the state representation and telling the view model that it got consumed.
Because in Jetpack Compose you will handle this case with the LaunchedEffect
, the library comes with a handy wrapper called EventEffect
that automatically handles this process.
It only requires the following two input parameters:
event
: TheStateEvent
orStateEventWithContent
reference from your respective view state.onConsumed
: The function reference the view model function that will mark the event as consumed.
The implementation
Now that you heard about the basics of the library, let’s take a look at how we can migrate the anti-pattern example to use the Compose State Events library.
As a first step, let’s extend the MainViewState
that currently only holds the loading state with the Compose State Events objects for both of our one-time events.
import de.palm.composestateevents.StateEvent import de.palm.composestateevents.StateEventWithContent import de.palm.composestateevents.consumed data class MainViewState( val isLoading: Boolean = false, val processSuccessEvent: StateEvent = consumed, val processSuccessWithTimestampEvent: StateEventWithContent<String> = consumed(), )
Now that we updated the MainViewState
, let’s migrate the ViewModel
class accordingly:
class MainViewModel : ViewModel() { private val _viewState = MutableStateFlow(MainViewState()) val viewState = _viewState.asStateFlow() fun startProcess(useTimestamp: Boolean) { viewModelScope.launch { _viewState.update { currentState -> currentState.copy(isLoading = true) } delay(3_000) if (useTimestamp) { _viewState.update { currentState -> currentState.copy( messageWithTimestampEvent = triggered(SimpleDateFormat.getTimeInstance().format(Date())), isLoading = false ) } } else { _viewState.update { currentState -> currentState.copy( messageEvent = triggered, isLoading = false ) } } } } fun setShowMessageConsumed() { _viewState.update { currentState -> currentState.copy( messageEvent = consumed, messageWithTimestampEvent = consumed() ) } } }
Instead of emitting standalone SharedFlow
streams individually, we now use the MainViewState
wrapped in the StateFlow
not only to update view states but also to call our one-time events.
To set them in the “invoked” state, we set the StateEvent
objects to the triggered
state.
We also introduced a new function that sets our StateEvent
functions back to the consumed
value.
In a real-world example, you would want to use two individual functions for setting this consumption state.
Now let’s see how we can adapt the anti-pattern version of the MainScreen
to consume our StateEvents
.
@Composable private fun MainScreen(viewModel: MainViewModel = viewModel()) { val viewState: MainViewState by viewModel.viewState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } EventEffect( event = viewState.processSuccessEvent, onConsumed = viewModel::setShowMessageConsumed ) { snackbarHostState.showSnackbar("Event success") } EventEffect( event = viewState.processSuccessWithTimestampEvent, onConsumed = viewModel::setShowMessageConsumed ) { timestamp: String -> snackbarHostState.showSnackbar("Event success at: $timestamp") } .. }
Instead of using a LaunchedEffect
and collect from individual SharedFlow
streams, we now introduce the previously discussed EventEffect
that comes with the Compose State Events library.
The first EventEffect
overload takes the one-time event version without timestamp and the second one with a timestamp as content. As you can see in the body of the EventEffect
you can directly access the content of the StateEventContent
for further processing.
In this case, we include it in the Snackbar
message.
You can also check out the code snippets from the Gists in a sample GitHub repository:
Conclusion
In this article, we have picked up the opinionated Android architecture from the Now in Android team. Especially we took a look at the suggested why of implementing one-time one-off events when using Jetpack Compose as your UI system.
We have discussed the presented antipatterns for implementing one-time events and afterward took a look at the Compose State Eventy library that provides a handy solution for implementing the suggested way by handling these events via the view models view state object.
After reading this article the question may arise “Why not just use the conventional way without using the Compose State Events library?”.
Using the Compose State Events library not only makes it easier to process one-off events but also makes it clear directly in our respective ViewState which events should be consumed by the UI layer and which should only be represented. One could argue that this should be left entirely to the view layer, but the practice has shown that view-state data classes can quickly grow large and thus just as quickly become unwieldy when it comes to remembering which events are only meant to be one-time events and which are only meant to represent the state.
In conclusion, I can only encourage you to try out the library for yourself.
I hope you had some takeaways, clap if you liked my article, make sure to subscribe to get notified via e-mail, and follow for more!
This article was originally published on proandroiddev.com on October 09, 2022