Blog Infos
Author
Published
Topics
,
Published
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

Manuel Vivo suggests in his talk at Droidcon Berlin 2022 on “Implementing Modern Android Architecture”, it’s a good idea to keep the UI state inside a data class within the view model, which is then presented to the UI layer.

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

Job Offers


    Senior Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

    Android Software Engineer (f/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

, ,

Testing: how hard can it be?

When people start looking into the testing domain, very similar questions arise: What to test? And more important, what not? What should I mock? What should I test with unit tests and what with Instrumentation?…
Watch Video

Testing: how hard can it be?

DANNY PREUSSLER
Android Lead
Soundcloud

Testing: how hard can it be?

DANNY PREUSSLER
Android Lead
Soundcloud

Testing: how hard can it be?

DANNY PREUSSLER
Android Lead
Soundcloud

Jobs

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: The StateEvent or StateEventWithContent 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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
Yes! You heard it right. We’ll try to understand the complete OTP (one time…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu