Handling State in Jetpack Compose
Table of contents
Introduction
In the realm of Android development, state management plays a pivotal role in ensuring a seamless and responsive user experience. Jetpack Compose, a declarative UI toolkit for building Android apps, introduces a paradigm shift in state management, moving away from traditional imperative approaches and embracing a more declarative and composable style.
Understanding State
State, in the context of Android development, refers to any value that can change during the app’s lifecycle. These values are often associated with user interactions, data retrieval, or other dynamic aspects of the app. State management involves effectively tracking and updating these values to ensure that the UI reflects the current state of the app.
This article delves deeper into state management in Jetpack Compose, utilizing a Quiz App as a practical example. We’ll explore key concepts like state variables, state hoisting, MutableState vs. ImmutableState, and ViewModels, unveiling their application in a real-world scenario.
Demonstration: State Management in a Quiz App
To illustrate the concepts of state management in Jetpack Compose, consider a quiz app. The app tracks the current quiz questions, user responses, and overall quiz progress.
State Representation
The quiz app utilizes state variables to represent the current state of the quiz. For instance, a state variable might hold the current quiz question, while another might store the user’s response to the current question.
questionIndex
: Tracks the current question the user is on (mutable state)._isNextEnabled
: Represents whether the "Next" button is enabled based on user selection (mutable state)._selectedAnswer
: Holds the user's chosen answer for a specific question (mutable state).showSubmitButton
: shows submit button if the final question displayed(mutable state)showPreviousButton
: shows the previous button when the question is greater than the index0
(mutable state))
_quizData
: Contains the entire quiz data, including questions, progress, and button visibility (mutable state).
The QuizData holds the ViewState for the QuizScreen per question
data class QuizData(
val showPreviousButton : Boolean,
val showSubmitButton : Boolean,
val questionIndex : Int,
val totalQuestions : Int,
val quizes: List<QuizModel>
)
This is how we’ve initialized _selectedAnswer
and _isNextEnabled
in the ViewModel
private var questionIndex = 0
private var _isNextEnabled = mutableStateOf(false)
val isNextEnabled : Boolean
get() = _isNextEnabled.value
private var _selectedAnswer = mutableStateOf<String?>(null)
val selectedAnswer : String?
get() = _selectedAnswer.value
private var _quizData = mutableStateOf(createQuizData())
val quizData : QuizData?
get() = _quizData.value
State Updates
When the user interacts with the app, such as selecting an answer or moving to the next question, the corresponding state variables are updated. This triggers the recomposition of the affected composable functions, ensuring that the UI reflects the updated state.
onEvents
function: Processes user interactions and updates relevant states.ClickNext
,ClickPrevious, Submit
: UpdatesquestionIndex
and triggerschangeQuestion
.OnChoiceChange
: Updates_selectedAnswer
and callsgetNextEnabled
to determine if the "Next" button should be enabled.
We initialize events to be handled in the Quiz Screen and use them in the onEvents
function
package com.joel.jetquiz.presentation.quiz
sealed class QuizEvents{
object ClickNext : QuizEvents()
object ClickPrevious : QuizEvents()
object Submit : QuizEvents()
data class OnChoiceChange(val selectedAnswer : String) : QuizEvents()
}
fun onEvents(events: QuizEvents){
when(events){
QuizEvents.ClickNext -> {
changeQuestion(questionIndex + 1)
}
QuizEvents.ClickPrevious -> {
changeQuestion(questionIndex - 1)
}
is QuizEvents.OnChoiceChange -> {
_selectedAnswer.value = events.selectedAnswer
_isNextEnabled.value = getNextEnabled()
}
QuizEvents.Submit -> {
viewModelScope.launch {
_uiEvents.send(JetQuizEvents.Navigate("start_page_route"))
}
}
}
}
State Hoisting for Communication
State Hoisting is a pattern of moving state to a composable caller to make a composable stateless
To facilitate communication between composables, state variables are hoisted to higher-level composables. This approach ensures a single source of truth for the state and promotes a clear data flow.
package com.joel.jetquiz.presentation.quiz
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.joel.jetquiz.presentation.composables.OutlinedActionButtonCard
import com.joel.jetquiz.presentation.composables.QuestionWrapper
import com.joel.jetquiz.ui.theme.grn3
import com.joel.jetquiz.ui.theme.wht3
import com.joel.jetquiz.utils.JetQuizEvents
@Composable
fun Quiz(
quizViewModel: QuizViewModel = viewModel(),
onNavigate : (JetQuizEvents.Navigate) -> Unit
){
val quizData = quizViewModel.quizData ?: return
LaunchedEffect(key1 = true){
quizViewModel.uiEvents.collect{ jetQuizEvents ->
when(jetQuizEvents){
is JetQuizEvents.Navigate -> {
onNavigate(jetQuizEvents)
}
}
}
}
QuizContent(
content = {paddingValues ->
AnimatedContent(
targetState = quizData,
transitionSpec = {
val animationSpec: TweenSpec<IntOffset> =
tween(300)
val direction = getTransitionDirection(
initialIndex = initialState.questionIndex,
targetIndex = targetState.questionIndex,
)
slideIntoContainer(
towards = direction,
animationSpec = animationSpec,
) togetherWith slideOutOfContainer(
towards = direction,
animationSpec = animationSpec
)
}, label = ""
) { targetState ->
if (targetState.quizes.isNotEmpty()){
Box(
modifier = Modifier
.padding(paddingValues)
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
){
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxWidth()
.padding(top = 25.dp)
) {
Text(
text = "Question ${targetState.questionIndex + 1} / ${targetState.totalQuestions}",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
QuestionWrapper(
quiz = quizData.quizes[targetState.questionIndex],
onSelectedAnswer = { answer ->
quizViewModel.onEvents(QuizEvents.OnChoiceChange(answer))
},
selectedAnswer = quizViewModel.selectedAnswer
)
}
}
} else {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(20.dp)
.fillMaxSize()
){
Text(
text = "QUIZES SHOULD BE HERE, AN UNEXPECTED ERROR OCCURRED",
fontSize = 24.sp
)
}
}
}
},
isNextEnabled = quizViewModel.isNextEnabled,
onPreviousPressed = {
quizViewModel.onEvents(QuizEvents.ClickPrevious)
},
onSubmitPressed = {
quizViewModel.onEvents(QuizEvents.Submit)
},
onNextPressed = {
quizViewModel.onEvents(QuizEvents.ClickNext)
},
quizData = quizData
)
}
@Composable
fun QuizContent(
content: @Composable (PaddingValues) -> Unit,
isNextEnabled : Boolean,
onPreviousPressed : () -> Unit,
onSubmitPressed : () -> Unit,
onNextPressed : () -> Unit,
quizData: QuizData
){
Scaffold(
bottomBar = {
QuizBottomBar(
showPreviousButton = quizData.showPreviousButton,
showSubmitButton = quizData.showSubmitButton,
onPreviousPressed = { onPreviousPressed() },
onSubmitPressed = { onSubmitPressed() },
onNextPressed = { onNextPressed() },
isNextButtonEnabled = isNextEnabled
)
},
content = content
)
}
@Composable
fun QuizBottomBar(
showPreviousButton : Boolean,
showSubmitButton : Boolean,
onPreviousPressed : () -> Unit,
onSubmitPressed : () -> Unit,
onNextPressed : () -> Unit,
isNextButtonEnabled : Boolean
){
Surface(
modifier = Modifier
.fillMaxWidth(),
shadowElevation = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 15.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (showPreviousButton){
OutlinedActionButtonCard(
title = "Previous",
onClick = { onPreviousPressed() },
modifier = Modifier
.weight(1f)
)
}
if (showSubmitButton){
OutlinedActionButtonCard(
title = "Submit",
onClick = { onSubmitPressed() },
modifier = Modifier
.weight(1f)
)
} else {
Button(
onClick = { onNextPressed() },
modifier = Modifier
.weight(1f)
.height(50.dp),
enabled = isNextButtonEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = if (!isNextButtonEnabled) Color.Black else grn3
)
) {
Text(
text = "Next",
color = wht3
)
}
}
}
}
}
private fun getTransitionDirection(
initialIndex: Int,
targetIndex: Int
): AnimatedContentTransitionScope.SlideDirection {
return if (targetIndex > initialIndex) {
// Going forwards in the survey: Set the initial offset to start
// at the size of the content so it slides in from right to left, and
// slides out from the left of the screen to -fullWidth
AnimatedContentTransitionScope.SlideDirection.Start
} else {
// Going back to the previous question in the set, we do the same
// transition as above, but with different offsets - the inverse of
// above, negative fullWidth to enter, and fullWidth to exit.
AnimatedContentTransitionScope.SlideDirection.End
}
}
State hoisting offers several advantages:
Shareability: A hoisted state can be shared among multiple composable functions, reducing the need for redundant state management.
Encapsulation: The state is encapsulated within a higher-level composable, promoting code organization and maintainability.
Decoupling: Composable functions become decoupled from the internal state, making them more reusable and testable.
ViewModels for handling configuration changes
For managing the overall quiz progress and other complex state aspects, a ViewModel can be employed. The ViewModel encapsulates the state and provides methods for managing it, ensuring data persistence across configuration changes (e.g., device rotation)
package com.joel.jetquiz.presentation.quiz
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.joel.jetquiz.data.DataStore.quizes
import com.joel.jetquiz.data.QuizModel
import com.joel.jetquiz.utils.JetQuizEvents
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class QuizViewModel : ViewModel(){
private var questionIndex = 0
private var _isNextEnabled = mutableStateOf(false)
val isNextEnabled : Boolean
get() = _isNextEnabled.value
private var _selectedAnswer = mutableStateOf<String?>(null)
val selectedAnswer : String?
get() = _selectedAnswer.value
private var _quizData = mutableStateOf(createQuizData())
val quizData : QuizData?
get() = _quizData.value
private val _uiEvents = Channel<JetQuizEvents>()
val uiEvents = _uiEvents.receiveAsFlow()
fun onEvents(events: QuizEvents){
when(events){
QuizEvents.ClickNext -> {
changeQuestion(questionIndex + 1)
}
QuizEvents.ClickPrevious -> {
changeQuestion(questionIndex - 1)
}
is QuizEvents.OnChoiceChange -> {
_selectedAnswer.value = events.selectedAnswer
_isNextEnabled.value = getNextEnabled()
}
QuizEvents.Submit -> {
viewModelScope.launch {
_uiEvents.send(JetQuizEvents.Navigate("start_page_route"))
}
}
}
}
private fun getNextEnabled(enabled : Boolean = true) : Boolean {
val value = if (enabled){
_selectedAnswer.value != null
} else {
return false
}
return value
}
private fun changeQuestion(newQuizIndex : Int){
questionIndex = newQuizIndex
_quizData.value = createQuizData()
_isNextEnabled.value = getNextEnabled()
println("Debug: isNextEnabled = ${_isNextEnabled.value}")
}
private fun createQuizData() : QuizData{
return QuizData(
showPreviousButton = questionIndex > 0,
showSubmitButton = questionIndex == quizes.size -1,
questionIndex = questionIndex,
totalQuestions = quizes.size,
quizes = quizes
)
}
}
data class QuizData(
val showPreviousButton : Boolean,
val showSubmitButton : Boolean,
val questionIndex : Int,
val totalQuestions : Int,
val quizes: List<QuizModel>
)
Subscribe to my newsletter
Read articles from Joel Muraguri directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by