El estado y Jetpack Compose

El estado de una app es cualquier valor que puede cambiar con el paso del tiempo. Esta es una definición muy amplia y abarca desde una base de datos de Room hasta una variable de una clase.

Todas las apps para Android muestran un estado al usuario. Estos son algunos ejemplos de estado de las apps para Android:

  • Una barra de notificaciones que se muestra cuando no se puede establecer una conexión de red
  • Una entrada de blog y los comentarios asociados
  • Las animaciones con efectos de propagación en botones que se reproducen cuando un usuario hace clic en ellas
  • Las calcomanías que un usuario puede dibujar sobre una imagen

Jetpack Compose te ayuda a definir explícitamente el lugar y la manera en que almacenas y usas el estado en una app para Android. Esta guía se enfoca en la conexión entre el estado y los elementos que admiten composición, y en las API que Jetpack Compose ofrece para trabajar de manera más sencilla con el estado en cuestión.

Estado en Compose

El concepto de estado es una parte fundamental de Compose. Un ejemplo sencillo es el de una pantalla en la que el usuario puede ingresar su nombre y se muestra un saludo como respuesta. El código que aparece a continuación incluye texto para el saludo y un campo para ingresar el nombre.

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

Si ejecutas el código, verás que no sucede nada. Eso se debe a que TextField no se actualiza a sí mismo, sino que lo hace cuando cambia su parámetro value. Tiene que ver con la manera en que funcionan la composición y la recomposición en Compose.

Composición y recomposición

Una composición describe la IU y se produce ejecutando elementos que admiten composición. Es una estructura de árbol de esos elementos que describe la IU.

Durante la composición inicial, Compose hará un seguimiento de los elementos que admiten composición que llamas a fin de describir tu IU en una composición. Luego, cuando cambia el estado de la app, Jetpack Compose programa la recomposición. La recomposición consiste en ejecutar los elementos que admiten composición que quizás se modificaron en respuesta a cambios de estado, y Jetpack Compose actualiza la composición para reflejar esos cambios.

Una composición solo puede producirse mediante una composición inicial y actualizarse mediante la recomposición. La única forma de modificar una composición es mediante la recomposición.

Para obtener más información sobre la composición inicial y la recomposición, consulta Cómo pensar en Compose.

Presentación del estado

Para actualizar el elemento que se puede componer, pasa un valor que represente el estado de TextField y agrega código para actualizar el estado cuando cambie el valor de TextField.

Para ingresar un estado local que contenga el nombre que se debe mostrar, usa remember { mutableStateOf() } y pasa el valor predeterminado del texto. De esa manera, cada vez que cambie el estado name, también se modificará el valor que muestra TextField.

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        Text(
            text = "Hello",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

Las funciones que admiten composición pueden almacenar un solo objeto en la memoria por medio del elemento remember que admite composición. Un valor calculado por remember se almacena en la composición durante la composición inicial, y el valor almacenado se muestra durante la recomposición. Puedes usar remember para almacenar objetos inmutables y mutables.

mutableStateOf crea un MutableState, que es un tipo observable en Compose. Cualquier cambio en su valor programará la recomposición de las funciones que admiten recomposición que lean ese valor.

remember te ayuda a preservar el estado entre recomposiciones. Si usas mutableStateOf sin remember, el estado se reinicializa como una string vacía cada vez que se recompone un elemento que admite composición HelloContent.

Puedes usar el valor recordado como un parámetro para otros elementos que admiten composición o incluso como lógica en declaraciones para cambiar los elementos que se muestran. Por ejemplo, si no quieres mostrar el saludo cuando el nombre está vacío, usa el estado en una declaración if:

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       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") }
       )
   }
}

Aunque remember te ayuda a retener el estado entre recomposiciones, el estado no se retiene entre cambios de configuración. Para ello, debes usar rememberSaveable. rememberSaveable almacena automáticamente cada valor que se puede guardar en un Bundle. Para otros valores, puedes pasar un objeto Saver personalizado.

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by rememberSaveable { 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") }
        )
    }
}

Elementos sin estado que admiten composición

Cuando un elemento que admite composición retiene su propio estado como en el ejemplo anterior, resulta difícil volver a usarlo y probarlo. Además, mantiene el elemento acoplado estrechamente al almacenamiento del estado. En cambio, debes asegurarte de que sea un elemento que admite composición sin estado, es decir, que no tenga ningún estado.

Para ello, puedes usar la elevación de estado, un patrón de programación en el que mueves el estado de un elemento que admite composición al llamador de ese elemento. Una manera sencilla de lograrlo consiste en reemplazar el estado con un parámetro y usar lambdas para representar eventos.

En el caso de ejemplo, extraes el name y el onValueChange de HelloContent, y los mueves hacia arriba en el árbol hasta un elemento que admite composición HelloScreen que llama a HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

HelloContent tiene acceso al estado como un parámetro String inmutable, así como un onNameChange lambda al que puede llamar cuando se desee solicitar el cambio de estado.

Las expresiones lambda son la forma más común de describir eventos en un elemento que admite composición. En este ejemplo, defines un evento llamado onNameChange usando un valor lambda que toma un String mediante la sintaxis de tipo de función de Kotlin: (String) -> Unit. El valor lambda se llama onNameChange (en presente), debido a que el evento no implica que el estado ya haya cambiado, sino que el elemento que admite composición está solicitando que el controlador del evento lo cambie.

Si se toma el estado de HelloContent, es más fácil entender el elemento que admite composición, volver a utilizarlo en diferentes situaciones y realizar pruebas. HelloContent está separado de la forma en que se almacena el estado. Esta separación implica que, si modificas o reemplazas HelloScreen, no necesitas cambiar la forma en que se implementa HelloContent.

El patrón en el que el estado baja y los eventos suben se llama flujo unidireccional de datos. En este caso, el estado baja de HelloScreen a HelloContent y los eventos suben de HelloContent a HelloScreen. Si sigues el flujo unidireccional de datos, podrás separar los elementos que admiten composición y muestran el estado de la IU respecto de las partes de la app que almacenan y cambian el estado.

ViewModel y estado

En Jetpack Compose, puedes usar ViewModel para exponer el estado en un elemento observable (como LiveData o Flow) y también para controlar eventos que afectan ese estado. El ejemplo de HelloScreen anterior se implementará usando un ViewModel como el siguiente:

class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

observeAsState observa un objeto LiveData<T> y muestra un objeto State<T> que se actualiza cada vez que cambian los LiveData. State<T> es un tipo observable que Jetpack Compose puede usar directamente. observeAsState solo observará LiveData mientras esté en la composición.

La línea:

val name: String by helloViewModel.name.observeAsState("")

...es sintaxis edulcorada para separar automáticamente el objeto de estado que muestra observeAsState. También puedes asignar el objeto de estado por medio de un operador de asignación, =, que lo convierte en un objeto State<String> en lugar de String.

val nameState: State<String> = helloViewModel.name.observeAsState("")

HelloViewModel y HelloScreen sigue el patrón de flujo unidireccional de datos en el que el estado fluye hacia abajo desde HelloViewModel y los eventos fluyen hacia arriba desde HelloScreen.

El flujo de estados y eventos entre HelloInput, HelloScreen y HelloViewModel

Observa el bucle de eventos de la IU para esta pantalla:

  1. Evento: Se llama a onValueChange cuando el usuario escriba un carácter.
  2. Estado de actualización: HelloViewModel.onNameChange controla el procesamiento y, luego, establece el estado de los valores mutables LiveData, _name.
  3. Estado de visualización: Cambia el valor de HelloViewmodel.name y Compose lo observa en observeAsState. A continuación, HelloScreen se ejecuta de nuevo (se recompone) para describir la IU según el nuevo valor de name.

Consulta Definir la arquitectura de la IU de Compose para descubrir cómo se puede implementar el flujo unidireccional de datos mediante ViewModel y Jetpack Compose.

Uso de remember

Las funciones que admiten composición pueden almacenar un solo objeto en la memoria por medio del elemento remember que admite composición. Un valor calculado por remember se almacena en la composición durante la composición inicial, y el valor almacenado se muestra durante la recomposición. Se puede usar remember para almacenar tanto objetos mutables como inmutables.

Cómo usar remember para almacenar valores inmutables

Puedes almacenar valores inmutables cuando almacenas en caché operaciones costosas de la IU, como el procesamiento de formato de texto. El valor recordado se almacena en la composición con el elemento que admite composición que llamó a remember.

@Composable
fun FancyText(text: String) {
    // by passing text as a parameter to remember, it will re-run the calculation on
    // recomposition if text has changed since the last recomposition
    val formattedText = remember(text) { computeTextFormatting(text) }
    /*...*/
}
Composición de FancyText con formattedText como elemento secundario

Cómo usar remember para crear un estado interno en un elemento que admite composición

Cuando almacenas un objeto mutable por medio de remember, agregas el estado a un elemento que admite composición. Puedes usar ese método a fin de crear un estado interno para un solo elemento con estado.

Te recomendamos que todos los estados mutables que utilicen los elementos que admiten composición sean observables. Eso le permitirá a Compose recomponer automáticamente cada vez que cambie el estado. Compose tiene un tipo State<T> integrado y observable, que se integra directamente en el entorno de ejecución de Compose.

Un buen ejemplo del estado interno en un elemento que admite composición es un ExpandingCard que se contrae y se despliega cuando el usuario hace clic en un botón.

El elemento ExpandedCard que admite composición muestra una animación entre contraído y expandido

