7

Managing State in Jetpack Compose [FREE]

 2 years ago
source link: https://www.raywenderlich.com/30172122-managing-state-in-jetpack-compose
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Home Android & Kotlin Tutorials

Managing State in Jetpack Compose

In this tutorial you’ll learn the differences between stateless and stateful composables, and how state hoisting can help making your composables more reusable.

By Rodrigo Guerrero Apr 11 2022 · Article (30 mins) · Intermediate

Version

Jetpack Compose is the new toolkit for building user interfaces in Android. You can use Kotlin code to create UI, letting you forget about old XML layouts.

But with great power comes great responsibility. Managing the state of the UI’s components requires a different approach than with XML layouts.

In this tutorial, you’ll build an app named iState. This app has two screens: a registration form that lets you add users to a list and the list to display the registered users.

In this tutorial, you’ll learn:

  • Composable functions
  • Recomposition
  • Stateful composables
  • Stateless composables
  • State hoisting
Note: This tutorial assumes you know Jetpack Compose basics. If you’re new to Jetpack Compose, check Jetpack Compose Tutorial for Android: Getting Started.

Getting Started

Download the materials by clicking Download Materials at the top or bottom of this tutorial. Open Android Studio Bumblebee or later and import the starter project.

Below is a summary of what each package contains:

  • models: Class that represents a user.
  • ui.composables: Composables used to create the screens.
  • ui.theme: Jetpack Compose theme definition classes.

Build and run the app. You’ll see a screen with a FloatingActionButton.

Empty Users List

Click the button to see the registration screen.

Empty Registration Screen

Notice nothing happens if you try to interact with the form – you can’t see the text you type, change the selected radio button or display the drop-down menu to select your favorite Avenger. You’ll learn how to manage state in Jetpack Compose and make these screens work.

But first, take a moment to learn more about Jetpack Compose.

Introducing Jetpack Compose

Jetpack Compose is a declarative way to create user interfaces. Instead of creating the layout once and updating the state of each component by hand, as you’ve always done with views in XML files, Compose renders each screen from scratch. It repeats the process each time any of the values in the screen change.

To create your UI with Compose, you need to create composable functions. Composable functions are the building blocks in Compose. They can receive data, use the data to create the UI and then emit UI components that users see on the screen.

It’s time to create the first composable in this tutorial.

Open MainScreenComposables.kt. There you’ll find the empty composable UserList() that will display the list of registered users in your app.

This function receives a list of users and doesn’t have a return value. Composable functions emit UI elements so they don’t need to return anything.

In the body, add the necessary composables to display the user items on the list:

 // 1.
  LazyColumn() {
    // 2.
    items(
      items = users,
      key = { user -> user.email }
    ) { user ->
      // 3.
      ItemUser(user)
      Divider()
    }
  }
Note: Add import androidx.compose.foundation.lazy.items and import androidx.compose.material.Divider at the top of MainScreenComposables.

To display a list of items, you use LazyColumn(). Several things happen in this function:

  1. LazyColumn() is a composable function that emits a column that loads its items lazily. You can execute a composable function from another composable function only. To fulfill that rule, UserList() has the Composable annotation.
  2. Use items() to assign a list of items to the column. You can also use the key attribute to add a distinctive identifier to each item in the list.
  3. For each item, emit an ItemUser() row and a Divider() which will make the list look pretty.

UserList() is free of side-effects, which means it always shows the same result with the same input data. It also doesn’t change any global variables or change state. Also, notice UserList() has a parameter set to emptyList(). That means if you don’t provide any data, the app will display an empty list.

Now open MainActivity.kt. Here you’ll find the composable function that emits the screen with all users, UserListScreen(). You don’t need to add anything at this point, but keep in mind you’ll change this code later to have a list with real users.

With these Jetpack Compose basic concepts in mind, you can create any Composable for the sample app and start learning how to handle state in Jetpack Compose. But first, it’s essential to learn how Compose works with data flows and updates the screens.

Understanding Unidirectional Data Flow

Jetpack Compose works entirely differently than XML layouts. Once the app draws a composable, it’s impossible to change it. However, you can change the values passed to each composable, which means you can change the state each composable receives.

On the other hand, a composable might generate events that can update the state. For example, your EditTextField generates an event with the text the user is typing. This event updates the composable’s state so it can show the typed text.

Compose uses the unidirectional data flow design pattern that indicates data or state only flows down while events flow up, as shown in the following diagram:

Unidirectional Data Flow

This diagram represents the UI update loop in Compose:

  1. The composable receives state and displays it on screen.
  2. An event can modify the state values and can come from a composable or from other parts in your app.
  3. The state handler, which can be a View Model, receives the event and modifies the state.

As you can see, the only way to update a composable is to redraw it. So, how can Compose know when to redraw composables? This is where recomposition enters the scene.

Learning About Recomposition

To update a composable, you need to call the composable function with the new data, thereby triggering the process of recomposition. During that process, Compose is only intelligent enough to redraw the composables whose state has changed.

Compose always tries to finish the recomposition before it needs to recompose again. However, sometimes state changes before the recomposition completes. In that case, Compose cancels the recomposition and restarts it with the new state values.

Recomposition can execute composable functions in any order. A composable function shouldn’t generate secondary effects, like changing a global variable, because Compose doesn’t guarantee the order of execution.

Composable functions can run in parallel. That’s another reason to have composable functions without side effects.

For example, suppose you change the value of a local variable within a composable. In that case, the variable could end up with an incorrect value. The recommended way to trigger side effects within a composable is to use callbacks that send events up to the state handler.

Finally, composable functions often run quietly, which means you shouldn’t perform expensive operations within them.

Now that you understand recomposition and why it’s essential, it’s time to talk about State.

Creating Stateful Composables

State is any value that can change during the execution of your app. It can include a value your user enters, data fetched from a database or a selection of options in a form.

Compose provides remember(), a function you can use to store a single object in memory. During the first composition run, remember() stores the initial value.

In each recomposition, remember() returns the stored value so the composable can use it. Whenever the stored value has to change, you can update it and remember() will store it. The next time a recomposition occurs, remember() will provide the latest value.

Creating Composable for Text Fields

It’s time to start using remember(). Open RegisterUserComposables.kt. In EditTextField(), add the following code at the beginning of the function:

// 1.
val text = remember {
  // 2.
  mutableStateOf("")
}

In this code, you:

  1. Create the variable using remember(). text will keep a String value through recompositions.
  2. Use a mutableStateOf() with an empty text as initial value.

Now, use the variable you just created. Replace the value and onValueChange() within the OutlinedTextField() with the following:

// 1.
value = text.value,
// 2.
onValueChange = { text.value = it },

In this code, you:

  1. Set the value of remember() to the OutlinedTextField. Because it’s a mutableState, call its value property.
  2. Update the value stored in the mutable state of remember() whenever the value in the OutlinedTextField changes.

Build and run the app. Open the registration form and add an email and username. You can see the text fields show the values that you enter, like in the following image:

Stateful EditTexts

Creating Radio Button Composable

remember() can hold state with any value type. Go to RadioButtonWithText() and add the following code at the beginning of the function:

val isSelected = remember {
  mutableStateOf(false)
}

In this case, remember() will hold a Boolean mutable state that indicates whether the user has selected the radio button. Now, update the RadioButton composable with isSelected:

RadioButton(
  // 1.
  selected = isSelected.value,
  // 2.
  onClick = { isSelected.value = !isSelected.value }
)

Similar to the previous code, here you:

  1. Set the radio button selected property with the value from remember().
  2. Change the value of isSelected whenever the user clicks the radio button.

Build and run the app again. Open the registration form. Now you can select or unselect the radio buttons too. However, you can have both radio buttons selected at the same time. You’ll fix this later in the tutorial.

Stateful Radio Buttons

Creating Composable for DropDown Menu

Finally, you’ll make DropDown a stateful composable. Add the following code at the top of the DropDown composable:

// 1.
val selectedItem = remember {
  mutableStateOf("Select your favorite Avenger:")
}
// 2.
val isExpanded = remember {
  mutableStateOf(false)
}

In this case, you need to use remember() twice:

  1. selectedItem will hold the value of the item the user selects from the drop-down menu. You also provide a default value.
  2. isExpanded will have the expanded state of the drop-down.

Add the following line of code to the row modifier, below .padding(vertical = 16.dp):

.clickable { isExpanded.value = true }

With this line, you set the value of isExpanded to true whenever the user clicks the drop-down menu, making it expand and show its contents. Update the line with Text("") like this:

Text(selectedItem.value)

This way, you set the selectedItem value to the Text() composable so users can see the value they selected once they dismiss either the drop-down menu or the default value, if they haven’t selected anything yet.

Within DropdownMenu, modify the line expanded = false as shown below:

expanded = isExpanded.value,

This makes DropdownMenu know whether it needs to expand. Now, update the line onDismissRequest = { }, as follows:

onDismissRequest = { isExpanded.value = false },

With this line, you collapse the drop-down menu whenever it receives a dismiss request.

