State In Jetpack Compose

State In Jetpack Compose

Introduction

The state is any value that can change over time. Examples of states in android are:-

  • Snackbar
  • blog post with associated comments
  • Ripples animations on buttons that play when a user clicks them.
  • Stickers that users can draw on top of images. Here we shall learn how to connect the state with composable and APIs in jetpack compose.

States and Composition

Compose is declarative and the only way to update it is by calling the same composable with the new argument which is representative of the UI state. Anytime a state is updated a recomposition takes place thus composable like TextFields don't automatically update like in XML views.

  • Composition - a description of the UI built by jetpack compose when it executes composable.
  • initial Composition - Is the creation of a composition by running composable the first time.
  • Re-composition - Re-running composable to update the composition when data changes.

States in Composable

Composable functions stores a single object in memory by using the remember composable. A value computed by remember is stored in the composition during initial composition and returned during recomposition.remember can be used to store both mutable and immutable objects.

Any change to a value will schedule the recomposition of any composable function that read the value. In the case of ExpandingCard when expanded it causes ExpandingCard to be recomposed.

Ways to declare MutableStates objects in Compose

  • val mutableState = remember{mutableStateOf("default")}
  • val value by remember = {mutableStateOf("default")}
  • val (value,setValue) = remember{mutableStateOf("default")}

when using by keyword ensure you have the following imports

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

Remember value can be used as parameter of other composable or even as logic in statement to change which composable is displayed example

var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )

Remember only retain state during composition and during configuration like screen rotation the state is not retained. For this case, we use rememberSaveable which automatically saves any value stored in a bundle.

Supported types of States

Jetpack compose supports other observables types which should be converted to State<T> so that compose can automatically recompose when there is a state change.

Compose only automatically recompose by reading the State<T> object from the observable.

Mutable objects that are not observable, such as ArrayList<T>or a mutable data class, cannot be observed by Compose to trigger recomposition when they change.

Instead of using non-observable mutable objects, we recommend you use an observable data holder such as State<List<T>> and the immutable listOf().

Stateless vs Stateful

Stateful uses remember to store an object and creates an internal state, making the composable stateful.

This can be useful in situations where a caller doesn't need to control the state and can use it without having to manage the state themselves.

However, composable with an internal state tends to be less reusable and harder to test.

A stateless composable is composable that doesn't hold any state. An easy way to achieve statelessness is by using state hoisting.

State Hoisting

It's a pattern of moving states to a composable's caller to make a composable stateless. The general pattern used is by replacing the state variable with two parameters.

Example

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    StatefulCounter(modifier)
}


//displays the count
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        if (count > 0) {
            Text("You've had $count glasses.")
        }
        Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
            Text("Add one")
        }
    }
}
//owns the count
@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
    var count by rememberSaveable { mutableStateOf(0) }
    StatelessCounter(count, { count++ }, modifier)
}

Properties of Hoisted States

  • Single source of truth - By moving state instead of duplicating it, we're ensuring there's only one source of truth. This helps avoid bugs.
  • Encapsulated - Only stateful composable will be able to modify their state. It's completely internal.
  • Shareable - A hoisted state can be shared with multiple composable
  • Interceptable - callers to the stateless composable can decide to ignore or modify events before changing the state.
  • Decoupled - the state for the stateless ExpandingCard may be stored anywhere. For example, it's now possible to move a name into a ViewModel

Rules to figure out where states should go during hoisting

  • State should be hoisted to at least the lowest common parent of all composable that use the state (read).
  • State should be hoisted to at least the highest level it may be changed (write).
  • If two states change in response to the same events they should be hoisted together.

Restoring States in Compose

rememberSaveable retains state across recompositions and also retains state across activity and process recreation.

Other Ways to Restore State

Data that are bundled are saved automatically when working with data that is not bundled use the following

  • Parcelize - Add the @Parcelize annotation to the object. To make it parcelable and bundled.
@Parcelize
data class City(
    val name: String,
    val country:String
): Parcelable
  • MapSaver - It's used to define your own rule for converting an object into a set of values that the system can save to the bundle.
data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}
  • ListSaver To avoid defining the keys for the map, you can use listSaver and use its indices as keys
data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

States are managed with the following:-

  • For Hoisted states, they are managed by the Composable for simple UI elements.
  • State holder this applies to complex UI. They own the UI elements states and logic.
  • Architecture component - They provide access to the business logic and the UI state.

managing-state.png

States in ViewModel.

Screen or Ui state indicates what should be displayed on the screen. The state is usually connected with other layers in the hierarchy because it contains application data.

The logic of the application describes how the application should behave and reacts to state changes.

Type of logic

  • UI Logic - Shows how to display state changes on the UI example is navigation logic.
  • Business Logic - Explains what to do with state changes. example making payments or storing user preferences.

Let's get started with implementation.

Step 1 - Creating the ViewModel

ViewModel provides Ui state and access to the business logic located on other layers of the app. ViewModel survives configuration changes and thus has a longer lifetime than the composition.

Ensure to add the gradle

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"
class WellnessViewModel : ViewModel() {
    private val _tasks = getWellnessTasks().toMutableStateList()
    val tasks: List<WellnessTask>
        get() = _tasks


    fun remove(item: WellnessTask) {
        _tasks.remove(item)
    }

    fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
        tasks.find { it.id == item.id }?.let { task ->
            task.checked = checked
        }

}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

Step 2 - Creating Data class for the Tasks

This class holds all the data used by the ViewModel.

class WellnessTask(
    val id: Int,
    val label: String,
    initialChecked: Boolean = false
) {
    var checked by mutableStateOf(initialChecked)
}

Step 3- Creating the Task Item

This simply maps how the items will appear on the screen.

@Composable
fun WellnessTaskItem(
    taskName: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier, verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 16.dp),
            text = taskName
        )
        Checkbox(
            checked = checked,
            onCheckedChange = onCheckedChange
        )
        IconButton(onClick = onClose) {
            Icon(Icons.Filled.Close, contentDescription = "Close")
        }
    }
}

Step 4- Task List

This is a list that holds all the task items under one LazyColumn

@Composable
fun WellnessTasksList(
    list: List<WellnessTask>,
    onCheckedTask: (WellnessTask, Boolean) -> Unit,
    onCloseTask: (WellnessTask) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier
    ) {
        items(
            items = list,
            key = { task -> task.id }
        ) { task ->
            WellnessTaskItem(
                taskName = task.label,
                checked = task.checked,
                onCheckedChange = { checked -> onCheckedTask(task, checked) },
                onClose = { onCloseTask(task) }
            )
        }
    }
}

Step 5 - Mapping Data to the Screen

@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier,
    wellnessViewModel: WellnessViewModel = viewModel()
) {
    Column(modifier = modifier) {
        StatefulCounter()

        WellnessTasksList(
            list = wellnessViewModel.tasks,
            onCheckedTask = { task, checked ->
                wellnessViewModel.changeTaskChecked(task, checked)
            },
            onCloseTask = { task ->
                wellnessViewModel.remove(task)
            }
        )
    }
}

Finally call the Function to the main activity and run the program.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            StatesDemoTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    WellnessScreen()
                }
            }
        }
    }
}

Conclusion

In this article, we have gone through states in compose and all the definitions used. Supported types of States, Stateless vs Stateful. State Hoisting, Restoring States in Compose and States in ViewModel.

Futher Reading