11.
Reacting to Compose Lifecycle
Written by Prateek Prasad
In previous chapters, you focused on building the JetReddit app by adding advanced layouts and complex UI.
In this chapter, you’ll learn how to react to the lifecycle of composable functions. This approach will allow you to execute your code at specific moments while your composable is active.
Jetpack Compose offers a list of events that can trigger at specific points in the the lifecycle, called effects. Throughout this chapter, you’ll learn about the different kinds of effects and how to use them to implement your logic.
Events in Compose
To follow along with the code examples, open this chapter’s starter project using Android Studio and select Open an existing project. Navigate to 11-reacting-to-compose-lifecycle/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’re ready to go!
You might already be familiar with the project hierarchy from the previous chapters, but in case you aren’t, look at the following image:
In this chapter, you’ll only work with two of these packages:
- screens, to implement a new screen
- routing, to add a new routing option.
The rest of the packages are already prepared to handle navigation, fetching data from the database, dependency injection and theme switching for you.
Once you’re familiar with the file organization, build and run the app. You’ll see:
This is a fully implemented home screen. When you browse the app, you’ll notice that two screens are pre-built and implemented for you: My Profile, in the app drawer, and New Post, the third option in the bottom navigation.
In this chapter, you’ll implement the option to choose a community inside the New Post screen:
Implementing the Community Chooser
You’ll implement a community chooser like the one the original Reddit app uses. Look at the following image for reference:
The community chooser contains a toolbar, a search input field and a list of communities. To fetch the community list, you’ll use a ViewModel that contains pre-defined methods.
Open the ChooseCommunityScreen.kt file and look at the code. There are three composables: ChooseCommunityScreen() for the whole screen, SearchedCommunities() for the community list and ChooseCommunityTopBar(), for the pre-built top navigation bar.
Creating a List of Communities
Just like in the previous chapters, you’ll build the smaller components first, starting with SearchedCommunities(). Start by changing SearchedCommunities() code to the following:
@Composable
fun SearchedCommunities(
communities: List<String>,
viewModel: MainViewModel?,
modifier: Modifier = Modifier
) {
communities.forEach {
Community(
text = it,
modifier = modifier,
onCommunityClicked = {
viewModel?.selectedCommunity?.postValue(it)
JetRedditRouter.goBack()
}
)
}
}
In the composable parameters, you see a list of strings that represent community names, the MainViewModel to update the data and a default Modifier.
First, you iterate over the communities list and create a Community() for each element. You already made Community() in the previous chapter, so this is a perfect opportunity to reuse it.
Next, for each of the community elements, you pass its name and a modifier, then set the onCommunityClicked action. When the user clicks any of the communities, you notify the other composables about the selected value using selectedCommunity, which is stored inside the viewModel.
Finally, you close the screen after the user selects the community by calling goBack() on the JetRedditRouter.
To see the changes, add the preview code at the bottom of ChooseCommunityScreen.kt:
@Preview
@Composable
fun SearchedCommunitiesPreview() {
Column {
SearchedCommunities(defaultCommunities, null, Modifier)
}
}
Build the app and look at the preview section. You see a list of communities with three elements:
Making the Community List Searchable
The next step is to add a TextField() to search the communities according to user input. Replace ChooseCommunityScreen() with the code below:
@Composable
fun ChooseCommunityScreen(
viewModel: MainViewModel,
modifier: Modifier = Modifier,
onBackSelected: () -> Unit
) {
val scope = rememberCoroutineScope()
val communities: List<String> by viewModel.subreddits.observeAsState(emptyList())
var searchedText by remember { mutableStateOf("") }
var currentJob by remember { mutableStateOf<Job?>(null) }
val activeColor = MaterialTheme.colors.onSurface
LaunchedEffect(Unit) {
viewModel.searchCommunities(searchedText)
}
Column {
ChooseCommunityTopBar(onBackSelected = onBackSelected)
TextField(
value = searchedText,
onValueChange = {
searchedText = it
currentJob?.cancel()
currentJob = scope.async {
delay(SEARCH_DELAY_MILLIS)
viewModel.searchCommunities(searchedText)
}
},
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = stringResource(id = R.string.search)
)
},
label = { Text(stringResource(R.string.search)) },
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = activeColor,
focusedLabelColor = activeColor,
cursorColor = activeColor,
backgroundColor = MaterialTheme.colors.surface
)
)
SearchedCommunities(communities, viewModel, modifier)
}
}
Here, you first created a coroutineScope by calling rememberCoroutineScope().
Note:
rememberCoroutineScope()is aSuspendingEffect, which is a type of an effect in Compose. It creates aCoroutineScope, which is bound to the composition.CoroutineScopeis only created once, and it stays the same even after recomposition. AnyJobbelonging to this scope will be canceled when the scope leaves the composition.
Then, you create three states: one for the list of communities, which is observed from the database. The second for searchedText, which updates based on user input. The third stores your search Job.
Next, you called LaunchedEffect(Unit) to search the communities when the composition is first composed. Searching for an empty string will return all communities from the database.
LaunchedEffect(key) runs the block of code whenever the composable enters recomposition, as long as the key you passed in changes between recompositions. Because you passed in Unit, which is a constant, it’s only going to run once — the first time the element is shown.
Finally, you added a Column() with three composables: the pre-built ChooseCommunityTopBar(), TextField() to capture the user input and SearchedCommunities() to display the list of communities.
With each value change inside TextField(), this code cancels the previous Job and starts a new one inside the scope you already created.
Inside the code block of the coroutine, you added delay() with a 300-millisecond delay. This prevents a new community search from starting each time the user types a new character, unless more than 300 milliseconds pass between keystrokes. Updating searchedText cancels the previous Job and a new one launches with a new delay.
Build and run, then open the New Post screen by selecting the third option in the bottom navigation.
Click the Choose a community button to open the screen you just implemented:
You see a list of communities and a search input field that you can use to filter the current list. If you type fast, the list won’t update until you wait for more than 300 milliseconds.
Currently, you’re fetching data from a local database, but when searches use a remote API, this implementation saves your network data and reduces the number of requests a server might receive.
If you want to go back without selecting a community, you can click the Close icon from the top app bar. But what happens when you click the built-in back button on your device? The app closes instead of navigating to the previous screen.
Next, you’ll learn about effects in Jetpack Compose.
Effects in Compose
To understand the topic of effects more clearly, you first need to learn how side effects work in Compose.
Side effects in programming are defined as operations that trigger a change in the value of anything outside the scope of a function.
An example of this is when a mutable object is passed to a function and changes some of that function’s properties resulting in an undesired result.
Such changes can affect other parts of the code that use the same object, so you need to be careful when applying them.
The biggest problem with side effects is that you don’t have control over when they actually occur. This is problematic in composables because the code inside them executes every time a recomposition takes place. Effects can help you by giving you control over when the code executes.
Here are more details about specific effects.
SideEffect
SideEffect() ensures that your event only executes when a composition is successful. If the composition fails, the event is discarded. In addition, only use it when you don’t need to dispose the event, but want it to run with every recomposition.
Take a look at the snippet below:
@Composable
fun MainScreen(router: Router) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
SideEffect {
router.isRoutingEnabled = drawerState.Closed
}
}
In this snippet, SideEffect() changes the state of the router. You disable the routing in the app when the drawer is closed: otherwise, you enable it. In this case, router is a singleton and you don’t want to dispose it because other screens are using it for navigation.
The next effect, LaunchedEffect(), is similar to rememberCoroutineScope(), which you used earlier.
LaunchedEffect
LaunchedEffect launches a coroutine into the composition’s CoroutineScope. You can perform any async operation within its scope. Just like rememberCoroutineScope(), its coroutine is canceled when LaunchedEffect leaves the composition and will relaunch on recomposition.
See the example below to get a deeper insight:
@Composable
fun SpeakerList(searchText: String) {
var communities by remember { mutableStateOf(emptyList<String>()) }
LaunchedEffect(searchText) {
communities = viewModel.searchCommunities(searchText)
}
Communities(communities)
}
This snippet is similar to what you did when you implemented the search feature in ChooseCommunityScreen().
When you implemented ChooseCommunityScreen, searchText was a mutable state depending on the user input. This time, searchText is a function parameter and isn’t saved as a mutable state. According to the Google guidelines, you should follow this approach to prevent performance issues.
LaunchedEffect initiates the first time it enters the composition and every time the parameter changes. It cancels all running Jobs during the parameter change or upon leaving the composition.
You now learned all effect types, but there are functions that might help you use those effects for more specific situations. These functions create different kinds of states that should be used inside effect composables. The first function on the list is rememberUpdatedState().
RememberUpdatedState
When using LaunchedEffect, it is initiated every time the passed parameter changes. If you want to use a constant parameter that never changes, your effect will never restart which is a problem if you have values that need to be updated.
In this case, you can use rememberUpdatedState on your value that needs to be updated. This creates a reference to that value and allows it to update when the composable is recomposed. The example when you would need this approach is a splash screen:
@Composable
fun LandingScreen(onSplashFinished: () -> NetworkData) {
val currentOnSplashFinished by rememberUpdatedState(onSplashFinished)
LaunchedEffect(Unit) {
delay(SplashWaitTimeMillis)
currentOnSplashFinished()
}
}
When the splash screen starts, you want to set a timeout for how long it should last and do some background work if you app requires it. When some of your background work is done, you might want to update the values in your composable which triggers the recomposition.
To update the value of your onSplashFinished lambda, you wrap it with rememberUpdatedState and then used inside the LaunchedEffect with Unit as a parameter. Since Unit is a constant value, the effect will never restart to ensure that your splash screen always has the same wait time, but your lambda will still be invoked with the latest value after the timeout is finished.
The next function that will help you when using effects is produceState.
ProduceState
Sometimes you want to do some work in the background and pass the result down to the presentation layer. Remember that composable functions have States and any data used in composables needs to be converted into compose State in order to be used and survive the recomposition.
You can use produceState to write a function that fetches data and converts it directly into compose State. In the following code, you can see an example of loading books by author.
@Composable
fun loadBooks(author: String, booksRepository: BooksRepository): State<Result<List<Book>>> {
return produceState(initialValue = Result.Loading, author, booksRepository) {
val books = booksRepository.load(author)
value = if (books == null) {
Result.Error
} else {
Result.Success(books)
}
}
}
The function has two parameters, an author and booksRepository. ProduceState is called to create a coroutine and fetch the books and directly convert them to a composable State. If either of the two passed parameters change, the job will be canceled and relaunched with the new values.
This allows you to create a composable with the return type and call it from other composables like you would usually do in your presenters or viewmodels. Note that the name convention for composables with return type is to start with lowercase letter like other non-composable functions.
Migrate Effects
If you used older versions of Jetpack Compose, you might have have a few different effects that were not mentioned in this chapter. Those effects are now removed, but you can still achieve the same implementation using LaunchedEffect, DisposableEffect and SideEffect.
To migrate to the newer version, you can use this cookbook prepared for you:
// onActive without subject parameter
onActive {
someFunction()
}
Replace with:
LaunchedEffect(Unit) {
someFunction()
}
You can replace onActive() without subject parameter by using LaunchedEffect with a constant value like Unit or true. This will ensure that the effect is used once, on the first composition.
Next, if you’re using it like so:
// onActive with subject parameter
onActive(parameter) {
someFunction()
}
Replace it with:
LaunchedEffect(parameter) {
someFunction()
}
If you use subject parameter with your onActive(), you can just replace it with LaunchedEffect.
Then if you’re using something like:
// onActive with onDispose
onActive {
val disposable = getData()
onDispose {
disposable.dispose()
}
}
Replace it with:
DisposableEffect(Unit) {
val disposable = getData()
onDispose {
disposable.dispose()
}
}
Like in the example without the subject parameter, you can replace onActive() with onDispose() inside by using DisposableEffect with a constant value like Unit or true.
Finally, if you’re using:
// onCommit without subject parameter
onCommit {
someFunction()
}
Replace it with:
SideEffect {
someFunction()
}
You can replace onCommit() without a subject parameter by using SideEffect with a constant value like Unit or true. This will ensure that the effect is used on the first composition, and again for every recomposition. To use onCommit() with the subject parameter or onDispose() inside, use the same code as for onActive().
Key Points
- Use
rememberCoroutineScope()when you are using coroutines and need to cancel and relaunch the coroutine after an event. - Use
LaunchedEffect()when you are using coroutines and need to cancel and relaunch the coroutine every time your parameter changes and it isn’t stored in a mutable state. -
DisposableEffect()is useful when you aren’t using coroutines and need to dispose and relaunch the event every time your parameter changes. -
SideEffect()triggers an event only when the composition is successful and you don’t need to dispose thesubject. - Use
rememberUpdatedState()when you want to launch your effect only once but still be able to update the values. - Use
produceState()to directly convert non-composable states into composable states. - Names of the composables with a return type should start with the lowercase letter.
Where to Go From Here?
Congratulations! Now, you know how to react to Compose lifecycle, which is one of the most complex parts of Jetpack Compose. At this point, you’ve seen an overview of how to solve some of the most complex and important problems you encounter while working with Compose.
In the next chapter, you’ll learn how to use animations to make your UI more beautiful. Animations are fun — and finally easy to do — so read on and enjoy!