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.

3 (2) · 2 Reviews

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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:

  1. state holds the state of the screen and signals to Jetpack Compose when to switch from QuizScreen() to ResultScreen().
  2. 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:

  1. When verifyAnswers() is called, the SharedFlow emits a Loading event.
  2. After 1 second, verifyAnswers() from repository verifies the answers.
  3. If the answers aren’t correct, event emits an Error. Otherwise, you use LiveData to set state to ScreenState.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:

  1. It’s up to you which observable class to use for interacting with the ViewModel. But keep in mind the differences: You use observeAsState() to observe the LiveData and collectAsState() to observe the SharedFlow. You also need to set the initial state for the SharedFlow. That’s why you pass null to collectAsState().
  2. Here, you instruct Jetpack Compose to recompose the UI depending on the state value.
  3. In this use case, you need to handle events only in QuizScreen() and react accordingly to whether it emits an Error or a Loading event. Here, you assign event to a local variable so you can use the smart cast to Event and access its message property.

Build and run the app. Tap Try me!. You’ll see an error dialog:

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.

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.

Start again button

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.

Recomposed three questions

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.

Initial screen

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