Managing State in Jetpack Compose [FREE]
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.
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.
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
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
.
Click the button to see the 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() } }
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:
-
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 theComposable
annotation. - 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. - For each item, emit an
ItemUser()
row and aDivider()
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:
This diagram represents the UI update loop in Compose:
- The composable receives state and displays it on screen.
- An event can modify the state values and can come from a composable or from other parts in your app.
- 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:
- Create the variable using
remember()
.text
will keep aString
value through recompositions. - 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:
- Set the value of
remember()
to theOutlinedTextField
. Because it’s amutableState
, call itsvalue
property. - Update the value stored in the mutable state of
remember()
whenever the value in theOutlinedTextField
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:
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:
- Set the radio button
selected
property with the value fromremember()
. - 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.
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:
-
selectedItem
will hold the value of the item the user selects from the drop-down menu. You also provide a default value. -
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:
- Set the selected Avenger name to
selectedItem
. - 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!
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 ViewModel
s 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:
-
value
will receive the current text value for theEditTextField
. -
isError
indicates whether the current text value is valid or invalid so theEditTextField
displays an error indicator, if needed. -
onValueChanged
will execute whenever thevalue
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:
- Use
value
to set the current value of theOutlinedTextField()
. - Set the value of
isError
using the parameter. - Execute
onValueChanged()
when thetext
parameter changes. Now you don’t need to update theremember()
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 EditTextField
s.
Add the following parameters to RegistrationFormScreen()
:
// 1. registrationFormData: RegistrationFormData, // 2. onEmailChanged: (String) -> Unit, // 3. onUsernameChanged: (String) -> Unit,
With this code, you added:
- A
registrationFormData
value that contains all the data needed for the registration form. -
onEmailChanged()
that will execute when the user updates the email text field. -
onUsernameChanged()
that will execute when the user updates the username text field.
Finally, you need to pass these values to each EditTextField
. Update both EditTextField
s 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:
- Use
registrationFormData.email
to set the email value. - Use
registrationFormData.isValidEmail
to show whether there is an error in the email field. - Execute
onEmailChanged()
whenever the email value changes. - Use
registrationFormData.username
to set the username value. - Set
isError
tofalse
since this field doesn’t have validation. - 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())
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:
- Pass the
registrationFormData
state to the registration form screen. - Call
onUsernameChanged()
within theViewModel
when the username changes. This function updates theregistrationFormData
content with the new username value. - Call
onEmailChanged()
within theViewModel
when the email changes. This function updates theregistrationFormData
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!
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:
- Execute the
onFavoriteAvengerChanged()
callback when selecting an item. - 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:
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:
- Create a
User
object and pass it toonRegisterClicked()
. - Set the
enabled
state to the register button. - 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:
- Execute
onClearClicked()
when you tap the button for clearing. - 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:
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:
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!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK