Lifecycle of Composables in Jetpack Compose [FREE]
source link: https://www.raywenderlich.com/32617206-lifecycle-of-composables-in-jetpack-compose
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Lifecycle of Composables in Jetpack Compose
Learn about the lifecycle of a composable function and also find out how to use recomposition to build reactive composables.
Version
Reactive programming is the backbone of Jetpack Compose. It allows you to build UI in a declarative manner. You no longer have to use getters and setters to change the views in response to underlying data. Instead, these changes happen reactively due to recomposition.
In this tutorial, you’ll learn:
- About the lifecycle of a composable function.
- Updating a composable from another composable.
- Observing changes in Logcat.
- How to use recomposition to build reactive composables.
Getting Started
Download the starter project by clicking Download Materials at the top or bottom of the tutorial. Unzip it and import the starter project into Android Studio.
Exploring the Project Structure
Unlike in a traditional Android project, in the QuizMe app you won’t find a layout resource directory here. The main directory is different as well – The ui directory has a screens package, which contains MainScreen, QuizScreen and ResultScreen. In these files, you’ll declare your UI. And you’ll do it all in Kotlin, with no more XML.
In the same directory, you’ll also find a theme package, which holds everything responsible for your UI items’ appearance.
Navigate to MainActivity.kt. Notice MainActivity
doesn’t extend AppCompatActivity
as usual but a ComponentActivity
. This is possible thanks to changes in the app build.gradle file. When you create a new project, you can start with an Empty Compose Activity, and Android Studio will add necessary dependencies for you.
But if you’re adding Jetpack Compose to an existing project, you first need to configure it in your app-level build.gradle file to comply with Jetpack Compose and then add the relevant dependencies.
Identifying the Problem
Now that you’re more familiar with the project, build and run it. You’ll see a quiz form.
Are you ready for a challenge? Try answering some of the questions.
Unfortunately, nothing appears if you start typing in the input fields. This is because your UI isn’t responsive yet. :[
Open QuizScreen.kt and take a look at QuizInput()
:
@Composable fun QuizInput(question: String) { Log.d(MAIN, "QuizInput $question") val input = "" TextField( value = input, onValueChange = { }, modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), placeholder = { Text(text = question) } ) }
This composable function is responsible for updating the text field for answers. It holds an input
variable, which is just an immutable String
. Currently, there’s no state that TextField()
could react to in order to update its value. The onValueChange
callback is empty as well.
It’s time to change this so your answers appear in the text field. But first, you need to learn about the lifecycle of a composable.
Lifecycle of a Composable
As part of the Android ecosystem, every composable has its own lifecycle. Luckily, it’s more simple compared to other components. Every composable goes through three stages of life:
- Entering the composition
- Recomposition
- Leaving the composition
A composable can recompose as many times as needed to render the latest UI.
Triggering Recomposition
In the starter project, you declare the composition within setContent()
in MainActivity
.
After you start QuizMe, your composables enter the composition declared in MainScreen()
which then calls QuizScreen()
with all its composables. But nothing gets recomposed afterward. QuizInput()
never enters the next stage of lifecycle because your UI remains static.
You need to give instructions to your composable functions to recompose and display the updated UI.
State plays an important role in declarative UIs. Recomposition happens when any of the states involved in the initial composition changes. Go ahead and change the immutable String
variable to a MutableState
variable, making the QuizInput()
stateful.
First, locate the input
variable and, instead of an empty text, use remember()
to create a mutable state:
var input by remember { mutableStateOf("") }
Now the input
is a state variable that holds a String
value. You use this value in TextField()
. Here, remember()
helps the composable keep the latest state of the input between recompositions.
Next, three lines below, update the onValueChange
callback:
onValueChange = { value -> input = value }
When the value of the text field changes, onValueChange()
gets triggered. It sets the value of the mutable state to the new input string and, as a result, Jetpack Compose reacts to this state change and recomposes TextField()
.
Build and run the app again. Try entering your answers to the questions now. When you enter a new letter in the text field, the recomposition happens, and you can see the latest input.
Well done!
But what if you want to trigger the recomposition of another composable in response to changes in QuizInput()
? For example, adding another TextField()
to your initial screen layout when the user selects the checkbox. Find the answer in the next paragraph.
Defining the Source of Recomposition
If you want to trigger the recomposition of one composable to another, use state hoisting and move the responsibility to the composable in control.
For example, look at CheckBox()
in QuizScreen.kt:
@Composable fun CheckBox( // 1 isChecked: Boolean, onChange: (Boolean) -> Unit ) { . . . Checkbox( // 2 checked = isChecked, onCheckedChange = onChange ) . . . }
Here’s how it works:
- It receives a
Boolean
checked state and anonChange
lambda as parameters. This is useful because you can reuse the composable multiple times and there won’t be any side-effects. Also, you added flexibility to the composable function because it can receive the state from anywhere now. - You assigned the value and lambda to the
Checkbox
component. Now, if the user changes thechecked
state, the component reacts with theonChange
lambda, lifting the current state ofisChecked
toQuizScreen()
.
Look at the following lines in QuizScreen()
:
var checked by remember { mutableStateOf(false) } val onCheckedChange: (Boolean) -> Unit = { value -> checked = value }
These mean QuizScreen()
is the actual holder of the checked
state value. It’s in control of the value and can change that value when it gets an onCheckedChange
callback.
As a result, you can use the checked
state from QuizScreen()
to trigger recomposition of other composables.
Next, you want to disable submitting the form if the box isn’t checked at least once. Change the signature of SubmitButton()
like this:
@Composable fun SubmitButton(isChecked: Boolean, text: String, onClick: () -> Unit)
Here you provide the state value to a composable function for the submit button.
Then, add a parameter to ExtendedFloatingActionButton()
inside SubmitButton()
:
backgroundColor = if (isChecked) MaterialTheme.colors.secondary else Color.Gray
With this line, you handle changing the background color of the submit button depending on the checked value.
Also, in QuizScreen()
, pass the checked
state to SubmitButton()
:
SubmitButton(checked, stringResource(id = R.string.try_me)) { }
Build and run the app. Try using the checkbox.
The button changes its color when the user selects or unselects the checkbox. Wow! You’re triggering the recomposition of the button in response to checkbox state changes.
Observing Logs and Conditions
Open Logcat, select com.yourcompany.android.quizme and observe the recomposition in the log output:
D/MainLog: Checked state true D/MainLog: Button recomposed D/MainLog: Checked state false D/MainLog: Button recomposed
As you can see, the recomposition happened as many times as you clicked on the checkbox.
Now add logic for disabling the button if the checkbox isn’t checked. In QuizScreen()
, add a condition for SubmitButton()
:
if (checked) { SubmitButton(checked, stringResource(id = R.string.try_me)) { } }
Now Compose won’t do the button recomposition if the box isn’t checked.
Build and run the app. Tap the checkbox a few times. Notice the button is visible only when you select the checkbox.
The log output is different as well:
D/MainLog: Checked state true D/MainLog: Button recomposed D/MainLog: Checked state false D/MainLog: Checked state true D/MainLog: Button recomposed D/MainLog: Checked state false
Recomposition follows conditional rules, just like every other piece of code in Kotlin. When the checked state is set to false, there’s no need for Jetpack Compose to recompose the SubmitButton()
.
Skipping Recomposition
Jetpack Compose is intelligent and can choose to skip recomposition when it’s not needed.
Inside the if
clause, above SubmitButton()
, assign a new value to the questions
variable:
questions = listOf( "What's the best programming language?", "What's the best OS?", "The answer to Life, The Universe and Everything?" )
Here you added one more question to the list. This will result in adding a new input field dynamically the first time the user selects the checkbox.
Build and run the app. Select the checkbox and you’ll see a new text field appears:
Check the log output. You’ll see the following:
D/MainLog: Checked state true D/MainLog: Button recomposed D/MainLog: QuizInput The answer to Life, The Universe and Everything?
Notice that the logs contain only the new QuizInput
field name here. Jetpack Compose recognized that the first two items in the list hadn’t changed, so it didn’t have to recompose them.
When you select the checkbox, it sends an event to the QuizScreen()
, which sets the checked
state to the new value. In its turn, Jetpack Compose knows which composables to recompose. In the log output, you see that the CheckBox()
gets recomposed each time the user selects or unselects it. But the button gets recomposed only when the checked
state is true
. InputFields()
isn’t affected by the user interaction with the checkbox at all.
This happens because Jetpack Compose skips recomposition where possible to stay optimized. Another example of this is when a conditional statement defines when not to show a composable as with SubmitButton()
or when the state doesn’t affect a composable or the state hasn’t changed, as with InputFields()
.
Smart Recomposition
How does Jetpack Compose know whether the state has changed? It simply uses equals()
to compare the new and the old values of the mutable state. Therefore, any immutable type won’t trigger recomposition.
Do you remember trying to use InputFields()
with the immutable String
value at the beginning of the tutorial? Jetpack Compose didn’t want to recompose anything because the value couldn’t change.
Any immutable type is stable and doesn’t trigger recomposition. That’s why you use remember()
combined with mutableStateOf()
. Jetpack Compose observes changes of the mutable state so it knows when to start recomposition and which composable functions it should recompose.
Modify the previous code block like this:
questions = listOf( "The answer to Life, The Universe and Everything?", "What's the best programming language?", "What's the best OS?" )
Here you shuffled questions in the list.
Build and run the app again. Select the checkbox.
At first sight, it still looks the same. But in the log output, you’ll see Compose recomposed all three inputs:
D/MainLog: Checked state true D/MainLog: Button recomposed D/MainLog: QuizInput The answer to Life, The Universe and Everything? D/MainLog: QuizInput What's the best programming language? D/MainLog: QuizInput What's the best OS?
Once Jetpack Compose started to compare the new list with the old, it noticed that the first item didn’t equal the first item of the old list. In that case, it couldn’t reuse the old QuizInputs()
and had to recompose them all. But how to be sure Jetpack Compose will use old composables if there are any?
Modify QuizInputFields()
by wrapping QuizInput()
into key()
:
key(question){ QuizInput(question = question) }
To ensure Jetpack Compose reuses old composables, you use key()
. The question
value you provided is identifier for a certain instance of QuizInput
.
Build and run the app again. Select the checkbox. The UI remains the same!
Recheck the log output. This time, it looks like when you added the new question to the bottom of the list – only the new InputField()
was recomposed:
D/MainLog: Checked state true D/MainLog: Button recomposed D/MainLog: QuizInput The answer to Life, The Universe and Everything?
Although the list order changed, Jetpack Compose reused two initial question fields because it could identify them this time. This is an important concept to remember when you need to render longer lists or when the list order is essential.
Interacting With ViewModels
What you want to do now is to verify the user inputs and let them know if they’re on the right track. Check out the repository and business folders in your project.
To produce questions and validate the answers, you’ll mock the backend in QuestionsRepository. You’ll use QuizViewModel to connect the UI with your data source.
Passing Data From a ViewModel to a Composable
Add these two functions to QuizViewModel
to fetch questions:
fun fetchQuestions(): List<String> { return repository.getQuestions() } fun fetchExtendedQuestions(): List<String> { return repository.getExtendedQuestions() }
These will call the functions from the repository to fetch a basic and an extended list of questions, which you introduced earlier.
In QuizScreen.kt, find QuizScreen()
and replace the hardcoded listOf()
questions with the function call from quizViewModel
:
var questions by remember { mutableStateOf(quizViewModel.fetchQuestions()) }
Also, above SubmitButton()
, modify the extended list by replacing listOf()
with this:
questions = quizViewModel.fetchExtendedQuestions()
Build and run the app. You shouldn’t notice any changes because recomposition still works as before. Only the data source has changed.
Congrats! You just connected QuizViewModel
with your composable function.
Reacting With LiveData and SharedFlow
When communicating with your ViewModel
from Jetpack Compose UI, you have several options for reacting to changes.
Navigate to QuizViewModel.kt and look at the following variables:
// 1 private val _state = MutableLiveData<ScreenState>(ScreenState.Quiz) val state: LiveData<ScreenState> = _state // 2 private val _event = MutableSharedFlow<Event>() val event: SharedFlow<Event> = _event
Here’s the explanation:
-
state
holds the state of the screen and signals to Jetpack Compose when to switch fromQuizScreen()
toResultScreen()
. -
event
helps you interact with the user by showing dialogs or loading indicators.
These variables hold a ScreenState
or an Event
instance, which are sealed classes at the bottom of QuizViewModel
.
Go back to QuizScreen.kt. In QuizScreen()
, add a map of answers
above the onAnswerChanged
value:
val answers = remember { mutableMapOf<String, String>() }
Using remember()
, you ensure the previous answers aren’t lost when QuizScreen()
recomposes.
Then, complete the onAnswerChanged
lambda:
answers[question] = answer
You use the question
and answer
values to set the data in the answers
map.
Next, in QuizInput()
, add a new parameter to the signature:
fun QuizInput(question: String, onAnswerChanged: (String, String) -> Unit)
Then, invoke onAnswerChanged
once value changes. Modify code in the onValueChange
lambda like this:
run { input = value onAnswerChanged(question, input) }
When the user enters a new letter, the value of the input state changes and the new answer gets saved in the answers
map.
To fix errors you made with the previous code, look for QuizInputFields()
and provide the onAnswerChanged
callback to QuizInput()
:
QuizInput(question = question, onAnswerChanged = onAnswerChanged)
Finally, you need to verify the answers once the user submits the quiz. In QuizScreen()
, complete the callback in SubmitButton()
with this:
quizViewModel.verifyAnswers(answers)
Now that you’ve connected your UI with QuizViewModel
and QuestionsRepository
, all that’s left to do is declare how your UI should react to changes.
Go to QuizViewModel.kt and check out verifyAnswers()
:
fun verifyAnswers(answers: MutableMap<String, String>) { viewModelScope.launch { // 1 _event.emit(Event.Loading) delay(1000) // 2 val result = repository.verifyAnswers(answers) when (result) { // 3 is Event.Error -> _event.emit(result) else -> _state.value = ScreenState.Success } } }
This function has several important points:
- When
verifyAnswers()
is called, the SharedFlow emits aLoading
event. - After 1 second,
verifyAnswers()
from repository verifies the answers. - If the answers aren’t correct,
event
emits anError
. Otherwise, you use LiveData to setstate
toScreenState.Success
.
As you can see, event
and state
should be transmitted to the composables from ViewModel
via LiveData
or SharedFlow
you defined before.
All that’s left now is to declare how MainScreen()
will react to changes in state
and event
.
Navigate to MainScreen.kt and replace the code inside MainScreen()
as follows:
// 1 val state by quizViewModel.state.observeAsState() val event by quizViewModel.event.collectAsState(null) // 2 when (state) { is ScreenState.Quiz -> { QuizScreen( contentPadding = contentPadding, quizViewModel = quizViewModel ) // 3 when (val e = event) { is Event.Error -> { ErrorDialog(message = e.message) } is Event.Loading -> { LoadingIndicator() } else -> {} } } is ScreenState.Success -> { ResultScreen( contentPadding = contentPadding, quizViewModel = quizViewModel ) } else -> {} }
Add the imports where necessary. Here’s a breakdown of the code:
- It’s up to you which observable class to use for interacting with the
ViewModel
. But keep in mind the differences: You useobserveAsState()
to observe theLiveData
andcollectAsState()
to observe theSharedFlow
. You also need to set the initial state for theSharedFlow
. That’s why you passnull
tocollectAsState()
. - Here, you instruct Jetpack Compose to recompose the UI depending on the state value.
- In this use case, you need to handle events only in
QuizScreen()
and react accordingly to whether it emits anError
or aLoading
event. Here, you assignevent
to a local variable so you can use the smart cast toEvent
and access itsmessage
property.
Build and run the app. Tap Try me!. You’ll see an error dialog:
Try answering the questions. If you do it incorrectly, you see an error dialog with an appropriate message. If you manage to answer all the questions, you’re redirected to the result screen.
Great, you’re almost there!
Open ResultScreen.kt and add another button to ResultScreen()
right below Congrats()
:
SubmitButton (true, stringResource(id = R.string.start_again)) { quizViewModel.startAgain() }
This code block allows you to start the quiz again.
Build and run the app. When you answer all questions correctly you’ll see the result screen. There you have the button to start the quiz again.
Challenge: Adding LiveData for Questions
Now that you know how to observe variables from the ViewModel
, you can make the app even prettier by adding another observable variable to QuizViewModel
:
private val _questions = MutableLiveData<List<String>>() val questions: LiveData<List<String>> = _questions
Before proceeding with the article, adjust the rest of the project and, when you’re ready, compare it with the suggested solution below.
Observing Composable Lifecycle
You’re almost done. Pass the quiz and tap Start again to restart the quiz again. Oops, all three questions are still there.
But how do you show the initial list of just two questions again?
Of course, you could simply refresh the questions in startAgain()
. But, this time you’ll try another approach.
In QuizScreen.kt, add this to the bottom of QuizScreen()
:
DisposableEffect(quizViewModel) { quizViewModel.onAppear() onDispose { quizViewModel.onDisappear() } }
This code retrieves the initial questions at the end of the QuizScreen()
lifecycle before it disappears from the screen.
Next, in QuizViewModel.kt, add the following line at the end of onDisappear()
:
fetchQuestions()
This code creates a side-effect. DisposableEffect()
instructs QuizViewModel
to call onAppear()
when QuizScreen()
enters the composition and call onDisappear()
when it leaves the composition. This way, you can bind your ViewModel
to the composable’s lifecycle so that when you move from the quiz screen to the result screen, the questions list in QuizViewModel
is refreshed.
Notice that you’re using quizViewModel
as a key to DisposableEffect()
here, which means it will also be triggered if quizViewModel
changes.
Many other side-effects can help you fine-tune the recomposition of various composables. But keep in mind you must use side-effects with caution because they will be apply even if the recomposition is canceled or restarted due to new state changes. This can lead to inconsistent UI if any of your composables depend on the side-effects.
Build and run the app. If you pass the quiz and start again, the screen looks as fresh as before.
Check out the log output. As you can see, the list of questions is recomposed before the quiz screen appears:
D/MainLog: QuizScreen disappears D/MainLog: QuizInput What's the best programming language? D/MainLog: QuizInput What's the best OS? D/MainLog: Checked state false D/MainLog: QuizScreen appears
Where to Go From Here?
Congratulations, you completed the tutorial! Download the final project by clicking Download Materials at the top or bottom of the tutorial.
In a more complicated app, you’ll use navigation to go from one screen to another instead of just switching the composables. Therefore, you might want to check out this article on Compose Destinations.
You also can continue learning about Jetpack Compose from the Jetpack Compose by Tutorials book, deepen your knowledge on Managing State in Compose or find out about other side-effects than DisposableEffect
.
To learn more about state hoisting, check out the official documentation.
Keep it up on your Jetpack Compose journey. You still have much to explore. :]
If you have any questions or comments, join the forum discussion below.
raywenderlich.com Weekly
The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK