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. By Lena Stepanova.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Lifecycle of Composables in Jetpack Compose
25 mins
- Getting Started
- Exploring the Project Structure
- Identifying the Problem
- Lifecycle of a Composable
- Triggering Recomposition
- Defining the Source of Recomposition
- Observing Logs and Conditions
- Skipping Recomposition
- Smart Recomposition
- Interacting With ViewModels
- Passing Data From a ViewModel to a Composable
- Reacting With LiveData and SharedFlow
- Challenge: Adding LiveData for Questions
- Observing Composable Lifecycle
- Where to Go From Here?
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.
[spoiler title=”Solution”]
Modify fetchQuestions()
and fetchExtendedQuestions()
in QuizViewModel
:
fun fetchQuestions() {
_questions.value = repository.getQuestions()
}
fun fetchExtendedQuestions() {
_questions.value = repository.getExtendedQuestions()
}
Notice that the return types and the body of these functions have changed. You don’t return the list of questions anymore. Now you use LiveData
for storing the questions.
Now, add an init
block to QuizViewModel
:
init {
fetchQuestions()
}
Here you call fetchQuestions()
while the QuizViewModel
is being initialized. This way, you’ll have your LiveData
‘s prepared once the app starts.
Finally, open QuizScreen.kt and find QuizScreen()
. Change the questions
value to look like this:
val questions by quizViewModel.questions.observeAsState()
With this line you observe questions
as a State
.
A few lines below, above the SubmitButton
callsite, wrap the QuizInputFields()
call with the let
block:
questions?.let { QuizInputFields(it, onAnswerChanged) }
This is necessary because observeAsState()
returns a nullable
value.
In the line below, remove assigning value to questions
:
quizViewModel.fetchExtendedQuestions()
You don’t need to assign the new value to questions
because there fetchExtendedQuestions()
doesn’t have a return type anymore.
Build and run the app.
Everything still works fine. Great job!
[/spoiler]
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