Este elemento tiene un estado importante: expanded. Cuando su estado sea expanded, el elemento deberá mostrar el cuerpo y, cuando sea contraído, deberá ocultarlo.

Composición de ExpandingCard con el estado expanded como elemento secundario

Puedes agregar un estado expanded a un elemento que admite composición si usas remember con mutableStateOf(initialValue).

@Composable
fun ExpandingCard(title: String, body: String) {
    // expanded is "internal state" for ExpandingCard
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                // TODO: show body & collapse icon
            } else {
                // TODO: show expand icon
            }
        }
    }
}

mutableStateOf crea un MutableState<T> observable, que es un tipo observable integrado en el entorno de ejecución de Compose.

interface MutableState<T> : State<T> {
   override var value: T
}

Cualquier cambio en value programará la recomposición de las funciones que admiten composición que lean value. En el caso de ExpandingCard, cada vez que cambia expanded, hace que se vuelva a componer ExpandingCard.

Existen tres maneras de declarar un objeto MutableState en un elemento que admite composición:

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

Esas declaraciones son equivalentes y se proporcionan como sintaxis edulcorada para diferentes usos del estado. Elige la que genere el código más fácil de leer en el elemento que admite composición que desees escribir.

Puedes usar el valor del estado interno en un elemento que admite composición como parámetro para otro de esos elementos o incluso a los fines de cambiar cuáles de ellos se llamarán. En ExpandingCard, una instrucción "if" cambiará el contenido de la tarjeta según el valor actual de expanded.

if (expanded) {
   // TODO: show body & collapse icon
} else {
   // TODO: show expand icon
}

Cómo modificar el estado interno de un elemento que admite composición

El estado debe ser modificado por los eventos en un elemento que admite composición. Modificar el estado cuando ejecutas un elemento de ese tipo en lugar de hacerlo en un evento es un efecto secundario del elemento, lo cual debe evitarse. Si quieres obtener más información sobre los efectos secundarios de Jetpack Compose, consulta Acerca de Compose.

Para completar el elemento ExpandingCard que admite composición, mostremos el body y un botón para contraer cuando expanded sea true y un botón para expandir cuando expanded sea false.

@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                Text(text = body, Modifier.padding(top = 8.dp))
                // change expanded in response to click events
                IconButton(onClick = { expanded = false }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                // change expanded in response to click events
                IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

En ese elemento que admite composición, el estado se modifica en respuesta a los eventos onClick. Dado que expanded usa var con la sintaxis del delegado de propiedad, las devoluciones de llamada onClick pueden asignar expanded en forma directa.

IconButton(onClick = { expanded = true }, /* … */) {
   // ...
}

Ahora podemos describir el bucle de actualización de la IU para ExpandingCard a fin de ver cómo Compose modifica y usa el estado interno.

  1. Evento: se llama a onClick cuando el usuario presione uno de los botones.
  2. Estado de actualización: se cambia expanded en el objeto de escucha onClick mediante la asignación.
  3. Estado de visualización: se recompone ExpandingCard porque expanded es el State<Boolean> que cambió y ExpandingCard lo lee en la línea if(expanded). Luego, ExpandingCard describe la pantalla para el valor nuevo de expanded.

Cómo usar otros tipos de estado en Jetpack Compose

Jetpack Compose no requiere que uses MutableState<T> para contener el estado. Jetpack Compose admite otros tipos observables. Antes de leer otro tipo observable en Jetpack Compose, debes convertirlo en un State<T> para que Jetpack Compose pueda recomponer automáticamente cuando cambie el estado.

Compose cuenta con funciones para crear State<T> a partir de tipos observables comunes utilizados en apps para Android:

Puedes compilar una función de extensión para Jetpack Compose a los efectos de leer otros tipos observables si tu app usa una clase observable personalizada. Consulta la implementación de los módulos integrados a fin de obtener ejemplos de cómo hacer esto. Cualquier objeto que permita que Jetpack Compose se adhiera a cada cambio puede convertirse en State<T> y leerse mediante un elemento que admite composición.

Cómo separar el estado interno de los elementos de la IU que admiten composición

El ExpandingCard de la última sección tiene estado interno. Como resultado, el llamador no puede controlar el estado. Eso significa que, por ejemplo, si quieres iniciar un ExpandingCard en el estado desplegado, no hay forma de hacerlo. Tampoco puedes hacer que la tarjeta se expanda en respuesta a otro evento, como cuando el usuario hace clic en un Fab. También implica que no podrás mover el estado expanded a una ViewModel aunque quieras hacerlo.

Por otro lado, mediante el uso de estado interno en ExpandingCard, un llamador que no necesita controlar o elevar el estado puede usarlo sin necesidad de administrar ese estado en sí.

A medida que desarrollas elementos reutilizables que admiten composición, a menudo deseas exponer una versión con estado y otra sin estado del mismo elemento que admite composición. La versión con estado es conveniente para los llamadores a los que no les importa el estado, y la versión sin estado es necesaria para los llamadores que necesitan controlar o elevar el estado.

A fin de proporcionar interfaces con estado y sin estado, extrae un elemento sin estado que admita composición y muestre la IU mediante la elevación del estado.

Ten en cuenta que ambos elementos que admiten composición tienen el nombre ExpandingCard, a pesar de que tengan parámetros diferentes. La convención de nomenclatura para los elementos que admiten composición que emiten la IU es un sustantivo CapitalCase que describe lo que el elemento representa en la pantalla. En ese caso, ambos representan un elemento ExpandingCard. Esa convención se aplica a todas las bibliotecas de Compose, por ejemplo en TextField y TextField.

Ese es el elemento ExpandingCard dividido en elementos con estado y sin estado:

// this stateful composable is only responsible for holding internal state
// and defers the UI to the stateless composable
@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }
    ExpandingCard(
        title = title,
        body = body,
        expanded = expanded,
        onExpand = { expanded = true },
        onCollapse = { expanded = false }
    )
}

