Handling State in Jetpack Compose

Table of contents

No heading

No headings in the article.

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 index 0 (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: Updates questionIndex and triggers changeQuestion.

  • OnChoiceChange: Updates _selectedAnswer and calls getNextEnabled 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>
)