Google se compromete a impulsar la igualdad racial para las comunidades afrodescendientes. Obtén información al respecto.

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.

Los eventos y el bucle de actualización de la IU

En una app para Android, el estado se actualiza en respuesta a eventos. Los eventos son entradas generadas fuera de nuestra app, como cuando el usuario presiona un botón que llama a un OnClickListener, un EditText que llama a afterTextChanged o un acelerómetro que envía un valor nuevo.

Todas las apps para Android tienen un bucle de actualización principal de la IU similar al siguiente:

El bucle de actualización principal de la IU en apps para Android.

  • Evento: el usuario o alguna otra parte del programa generan un evento.
  • Estado de actualización: un controlador de eventos cambia el estado.
  • Estado de visualización: se actualiza la IU a fin de mostrar el estado nuevo.

En Jetpack Compose, el estado y los eventos son independientes entre sí. Un estado representa un valor modificable, mientras que un evento representa la notificación de que algo ocurrió.

Si separas el estado de los eventos, podrás separar la forma en que se muestra el estado de la forma en que este se almacena y se modifica.

Flujo unidireccional de datos en Jetpack Compose

Compose está diseñado para que el flujo de datos sea unidireccional. En este esquema, el estado fluye hacia abajo y los eventos lo hacen hacia arriba.

Figura 1: Flujo unidireccional de datos

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.

El bucle de actualización de la IU de una app que usa un flujo unidireccional de datos se ve de la siguiente manera:

  • Evento: parte de la IU genera un evento y lo envía hacia arriba.
  • Estado de actualización: un controlador de evento puede cambiar el estado.
  • Estado de visualización: el estado se envía hacia abajo, y la IU observa el estado nuevo y lo muestra.

Seguir ese patrón cuando usas Jetpack Compose tiene varias ventajas:

  • Capacidad de prueba: si separas el estado de la IU que lo muestra, es más fácil probar ambos de forma aislada.
  • Encapsulamiento del estado: dado que el estado solo se puede actualizar en un lugar, es menos probable que crees estados incoherentes (o errores).
  • Coherencia de la IU: todas las actualizaciones de estado se reflejan de inmediato en la IU mediante el uso de contenedores de estado observables.

ViewModel y el flujo unidireccional de datos

Cuando usas ViewModel y LiveData de los componentes de la arquitectura de Android, incorporas un flujo unidireccional de datos a tu app.

Antes de observar los objetos ViewModel con Compose, procura usar un objeto Activity por medio de las vistas de Android y del flujo unidireccional de datos que muestra "Hello, ${name}" y permite que el usuario ingrese su nombre.

Un ejemplo de entrada del usuario con ViewModels.

El código de esta pantalla con un ViewModel y un Activity es 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

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

class HelloActivity : AppCompatActivity() {
   val helloViewModel by viewModels<HelloViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* … */
       // binding represents the activity layout, inflated with ViewBinding
       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

Por medio de los componentes de la arquitectura de Android, aplicamos un diseño de flujo unidireccional de datos a este objeto Activity.

Figura 2: Flujo unidireccional de datos de un objeto Activity con ViewModel

A los efectos de ver cómo funciona el flujo unidireccional de datos en el bucle de actualización de la IU, examina el bucle de este objeto Activity:

  1. Evento: la IU llama a onNameChanged cuando cambia la entrada de texto.
  2. Estado de actualización: onNameChanged realiza el procesamiento y, luego, establece el estado de _name.
  3. Estado de visualización: se llama a los observadores de name, y la IU muestra el estado nuevo.

ViewModel y Jetpack Compose

Puedes usar LiveData y ViewModel en Jetpack Compose a fin de implementar el flujo unidireccional de datos, de la misma forma que lo hiciste con el objeto Activity de la sección anterior.

A continuación, tienes el código para la misma pantalla que HelloActivity escrito en Jetpack Compose con el mismo HelloViewModel:

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

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(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("")

   Column {
       Text(text = name)
       TextField(
           value = name,
           onValueChange = { helloViewModel.onNameChanged(it) },
           label = { Text("Name") }
       )
   }
}

HelloViewModel y HelloScreen siguen el esquema del flujo unidireccional de datos. El estado fluye desde HelloViewModel, y los eventos fluyen desde HelloScreen.

El flujo unidireccional entre ViewModel y HelloScreen.

Observa el bucle de eventos de la IU para este elemento que admite composición:

  1. Evento: se llama a onNameChanged cuando el usuario escriba un carácter.
  2. Estado de actualización: onNameChanged realiza el procesamiento y, luego, establece el estado de _name.
  3. Estado de visualización: cambia el valor de name, que Compose observa en observeAsState. A continuación, HelloScreen se ejecuta de nuevo (se recompone) a fin de describir la IU según el nuevo valor de name.

Si quieres obtener más información para usar ViewModel y LiveData a los efectos de compilar un flujo unidireccional de datos en Android, lee la guía de arquitectura de apps.

Elementos sin estado que admiten composición

Un elemento sin estado que admite composición es aquel que no puede cambiar ningún estado. Los componentes sin estado son más fáciles de probar, tienden a tener menos errores y ofrecen más oportunidades de reutilización.

Si el elemento que admite composición tiene estado, puedes dejarlo sin él mediante la elevación de estado. La elevación de estado es un patrón de programación en el que mueves el estado al llamador de un elemento que admite composición reemplazando el estado interno de ese elemento por un parámetro y eventos.

Para ver un ejemplo de elevación de estado, extrae de HelloScreen un elemento sin estado que admita composición.

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

   // name is the _current_ value of [helloViewModel.name]
   val name: String by helloViewModel.name.observeAsState("")

   HelloInput(name = name, onNameChange = { helloViewModel.onNameChanged(it) })
}

@Composable
fun HelloInput(
   /* state */ name: String,
   /* event */ onNameChange: (String) -> Unit
) {
   Column {
       Text(name)
       TextField(
           value = name,
           onValueChange = onNameChange,
           label = { Text("Name") }
       )
   }
}

HelloInput tiene acceso al estado como un parámetro String inmutable, así como un evento onNameChange 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. Aquí definimos un evento onNameChange utilizando una expresión lambda que admite un String con la sintaxis del tipo de función de Kotlin (String) -> Unit. Observa que onNameChange está en presente, ya que el evento no significa que el estado haya cambiado, sino que el elemento que admite composición está solicitando que el controlador de eventos lo cambie.

HelloScreen es un elemento con estado que admite composición porque tiene una dependencia en la clase final, HelloViewModel, que puede cambiar directamente el estado name. No hay forma de que el llamador de HelloScreen controle las actualizaciones al estado name. HelloInput es un elemento sin estado que admite composición porque no tiene la capacidad de cambiar en forma directa ningún estado.

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

El proceso de elevación de estado te permite extender el flujo unidireccional de datos a los elementos sin estado que admiten composición. El diagrama de flujo unidireccional de datos para esos elementos mantiene el flujo del estado hacia abajo y el flujo de los eventos hacia arriba a medida que más elementos interactúan con el estado.

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

Es importante entender que un elemento sin estado que admite composición aún puede interactuar con el estado que cambia conforme pasa el tiempo mediante el flujo unidireccional de datos y la elevación del estado.

A los efectos de comprender cómo funciona, observa el bucle de actualización de la IU de HelloInput:

  1. Evento: se llama a onNameChange cuando el usuario escribe un carácter.
  2. Estado de actualización: HelloInput no puede modificar el estado de forma directa. El llamador puede elegir modificar los estados en respuesta al evento onNameChange. Aquí, el llamador, HelloScreen, llamará a onNameChanged en HelloViewModel, lo que hará que se actualice el estado de name.
  3. Estado de visualización: cuando cambia el valor de name, se vuelve a llamar a HelloScreen con el name actualizado a causa de observeAsState. A su vez, llamará nuevamente a HelloInput con el nuevo parámetro name. Volver a llamar a elementos que admiten composición en respuesta a los cambios de estado se denomina recomposición.

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 tu IU.

Durante la composición inicial, Jetpack Compose hará un seguimiento de los elementos que admiten composición que llamas a fin de describir la 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 cambiaron en respuesta a los cambios de estado, y Jetpack Compose actualiza la composición para reflejar los 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 Acerca de 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.

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) }
    …
}
Figura 3: 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.

Figura 4: El elemento ExpandedCard se contrae y se despliega

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.

Figura 5: Composición de ExpandingCard con 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 desplegar 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(Icons.Default.ExpandLess)
               }
           } else {
               // change expanded in response to click events
               IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

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.

También puedes compilar capas de integración para objetos de estado no observables por medio de invalidate a fin de activar la recomposición de forma manual. Eso debe reservarse para situaciones en las que debes interoperar con un tipo no observable. El uso de invalidate es pasible al error y suele generar un código complejo que es más difícil de leer que el mismo código si se usaran objetos de estado observables.

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 admite composición que 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(Icons.Default.ExpandLess)
               }
           } else {
               IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

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.

Figura 6: Diagrama de flujo unidireccional de datos para ExpandingCard con estado y sin estado

Cambios de estado interno y de configuración

Los valores que recuerda remember en una composición se olvidan y se vuelven a crear durante los cambios de configuración, como la rotación.

Si usas remember { mutableStateOf(false) }, el objeto ExpandingCard con estado se restablecerá al modo contraído cada vez que el usuario rote el teléfono. Es posible corregir este problema usando el estado de la instancia guardada, a fin de guardar y restablecer automáticamente el estado cuando ocurra un cambio en la configuración.

@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by savedInstanceState { false }
   ExpandingCard(
       title = title,
       body = body,
       expanded = expanded,
       onExpand = { expanded = true },
       onCollapse = { expanded = false }
   )
}

La función savedInstanceState<T> que admite composición muestra un MutableState<T> que se guarda y se restablece automáticamente ante cambios de configuración. Debes usarla para cualquier estado interno que un usuario espere que permanezca vigente luego de cambiar la configuración.

Más información

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