// this stateless composable is responsible for describing the UI based on the state
// passed to it and firing events in response to the buttons being pressed
@Composable
fun ExpandingCard(
    title: String,
    body: String,
    expanded: Boolean,
    onExpand: () -> Unit,
    onCollapse: () -> Unit
) {
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(title)
            if (expanded) {
                Spacer(Modifier.height(8.dp))
                Text(body)
                IconButton(onClick = onCollapse, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

La toma de estado en Compose es un patrón asociado al movimiento del estado a un llamador de un elemento que admite composición a fin de hacer que un elemento sea sin estado. El patrón general para la elevación de estado en Jetpack Compose es reemplazar la variable de estado con dos parámetros:

  • value: T: el valor actual que se mostrará
  • onValueChange: (T) -> Unit: un evento que solicita que cambie el valor, donde T es el valor nuevo propuesto

Sin embargo, no estás limitado a onValueChange. Si hay eventos más específicos adecuados para el elemento que admite composición, deberás definirlos con expresiones lambda, como hace ExpandingCard con onExpand y onCollapse.

El estado elevado de esta manera tiene algunas propiedades importantes:

  • Fuente única de información: mover el estado en lugar de duplicarlo garantizará que exista solo una fuente de información para expanded. Eso ayuda a evitar errores.
  • Encapsulamiento: solo ExpandingCard con estado podrá modificar su estado. Es completamente interno.
  • Capacidad de compartir: el estado elevado puede compartirse con varios elementos que admiten composición. Si quisiéramos ocultar un botón Fab cuando se expande Card, la elevación nos permitiría hacer eso.
  • Capacidad de interceptar: los llamadores a ExpandingCard sin estado pueden decidir ignorar o modificar eventos antes de cambiar el estado.
  • Separación: el estado para ExpandingCard sin estado se puede almacenar en cualquier lugar. Por ejemplo, ahora es posible mover title, body y expanded a un ViewModel.

Alojar de esta manera también sigue el flujo unidireccional de datos. El estado se transfiere desde el elemento con estado que admite composición, y los eventos fluyen desde el elemento sin estado.

Diagrama de flujo unidireccional de datos para ExpandingCard con estado y sin estado

Cómo restablecer el estado de la IU después de la recreación de la actividad y el proceso.

Usa rememberSaveable para restablecer el estado de tu IU después de volver a crear una actividad o un proceso. rememberSaveable conserva el estado en todas las recomposiciones. Además, rememberSaveable también lo conserva en toda la recreación de la actividad y el proceso.

@Composable
fun MyExample() {
    var selectedId by rememberSaveable<String?> { mutableStateOf(null) }
    /*...*/
}

Todos los tipos de datos que se agregan a Bundle se guardan automáticamente. Si deseas guardar algo que no se puede agregar a Bundle, tienes varias opciones.

La solución más simple es agregar la anotación @Parcelize al objeto. El objeto se vuelve parcelable y se puede empaquetar. Por ejemplo, este código hace que un tipo de datos City se vuelva parcelable y lo guarda en el estado.

@Parcelize
data class City(val name: String, val country: String)

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

Si la alternativa @Parcelize no es adecuada por algún motivo, puedes usar mapSaver para definir tu propia regla de conversión de objetos en conjuntos de valores que el sistema pueda guardar en 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, nameKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

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

Para evitar tener que definir las leyendas del mapa, también puedes usar listSaver y emplear sus índices como leyendas:

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 MyExample() {
    var selectedCity = rememberSaveable(saver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) }
    /*...*/
}

Más información

Para obtener más información sobre el estado y Jetpack Compose, realiza el codelab Cómo usar State en Jetpack Compose.