Finally, you have to implement the code when users select their favorite Avenger. Update the onClick content within the DropdownMenuItem() as follows:

onClick = {
  // 1.
  selectedItem.value = menuItems[index]
  // 2.
  isExpanded.value = false
}

In this code, you:

  1. Set the selected Avenger name to selectedItem.
  2. Collapse the drop-down menu after the user selects an item.

Build and run the app. Tap on the drop-down menu and select your favorite Avenger. Once you select it, the drop-down collapses and you can see the name of the Avenger you selected. Great work!

Stateful Drop-down

Composables that use remember() to create and store state are stateful components. Each component stores and modifies its state.

Having stateful components is useful when a caller doesn’t need to know or modify the composable’s state. However, these components are difficult to reuse. And, as you saw with the radio buttons, it’s not possible to share state between composables.

When you need a component whose caller needs to control and modify its state, you need to create stateless composables.

Creating Stateless Composables

Compose uses the state hoisting pattern to make composables stateless. State hoisting moves the composable’s state to its caller.

However, the composable still needs to have values that can change, and emit events whenever an action takes place. You can replace the state with two types of parameters:

  • value: In this variable, you receive the value to display in your composable.
  • onEventCallback: Your composable will call each onEventCallback() for each event it needs to trigger. This way, the component lets its caller know that an action occurred.

Each composable can have many value parameters and many event callbacks. Once the composable is stateless, someone needs to manage the state.

State Holders

A ViewModel can hold the state of the composables in a view. The ViewModel provides the UI with access to the other layers, like the business and data layers. Another advantage is that ViewModels have longer lifetimes than the composables, so it makes them a good place to hold the UI state.

You can then define the state variables using LiveData, Flow or RxJava and define the methods that will change the state of these variables. You can take a look at FormViewModel.kt and MainViewModel.kt to see iState‘s implementation of state holders.

Next, you’ll start implementing state hoisting.

Implementing State Hoisting

Open RegisterUserComposables.kt. Start implementing state hoisting on EditTextField(). This composable needs two variables for its state: one that holds the text that the user is typing and another that holds the state to show whether there is an error. Also, it needs a callback to notify the state holder that the text value changed.

Add the following lines at the top of the parameters list in EditTextField():

// 1.
value: String,
// 2.
isError: Boolean,
// 3.
onValueChanged: (String) -> Unit,

Here is an explanation for this code:

  1. value will receive the current text value for the EditTextField.
  2. isError indicates whether the current text value is valid or invalid so the EditTextField displays an error indicator, if needed.
  3. onValueChanged will execute whenever the value changes.

Next, remove the following code:

val text = remember {
  mutableStateOf("")
}

Because you are now receiving the state in these parameters, the composable doesn’t need to remember its state.

Now, update OutlinedTextField() as follows:

OutlinedTextField(
  // 1.
  value = value,
  // 2.
  isError = isError,
  // 3.
  onValueChange = { onValueChanged(it) },
  leadingIcon = { Icon(leadingIcon, contentDescription = "") },
  modifier = modifier.fillMaxWidth(),
  placeholder = { Text(stringResource(placeholder)) }
)

In this code, you:

  1. Use value to set the current value of the OutlinedTextField().
  2. Set the value of isError using the parameter.
  3. Execute onValueChanged() when the text parameter changes. Now you don’t need to update the remember() value — you only need to hoist this value up.

Amazing! EditTextField is now stateless. Because you are implementing state hoisting, now RegistrationFormScreen() needs to receive the state for the EditTextFields.

Add the following parameters to RegistrationFormScreen():

// 1.
registrationFormData: RegistrationFormData,
// 2.
onEmailChanged: (String) -> Unit,
// 3.
onUsernameChanged: (String) -> Unit,

With this code, you added:

  1. A registrationFormData value that contains all the data needed for the registration form.
  2. onEmailChanged() that will execute when the user updates the email text field.
  3. onUsernameChanged() that will execute when the user updates the username text field.

Finally, you need to pass these values to each EditTextField. Update both EditTextFields as follows:

EditTextField(
  leadingIcon = Icons.Default.Email, 
  placeholder = R.string.email,
  // 1.
  value = registrationFormData.email,
  // 2.
  isError = !registrationFormData.isValidEmail,
  // 3.
  onValueChanged = { onEmailChanged(it) }
)

EditTextField(
  leadingIcon = Icons.Default.AccountBox,
  placeholder = R.string.username,
  modifier = Modifier.padding(top = 16.dp),
  // 4.
  value = registrationFormData.username,
  // 5.
  isError = false,
  // 6.
  onValueChanged = { onUsernameChanged(it) }
)

With this code, you:

  1. Use registrationFormData.email to set the email value.
  2. Use registrationFormData.isValidEmail to show whether there is an error in the email field.
  3. Execute onEmailChanged() whenever the email value changes.
  4. Use registrationFormData.username to set the username value.
  5. Set isError to false since this field doesn’t have validation.
  6. Execute onUsernameChanged() whenever the username value changes.

Open MainActivity.kt and add the following line below the formViewModel declaration:

val registrationFormData by formViewModel.formData.observeAsState(RegistrationFormData())
Note: Ensure you have import androidx.compose.runtime.getValue at the top of the MainActivity.

formViewModel contains a LiveData variable called formData that contains the state for the registration screen.

In this line, you observe this variable as state, using observeAsState(). You need to set its default value. You can use the default values with RegistrationFormData(), which make the form have empty text fields and a preselected radio button.

Whenever a value in formData changes, FormViewModel has logic to update registrationFormData‘s state. This new value propagates down to the composables that use it, triggering the recomposition process.

Finally, update the call to RegistrationFormScreen() like this:

RegistrationFormScreen(
  // 1. 
  registrationFormData = registrationFormData,
  // 2.
  onUsernameChanged = formViewModel::onUsernameChanged,
  // 3.
  onEmailChanged = formViewModel::onEmailChanged,
)

In this code, you:

  1. Pass the registrationFormData state to the registration form screen.
  2. Call onUsernameChanged() within the ViewModel when the username changes. This function updates the registrationFormData content with the new username value.
  3. Call onEmailChanged() within the ViewModel when the email changes. This function updates the registrationFormData content with the new email value.

Build and run the app. Open the registration screen. You can now add an email and username and see the value you type on the screen. Also, you’re able to check whether the email you entered is valid. Hooray!

Stateless EditTexts

Next, you’ll implement state hoisting to make the radio buttons and drop-down work.

Implementing State Hoisting in Other Composables

It’s time to make the radio button and drop-down composables stateless. Open RegisterUserComposables.kt and add the following parameters to RadioButtonWithText, above the text parameter:

isSelected: Boolean,
onClick: () -> Unit,

Here you pass the radio button isSelected parameter along with its onClick() callback.

Now, remove the following code:

val isSelected = remember {
  mutableStateOf(false)
}

Because RadioButtonWithText now receives its state, this remember() is no longer needed.

Next, update the RadioButton composable like this:

RadioButton(
  selected = isSelected,
  onClick = { onClick() }
)

Here you assign both the value and the callback. With this, you made RadioButtonWithText() stateless.

The drop-down menu works differently than the previous composables. In this case, the drop-down menu needs a state that indicates whether it’s in expanded state. You don’t need to hoist this state because it’s only needed by the drop-down composable itself.

On the other hand, this component needs state that has the selected item. In this case, you need a hybrid composable: part of its state is hoisted while the component still has some intrinsic state.

Update DropDown() adding these parameters above the menuItems parameter:

selectedItem: String,
onItemSelected: (String) -> Unit,

selectedItem will hold the selected value and onItemSelected() is the callback that executes when the user selects an item.

Next, remove the following code:

val selectedItem = remember {
  mutableStateOf("Select your favorite Avenger:")
}

Because the composable receives selectedItem, it doesn’t need this remember() anymore.

Next, update Text(selectedItem.value), like this:

Text(selectedItem)

With this code, Text() uses the selectedItem parameter to display its value.

Finally, update DropDownMenuItem() as follows:

DropdownMenuItem(onClick = {
  onItemSelected(menuItems[index])
  isExpanded.value = false
}) {
  Text(text = name)
}

Here, you call onItemSelected() when the user selects a value. With this code, DropDown() is now a hybrid composable.

Now, update the composables caller. Add the following parameters to RegistrationFormScreen(), below the line onUsernameChanged: (String) -> Unit,:

onStarWarsSelectedChanged: (Boolean) -> Unit,
onFavoriteAvengerChanged: (String) -> Unit,

Here, you updated the parameters to receive the different callbacks the radio buttons and drop-down menu need.

Update both radio buttons code within RegistrationFormScreen() like this:

RadioButtonWithText(
  text = R.string.star_wars,
  isSelected = registrationFormData.isStarWarsSelected,
  onClick = { onStarWarsSelectedChanged(true) }
)

RadioButtonWithText(
  text = R.string.star_trek,
  isSelected = !registrationFormData.isStarWarsSelected,
  onClick = { onStarWarsSelectedChanged(false) }
)

In that code, you use the state parameters and assign the callbacks to both radio buttons.

Now, update the drop-down like this:

DropDown(
  menuItems = avengersList,
  // 1.
  onItemSelected = { onFavoriteAvengerChanged(it) },
  // 2.
  selectedItem = registrationFormData.favoriteAvenger
)

Here, you:

  1. Execute the onFavoriteAvengerChanged() callback when selecting an item.
  2. Set the selected value to display your favorite Avenger.

Open MainActivity.kt and update RegistrationFormScreen() call like this:

RegistrationFormScreen(
  registrationFormData = registrationFormData,
  onUsernameChanged = formViewModel::onUsernameChanged,
  onEmailChanged = formViewModel::onEmailChanged,
  onStarWarsSelectedChanged = formViewModel::onStarWarsSelectedChanged,
  onFavoriteAvengerChanged = formViewModel::onFavoriteAvengerChanged,
)

In this code, you pass the radio buttons and drop-down state variables and assign the event callbacks to the registration screen.

Build and run the app. Open the registration screen. Now, you can make a selection with the radio buttons and select your favorite Avenger, like in this image:

Stateless RadioButtons and Dropdown

Finally, to finish this up, you’ll make the buttons work.

Implementing the Register and Clear Buttons

Open RegisterUserComposables.kt and add these parameters to RegistrationFormScreen(), below onFavoriteAvengerChanged:

onRegisterClicked: (User) -> Unit,
onClearClicked: () -> Unit

In this code, you added the buttons’ callbacks.

Now, update the two buttons within RegistrationFormScreen() like this:

OutlinedButton(
  onClick = {
    // 1.
    onRegisterClicked(
      User(
        username = registrationFormData.username,
        email = registrationFormData.email,
        favoriteAvenger = registrationFormData.favoriteAvenger,
        likesStarWars = registrationFormData.isStarWarsSelected
      )
    )
  },
  modifier = Modifier.fillMaxWidth(),
  // 2.
  enabled = registrationFormData.isRegisterEnabled
) {
  Text(stringResource(R.string.register))
}
OutlinedButton(
  // 3.
  onClick = { onClearClicked() },
  modifier = Modifier
    .fillMaxWidth()
    .padding(top = 8.dp)
) {
  Text(stringResource(R.string.clear))
}

In this code, you:

  1. Create a User object and pass it to onRegisterClicked().
  2. Set the enabled state to the register button.
  3. Execute onClearClicked() when the user clicks the clear button.

Next, open MainActivity.kt and update the call to RegistrationFormScreen() with these parameters, adding them below onFavoriteAvengerChanged:

// 1.
onClearClicked = formViewModel::onClearClicked,
// 2.
onRegisterClicked = { user ->
  formViewModel.onClearClicked()
  mainViewModel.addUser(user)
  navController.popBackStack()
}

In this code, you:

  1. Execute onClearClicked() when you tap the button for clearing.
  2. Add the user creation code: Function for clearing the form, adding the user and navigating back to the main screen.

Build and run the app. Open the registration screen and register a user, like in this image:

Stateless Registration Form

Click the register button. You’ll navigate back to the user list, but the user is still not there. You’ll fix that next.

Fixing the Users List

In MainActivity.kt, modify UserListScreen() to receive a list of users as parameter, like this:

@Composable
fun UserListScreen(
  navController: NavController,
  users: List<User>
)

Here, UserListScreen() receives the list of registered users. The MainViewModel is the state handler that holds the users list.

In UserListScreen(), pass the users list to UserList():

UserList(users)

With this, you provide UserList() with the state needed to show the list of users.

In onCreate(), below navController, create a state variable that will hold the users list:

val users by mainViewModel.users.observeAsState(emptyList())

users is a LiveData that you’ll observe as state and assign an empty list as default value.

Finally, four lines below, pass the users state variable to UserListScreen() as follows:

UserListScreen(navController, users)

Build and run the app. Add and register a new user. You’ll see the user in the list on the main screen, like in the next image:

Users List

Amazing! You added state to your screens and made the app work. Now, you are a pro in creating and modifying composables. Congrats!

Where to Go From Here?

Download the final project by using the Download Materials button at the top or bottom of the tutorial.

To learn more about Jetpack Compose, watch the Jetpack Compose video course. You can also find more information in our Jetpack Compose by Tutorials book.

Finally, Android Animations by Tutorials has an excellent section on animations in Jetpack Compose.

I hope you enjoyed this tutorial on managing state in Jetpack Compose. Please join the forum discussion below if you have any questions or comments.

raywenderlich.com Weekly

The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.

Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK