Advanced State and Side Effects in Jetpack Compose

1. Introduction

In this codelab you will learn advanced concepts related to State and Side Effects APIs in Jetpack Compose. We'll see how to create a state holder for stateful composables whose logic isn't trivial, how to create coroutines and call suspend functions from Compose code, and how to trigger side effects to accomplish different use cases.

What you'll learn

What you'll need

What you'll build

In this codelab we'll start from an unfinished application, the Crane material study app, and we'll add features to improve the app.

1fb85e2ed0b8b592.gif

2. Getting set up

Get the code

The code for this codelab can be found in the android-compose-codelabs Github repository. To clone it, run:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

Alternatively, you can download the repository as a zip file:

Download Zip

Check out the sample app

The code you just downloaded contains code for all Compose codelabs available. To complete this codelab, open the AdvancedStateAndSideEffectsCodelab project inside Android Studio Arctic Fox.

We recommend that you start with the code in the main branch and follow the codelab step-by-step at your own pace.

During the codelab, you'll be presented with snippets of code that you'll need to add to the project. In some places, you'll also need to remove code that is explicitly mentioned in comments on the code snippets.

Getting familiar with the code and running the sample app

Take a moment to explore the project structure and run the app.

37d39b9ac4a9d2fa.png

When you run the app from the main branch, you'll see that some functionality such as the drawer, or loading flight destinations doesn't work! That's what we'll be doing in the next steps of the codelab.

1fb85e2ed0b8b592.gif

UI tests

The app is covered with very basic UI tests available in the androidTest folder. They should pass for both the main and end branches at all times.

[Optional] Displaying the map on the details screen

Displaying the map of the city on the details screen is not necessary at all to follow along. However, if you want to see it, you need to get a personal API key as the Maps documentation says. Include that key in the local.properties file as follows:

// local.properties file
google.maps.key={insert_your_api_key_here}

Solution to the codelab

To get the end branch using git, use this command:

$ git clone -b end https://github.com/googlecodelabs/android-compose-codelabs

Alternatively, you can download the solution code from here:

Download the final code

Frequently asked questions

3. Consuming a Flow from the ViewModel

As you might've noticed when running the app from the main branch, the list of flight destinations is empty! To see what's happening, open the home/CraneHome.kt file and look at the CraneHomeContent composable.

There's a TODO comment above the definition of suggestedDestinations which is assigned to a remembered empty list. This is what's showing on the screen: an empty list! In this step, we'll fix that and show the suggested destinations that the MainViewModel exposes.

9cadb1fd5f4ced3c.png

Open home/MainViewModel.kt and take a look at the suggestedDestinations StateFlow that is initialized to destinationsRepository.destinations, and gets updated when the updatePeople or toDestinationChanged functions get called.

We want our UI in the CraneHomeContent composable to update whenever there's a new item emitted into the suggestedDestinations stream of data. We can use the StateFlow.collectAsState() function. When used in a composable function, collectAsState() collects values from the StateFlow and represents the latest value via Compose's State API. This will make the Compose code that reads that state value recompose on new emissions.

Go back to the CraneHomeContent composable and replace the line that assigns suggestedDestinations with a call to collectAsState on the ViewModel's suggestedDestinations property:

import androidx.compose.runtime.collectAsState

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsState()
    // ...
}

If you run the app, you'll see that the list of destinations are populated, and that they change whenever you tap the number of people travelling.

4ec666a2d1ac0903.gif

4. LaunchedEffect and rememberUpdatedState

In the project, there's a home/LandingScreen.kt file that's not used at the moment. We want to add a landing screen to the app, which potentially could be used to load all the data needed in the background.

The landing screen will occupy the whole screen and show the app's logo in the middle of the screen. Ideally, we'd show the screen and—after all the data's been loaded—we'd notify the caller that the landing screen can be dismissed using the onTimeout callback.

Kotlin coroutines are the recommended way to perform asynchronous operations in Android. An app would usually use coroutines to load things in the background when it starts. Jetpack Compose offers APIs that make using coroutines safe within the UI layer. As this app doesn't communicate with a backend, we'll use the coroutines' delay function to simulate loading things in the background.

A side-effect in Compose is a change to the state of the app that happens outside the scope of a composable function. Changing the state to show/hide the landing screen will happen in the onTimeout callback and since before calling onTimeout we need to load things using coroutines, the state change needs to happen in the context of a coroutine!

To call suspend functions safely from inside a composable, use the LaunchedEffect API, which triggers a coroutine-scoped side-effect in Compose.

When LaunchedEffect enters the Composition, it launches a coroutine with the block of code passed as a parameter. The coroutine will be cancelled if LaunchedEffect leaves the composition.

Although the next code is not correct, let's see how to use this API and discuss why the following code is wrong. We'll be calling the LandingScreen composable later in this step.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Some side-effect APIs like LaunchedEffect take a variable number of keys as a parameter that are used to restart the effect whenever one of those keys changes. Have you spotted the error? We don't want to restart the effect if onTimeout changes!

To trigger the side-effect only once during the lifecycle of this composable, use a constant as a key, for example LaunchedEffect(true) { ... }. However, we're not protecting against changes to onTimeout now!

If onTimeout changes while the side-effect is in progress, there's no guarantee that the last onTimeout is called when the effect finishes. To guarantee this by capturing and updating to the new value, use the rememberUpdatedState API:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes, 
        // the delay shouldn't start again.
        LaunchedEffect(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Showing the landing screen

Now we need to show the landing screen when the app is opened. Open the home/MainActivity.kt file and check out the MainScreen composable that's first called.

In the MainScreen composable, we can simply add an internal state that tracks whether the landing should be shown or not:

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

If you run the app now, you should see the LandingScreen appearing and disappearing after 2 seconds.

fda616dda280aa3e.gif

5. rememberCoroutineScope

In this step, we'll make the navigation drawer work. Currently, nothing happens if you try to tap the hamburger menu.

Open the home/CraneHome.kt file and check out the CraneHome composable to see where we need to open the navigation drawer: in the openDrawer callback!

In CraneHome, we have a scaffoldState that contains a DrawerState. DrawerState has methods to open and close the navigation drawer programmatically. However, if you attempt to write scaffoldState.drawerState.open() in the openDrawer callback, you'll get an error! That's because the open function is a suspend function. We're in the realm of coroutines again.

Apart from APIs to make calling coroutines safe from the UI layer, some Compose APIs are suspend functions. One example of this is the API to open the navigation drawer. Suspend functions, in addition to being able to run asynchronous code, also help represent concepts that happen over time. As opening the drawer requires some time, movement, and potential animations, that's perfectly reflected with the suspend function, which will suspend the execution of the coroutine where it's been called until it finishes and resumes execution.

scaffoldState.drawerState.open() must be called within a coroutine. What can we do? openDrawer is a simple callback function, therefore:

  • We cannot simply call suspend functions in it because openDrawer is not executed in the context of a coroutine.
  • We cannot use LaunchedEffect as before because we cannot call composables in openDrawer. We're not in the Composition.

We want to be able to launch a coroutine which scope should we use? Ideally, we'd want a CoroutineScope that follows the lifecycle of its call-site. To do this, use the rememberCoroutineScope API. The scope will be automatically cancelled once it leaves the Composition. With that scope, you can start coroutines when you're not in the Composition, for example, in the openDrawer callback.

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

If you run the app, you'll see that the navigation drawer opens when you tap the hamburger menu icon.

ad44883754b14efe.gif

LaunchedEffect vs rememberCoroutineScope

Using LaunchedEffect in this case wasn't possible because we needed to trigger the call to create a coroutine in a regular callback that was outside of the Composition.

Looking back at the landing screen step that used LaunchedEffect, could you use rememberCoroutineScope and call scope.launch { delay(); onTimeout(); } instead of using LaunchedEffect?

You could've done that, and it would've seemed to work, but it wouldn't be correct. As explained in the Thinking in Compose documentation, composables can be called by Compose at any moment. LaunchedEffect guarantees that the side-effect will be executed when the call to that composable makes it into the Composition. If you use rememberCoroutineScope and scope.launch in the body of the LandingScreen, the coroutine will be executed every time LandingScreen is called by Compose regardless of whether that call makes it into the Composition or not. Therefore, you'll waste resources and you won't be executing this side-effect in a controlled environment.

6. Creating a state holder

Have you noticed that if you tap Choose Destination you can edit the field and filter cities based on your search input? You also probably noticed that whenever you modify Choose Destination, the text style changes.

99dec71d23aef084.gif

Open the base/EditableUserInput.kt file. The CraneEditableUserInput stateful composable takes some parameters such as the hint and a caption which corresponds to the optional text next to the icon. For example, the caption To appears when you search for a destination.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

Why?

The logic to update the textState and determine whether what's been displayed corresponds to the hint or not is all in the body of the CraneEditableUserInput composable. This brings some downsides with it:

  • The value of the TextField is not hoisted and therefore cannot be controlled from outside, making testing harder.
  • The logic of this composable could become more complex and the internal state could be out of sync more easily.

By creating a state holder responsible for the internal state of this composable, you can centralize all state changes in one place. With this, it's more difficult for the state to be out of sync, and the related logic is all grouped together in a single class. Furthermore, this state can be easily hoisted up and can be consumed from callers of this composable.

In this case, hoisting the state is a good idea since this is a low-level UI component that might be reused in other parts of the app. Therefore, the more flexible and controllable it is, the better.

Creating the state holder

As CraneEditableUserInput is a reusable component, let's create a regular class as state holder named EditableUserInputState in the same file that looks like the following:

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint
}

