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 y composición

Compose es declarativo y, por lo tanto, la única manera de actualizarlo es llamar al mismo elemento que admite composición con argumentos nuevos. Estos argumentos son representaciones del estado de la IU. Cada vez que se actualiza un estado, se produce una recomposición. En consecuencia, elementos, como TextField, no se actualizan automáticamente de la misma manera que en las vistas imperativas que se basan en XML. A un elemento que admite composición se le debe informar, de manera explícita, el estado nuevo para que se actualice según corresponda.

@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.

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

El estado en elementos que admiten composición

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.

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.

La sintaxis del delegado by requiere las siguientes importaciones:

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

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.

Otros tipos de estado compatibles

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.

Con estado frente a sin estado

Un elemento que admite composición y usa remember para almacenar un objeto crea un estado interno, lo que genera un elemento con estado que admite composición. HelloContent es un ejemplo de un elemento con estado que admite composición, ya que mantiene y modifica su estado name de forma interna. Puede ser útil en situaciones en las que no es necesario que el llamador controle el estado, y pueda usar este estado sin tener que administrarlo por su cuenta. Sin embargo, los elementos con estado interno que admiten composición suelen ser menos reutilizables y más difíciles de probar.

Un elemento sin estado que admite composición no mantiene ningún estado. Una manera fácil de lograr este tipo de estado es usar la elevación de estado.

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.

Elevación de estado

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. Eso ayuda a evitar errores.
  • Encapsulamiento: Solo elementos con estado que admiten composición podrán modificar su estado. Es completamente interno.
  • Capacidad de compartir: El estado elevado puede compartirse con varios elementos que admiten composición. Si quisiéramos usar name en un elemento diferente que admite composición, la elevación nos permitirá hacerlo.
  • Capacidad de interceptar: Los llamadores a los elementos sin estado que admiten composición 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 name a un objeto ViewModel.

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") }
        )
    }
}

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

Los ViewModels son el contenedor de estado recomendado para elementos componibles que se encuentran en los primeros lugares del árbol de IU de Compose o que se utilizan como destinos en la biblioteca de Navigation. Los ViewModels sobreviven a los cambios de configuración, por lo que te permiten encapsular estados y eventos relacionados con la IU sin tener que lidiar con el ciclo de vida del fragmento o actividad que aloja tu código de Compose.

Tus ViewModels deben exponer el estado en un contenedor observable, como LiveData o StateFlow. Cuando el objeto de estado se lee durante una composición, el alcance de recomposición actual de la composición se suscribe automáticamente a las actualizaciones de ese objeto de estado.

Puedes tener uno o más contenedores de estado observables. Cada uno de ellos debe mantener el estado para partes de la pantalla que se relacionen de forma conceptual y que cambien. De esta manera, conservas una única fuente de información, incluso si el estado se usa en varios elementos componibles.

Puedes usar LiveData y ViewModel en Jetpack Compose para implementar el flujo de datos unidireccional. El ejemplo de HelloScreen se implementará usando un objeto 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("")

Cómo restablecer el estado en Compose

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.

Maneras de almacenar el estado

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.

Crea paquetes

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) : Parcelable

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

MapSaver

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, 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

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 CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = 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.