The class should have the following traits:

  • text is a mutable state of type String, just as we have in CraneEditableUserInput. It's important to use mutableStateOf so that Compose tracks changes to the value and recomposes when changes happen.
  • text is a var, which makes it possible to be directly mutated from outside the class.
  • The class takes an initialText as a dependency that is used to initialize text.
  • The logic to know if the text is the hint or not is in the isHint property that performs the check on-demand.

If the logic gets more complex in the future, we only need to make changes to one class: EditableUserInputState.

Remembering the state holder

State holders always need to be remembered in order to keep them in the Composition and not create a new one every time. It's a good practice to create a method in the same file that does this to remove boilerplate and avoid any mistakes that might occur. In the base/EditableUserInput.kt file, add this code:

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

If we only remember this state, it won't survive activity recreations. To achieve that, we can use the rememberSaveable API instead which behaves similarly to remember, but the stored value also survives activity and process recreation. Internally, it uses the saved instance state mechanism.

rememberSaveable does all this with no extra work for objects that can be stored inside a Bundle. That's not the case for the EditableUserInputState class that we created in our project. Therefore, we need to tell rememberSaveable how to save and restore an instance of this class using a Saver.

Creating a custom saver

A Saver describes how an object can be converted into something which is Saveable. Implementations of a Saver need to override two functions:

  • save to convert the original value to a saveable one.
  • restore to convert the restored value to an instance of the original class.

For our case, instead of creating a custom implementation of Saver for the EditableUserInputState class, we can use some of the existing Compose APIs such as listSaver or mapSaver (that stores the values to save in a List or Map) to reduce the amount of code that we need to write.

It's a good practice to place Saver definitions close to the class they work with. Because it needs to be statically accessed, let's add the Saver for EditableUserInputState in a companion object. In the base/EditableUserInput.kt file, add the implementation of the Saver:

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

In this case, we use a listSaver as an implementation detail to store and restore an instance of EditableUserInputState in the saver.

Now, we can use this saver in rememberSaveable (instead of remember) in the rememberEditableUserInputState method we created before:

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

With this, the EditableUserInput remembered state will survive process and activity recreations.

Using the state holder

We're going to use EditableUserInputState instead of text and isHint, but we don't want to just use it as an internal state in CraneEditableUserInput as there's no way for the caller composable to control the state. Instead, we want to hoist EditableUserInputState so that callers can control the state of CraneEditableUserInput. If we hoist the state, then the composable can be used in previews and be tested more easily since you're able to modify its state from the caller.

To do this, we need to change the parameters of the composable function and give it a default value in case it is needed. Because we might want to allow CraneEditableUserInput with empty hints, we add a default argument:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

You've probably noticed that the onInputChanged parameter is not there anymore! Since the state can be hoisted, if callers want to know if the input changed, they can control the state and pass that state into this function.

Next, we need to tweak the function body to use the hoisted state instead of the internal state that was used before. After the refactoring, the function should look like this:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.text = it },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

State holder callers

Since we changed the API of CraneEditableUserInput, we need to check in all places where it's called to make sure we pass in the appropriate parameters.

The only place in the project that we call this API is in the home/SearchUserInput.kt file. Open it and go to the ToDestinationUserInput composable function; you should see a build error there. As the hint is now part of the state holder, and we want a custom hint for this instance of CraneEditableUserInput in the Composition, we need to remember the state at the ToDestinationUserInput level and pass it into CraneEditableUserInput:

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

The code above is missing functionality to notify ToDestinationUserInput's caller when the input changes. Due to how the app is structured, we don't want to hoist the EditableUserInputState any higher up in the hierarchy because we want to couple the other composables such as FlySearchContent with this state. How can we call the onToDestinationChanged lambda from ToDestinationUserInput and still keep this composable reusable?

We can trigger a side-effect using LaunchedEffect every time the input changes and call the onToDestinationChanged lambda:

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

We've already used LaunchedEffect and rememberUpdatedState before, but the code above also uses a new API! We use the snapshotFlow API to convert Compose State<T> objects into a Flow. When the state read inside snapshotFlow mutates, the Flow will emit the new value to the collector. In our case, we convert the state into a flow to use the power of flow operators. With that, we filter when the text is not the hint, and collect the emitted items to notify the parent that the current destination changed.

There are no visual changes in this step of the codelab, but we've improved the quality of this part of the code. If you run the app now, you should see everything is working as it did previously.

7. DisposableEffect

When you tap on a destination, the details screen open and you can see where the city is on the map. That code is in the details/DetailsActivity.kt file. In the CityMapView composable, we're calling the rememberMapViewWithLifecycle function. If you open this function, which is in the details/MapViewUtils.kt file, you'll see that it's not connected to any lifecycle! It just remembers a MapView and calls onCreate on it:

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

Even though the app runs fine, this is a problem because the MapView is not following the correct lifecycle. Therefore, it won't know when the app is moved to the background, when the View should be paused, etc. Let's fix this!

As the MapView is a View and not a composable, we want it to follow the lifecycle of the Activity where it's used instead of the lifecycle of the Composition. That means we need to create a LifecycleEventObserver to listen for lifecycle events and call the right methods on the MapView. Then, we need to add this observer to the current activity's lifecycle.

Let's start by creating a function that returns a LifecycleEventObserver that calls the corresponding methods in a MapView given a certain event:

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

Now, we need to add this observer to the current lifecycle, which we can get using the current LifecycleOwner with the LocalLifecycleOwner composition local. However, it's not enough to add the observer; we also need to be able to remove it! We need a side effect that tells us when the effect is leaving the Composition so that we can perform some cleanup code. The side-effect API we're looking for is DisposableEffect.

DisposableEffect is meant for side effects that need to be cleaned up after the keys change or the composable leaves the Composition. The final rememberMapViewWithLifecycle code does exactly that. Implement the following lines in your project:

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

The observer is added to the current lifecycle, and it'll be removed whenever the current lifecycle changes or this composable leaves the Composition. With the keys in DisposableEffect, if either the lifecycle or the mapView change, the observer will be removed and added again to the right lifecycle.

With the changes we've just made, the MapView will always follow the lifecycle of the current LifecycleOwner and its behavior would be just as if it was used in the View world.

Feel free to run the app and open the details screen to make sure that the MapView still renders properly. There are no visual changes in this step.

8. produceState

In this section, we're going to improve how the details screen starts. The DetailsScreen composable in the details/DetailsActivity.kt file gets the cityDetails synchronously from the ViewModel and calls DetailsContent if the result is successful.

However, cityDetails could evolve to be more costly to load on the UI thread and it could use coroutines to move the loading of the data to a different thread. Let's improve this code to add a loading screen and display the DetailsContent when the data is ready.

One way to model the state of the screen is with the following class that covers all possibilities: data to display on the screen, and the loading and error signals. Add the DetailsUiState class to the DetailsActivity.kt file:

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

We could map what the screen needs to display and the UiState in the ViewModel layer by using a stream of data, a StateFlow of type DetailsUiState, that the ViewModel updates when the information is ready and that Compose collects with the collectAsState() API that you already know about.

However, for the sake of this exercise, we're going to implement an alternative. If we wanted to move the uiState mapping logic to the Compose world, we could use the produceState API.

produceState allows you to convert non-Compose state into Compose State. It launches a coroutine scoped to the Composition that can push values into the returned State using the value property. As with LaunchedEffect, produceState also takes keys to cancel and restart the computation.

For our use case, we can use produceState to emit uiState updates with an initial value of DetailsUiState(isLoading = true) as follows:

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ... 
}

Next, depending on the uiState, we show the data, show the loading screen, or report the error. Here's the complete code for the DetailsScreen composable:

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

If you run the app, you'll see how the loading spinner appears before showing the city details.

18956feb88725ca5.gif

9. derivedStateOf

The last improvement we're going to make to Crane is showing a button to Scroll to top whenever you scroll in the flight destinations list after you pass the first element of the screen. Taping on the button takes you to the first element on the list.

59d2d10bd334bdb.gif

Open the base/ExploreSection.kt file that contains this code. The ExploreSection composable corresponds to what you see in the backdrop of the scaffold.

The solution to implement the behavior seen in the video shouldn't be a surprise to you. However, there's a new API that we haven't seen yet and it's important in this use case: the derivedStateOf API.

derivedStateOf is used when you want a Compose State that's derived from another State. Using this function guarantees that the calculation will only occur whenever one of the states used in the calculation changes.

To calculate whether the user has passed the first item using the listState is as simple as checking if listState.firstVisibleItemIndex > 0. However, firstVisibleItemIndex is wrapped in the mutableStateOf API, which makes it an observable Compose State. Our calculation also needs to be a Compose State since we want to recompose the UI to show the button!

A naive and inefficient implementation would look like the following example. Do not copy it into your project; the correct implementation will be copied into your project with the rest of the logic for the screen later:

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

A better and more efficient alternative is using the derivedStateOf API that calculates showButton only when listState.firstVisibleItemIndex changes:

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

The new code for the ExploreSection composable should be familiar to you already. See again how we make use of rememberCoroutineScope to call the listState.scrollToItem suspend function inside the Button's onClick callback. We're using a Box to place the Button that is shown conditionally on top of ExploreList:

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.google.accompanist.insets.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

If you run the app, you'll see the button appearing at the bottom once you scroll and pass the first element of the screen.

10. Congratulations!

Congratulations, you've successfully completed this codelab and learned advanced concepts of state and side-effect APIs in a Jetpack Compose app!

You learned about how to create state holders, side effect APIs such as LaunchedEffect, rememberUpdatedState, DisposableEffect, produceState, and derivedStateOf, and how to use coroutines in Jetpack Compose.

What's next?

Check out the other codelabs on the Compose pathway, and other code samples, including Crane.

Documentation

For more information and guidance about these topics, check out the following documentation: