1. Introducción
En este codelab, aprenderás sobre el estado y la forma en la que se lo puede usar y manipular mediante Jetpack Compose.
Antes de profundizar, resultará útil definir qué es exactamente el estado. En esencia, el estado de una aplicación es cualquier valor que puede cambiar con el 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 aplicaciones para Android muestran un estado al usuario. Los siguientes son algunos ejemplos de estado de este tipo de aplicaciones:
- 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
- Las calcomanías que un usuario puede dibujar sobre una imagen
En este codelab, explorarás cómo usar el estado y pensar en él a la hora de usar Jetpack Compose. Para ello, compilaremos una aplicación de tareas pendientes. Al final de este codelab, habrás compilado una IU con estado que muestre una lista de tareas pendientes interactiva y editable.
En la siguiente sección, aprenderás sobre el flujo unidireccional de datos, un patrón de diseño fundamental para comprender cómo mostrar y administrar el estado cuando usas Compose.
Qué aprenderás
- Qué es el flujo unidireccional de datos
- Cómo pensar en el estado y los eventos en una IU
- Cómo usar
ViewModel
yLiveData
del componente de la arquitectura en Compose para administrar el estado - Cómo usa Compose el estado a fin de dibujar una pantalla
- Cuándo mover el estado a un llamador
- Cómo usar el estado interno en Compose
- Cómo usar
State<T>
a los efectos de integrar el estado con Compose
Requisitos
- Android Studio Bumblebee
- Conocimientos sobre Kotlin
- Antes de este codelab, considera realizar el codelab de los principios básicos de Jetpack Compose
- Conocimientos básicos sobre Compose (como la anotación
@Composable
) - Conocimientos básicos sobre diseños de Compose (p. ej., Fila y Columna)
- Conocimientos básicos sobre modificadores (p. ej., Modifier.padding)
- Conocimientos básicos sobre
ViewModel
yLiveData
del componente de la arquitectura
Qué compilarás
- Una app interactiva de tareas pendientes que usa el flujo unidireccional de datos en Compose
2. Cómo prepararte
Para descargar la app de ejemplo, puedes optar por una de las dos opciones siguientes:
… o clona el repositorio de GitHub desde la línea de comandos con el siguiente comando:
git clone https://github.com/googlecodelabs/android-compose-codelabs.git cd android-compose-codelabs/StateCodelab
En cualquier momento, puedes ejecutar cualquiera de los módulos en Android Studio si realizas cambios en la configuración de ejecución de la barra de herramientas.
Abre el proyecto en Android Studio
- En la ventana Welcome to Android Studio, selecciona
Open an Existing Project.
- Selecciona la carpeta
[Download Location]/StateCodelab
(sugerencia: Asegúrate de seleccionar el directorioStateCodelab
que contienebuild.gradle
). - Cuando Android Studio haya importado el proyecto, prueba si puedes ejecutar los módulos
start
yfinished
.
Explora el código de inicio
El código de inicio contiene cuatro paquetes:
examples
: Brinda actividades de ejemplo para explorar los conceptos del flujo unidireccional de datos (no necesitarás editar este paquete)ui
: Contiene temas generados automáticamente por Android Studio cuando se inicia un nuevo proyecto de Compose (no necesitarás editar este paquete)util
: Contiene un código de ayuda para el proyecto (no necesitarás editar este paquete)todo
: Es el paquete que contiene el código de la pantalla de tareas pendientes que estamos compilando (realizarás modificaciones a este paquete)
Este codelab se enfocará en los archivos del paquete todo
. En el módulo start
, hay varios archivos con los que debes familiarizarte.
Archivos proporcionados en el paquete de todo
Data.kt
: Estructuras de datos usadas para representar unTodoItem
TodoComponents.kt
: Elementos reutilizables que admiten composición y que usarás para compilar la pantalla de tareas pendientes (no necesitarás editar este archivo)
Archivos que editarás en el paquete de todo
TodoActivity.kt
: Actividad de Android que usará Compose a fin de dibujar una pantalla de tareas pendientes una vez que hayas terminado este codelabTodoViewModel.kt
: UnViewModel
que integrarás con Compose para compilar la pantalla de tareas pendientes (lo conectarás a Compose y lo extenderás con el fin de agregar más funciones a medida que avances en este codelab)TodoScreen.kt
: Implementación de Compose de una pantalla de tareas pendientes que compilarás durante este codelab
3. Cómo comprender el flujo unidireccional de datos
El bucle de actualización de la IU
Antes de pasar a la app de tareas pendientes, exploremos los conceptos de flujo unidireccional de datos con el sistema de vistas de Android.
¿Por qué se actualiza el estado? En la introducción, hablamos sobre el estado como cualquier valor que cambia con el tiempo. Esto es solo parte de la historia relativa al estado en una aplicación para Android.
En las apps para Android, el estado se actualiza en respuesta a eventos. Los eventos son entradas generadas fuera de nuestra aplicación, 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.
En todas las apps para Android, hay un bucle de actualización principal de la IU similar al siguiente:
- Evento: El usuario o alguna otra parte del programa generan un evento.
- Estado de actualización: Un controlador de eventos cambia el estado que usa la IU.
- Estado de visualización: Se actualiza la IU a fin de mostrar el estado nuevo.
Para administrar el estado en Compose, debes comprender cómo el estado y los eventos interactúan entre sí.
Estado no estructurado
Antes de trabajar en Compose, exploremos los eventos y el estado en el sistema de vistas de Android. Como estado de "Hello World", compilaremos una Activity
de Hello World que le permita al usuario ingresar su nombre.
Una forma de escribir esto es hacer que la devolución de llamada del evento establezca directamente el estado en la TextView, y el código, con ViewBinding
, podría verse de la siguiente manera:
HelloCodelabActivity**.kt**
class HelloCodelabActivity : AppCompatActivity() {
private lateinit var binding: ActivityHelloCodelabBinding
var name = ""
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {text ->
name = text.toString()
updateHello()
}
}
private fun updateHello() {
binding.helloText.text = "Hello, $name"
}
}
Un código como este funciona y es adecuado para un ejemplo pequeño como este. Sin embargo, tiende a ser difícil de administrar a medida que la IU crece.
Cuando agregas más eventos y estados a una Actividad compilada de esta manera, pueden surgir varios problemas:
- Pruebas: Como el estado de la IU está entrelazado con las
Views
, puede ser difícil probar este código. - Actualizaciones parciales del estado: Cuando la pantalla tiene muchos más eventos, es fácil olvidar actualizar parte del estado en respuesta a un evento. Como resultado, el usuario podría ver una IU incoherente o incorrecta.
- Actualizaciones parciales de la IU: Dado que actualizamos la IU de forma manual después de cada cambio de estado, es muy fácil olvidar esto a veces. Como resultado, el usuario puede ver datos inactivos en su IU que se actualizan de forma aleatoria.
- Complejidad del código: Es difícil extraer parte de la lógica cuando se codifica en este patrón. Como resultado, el código tiende a ser difícil de leer y entender.
Usa el flujo unidireccional de datos
Para ayudar a solucionar estos problemas con el estado no estructurado, presentamos los componentes de la arquitectura de Android que contienen ViewModel
y LiveData
.
Un elemento ViewModel
te permite extraer estado de tu IU y definir eventos que la IU puede llamar a fin de actualizar ese estado. Veamos la misma Actividad escrita con un ViewModel
.
HelloCodelabActivity.kt
class HelloCodelabViewModel: 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 HelloCodeLabActivityWithViewModel : AppCompatActivity() {
private val helloViewModel by viewModels<HelloCodelabViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {
helloViewModel.onNameChanged(it.toString())
}
helloViewModel.name.observe(this) { name ->
binding.helloText.text = "Hello, $name"
}
}
}
En este ejemplo, cambiamos el estado de la Activity
a un ViewModel
. En un ViewModel, el estado se representa mediante LiveData
. Un LiveData
es un contenedor de estado observable, lo que significa que proporciona una forma de observar cambios en el estado. Luego, en la IU, usaremos el método observe
para actualizar la IU cada vez que cambie el estado.
El ViewModel
también expone un evento: onNameChanged
. La IU llama a este evento en respuesta a los eventos del usuario, como lo que sucede aquí cada vez que cambia el texto de EditText
.
Si volvemos al bucle de actualización de la IU que mencionamos antes, podemos ver cómo ViewModel
se combina con los eventos y el estado.
- Evento: La IU llama a
onNameChanged
cuando cambia la entrada de texto. - Estado de actualización:
onNameChanged
realiza el procesamiento y, luego, establece el estado de_name
. - Estado de visualización: Se llama a los observadores de
name
, que notifican a la IU de los cambios de estado.
Si estructuramos nuestro código de esta manera, podemos pensar en eventos que fluyen "hacia arriba", al ViewModel
. Luego, en respuesta a los eventos, ViewModel
realizará un procesamiento y, posiblemente, actualizará el estado. Cuando se actualiza el estado, este fluye "hacia abajo", a la Activity
.
Este patrón se denomina flujo unidireccional de datos. Se trata de un diseño en el que el estado fluye hacia abajo y los eventos lo hacen hacia arriba. Cuando estructuramos el código de esta manera, obtenemos algunas ventajas:
- Capacidad de prueba: Si desacoplas el estado de la IU que lo muestra, es más fácil probar tanto el ViewModel como la Actividad.
- Encapsulamiento de estado: Como el estado solo se puede actualizar en un lugar (el
ViewModel
), es menos probable que se introduzca un error de actualización de estado parcial a medida que la IU crece. - Coherencia de la IU: Todas las actualizaciones de estado se reflejan de inmediato en la IU mediante el uso de contenedores de estado observables.
De esta manera, aunque este enfoque agrega un poco más de código, tiende a ser más fácil y confiable para manejar estados y eventos complejos mediante el flujo unidireccional de datos.
En la siguiente sección, veremos cómo usar el flujo unidireccional de datos con Compose.
4. Compose y ViewModels
En la sección anterior, exploramos el flujo unidireccional de datos en el sistema de Android View con ViewModel
y LiveData
. Ahora, pasaremos a Compose y exploraremos cómo usar el flujo unidireccional de datos en Compose con ViewModels
.
Al final de esta sección, habrás compilado esta pantalla:
Explora los elementos de TodoScreen que admiten composición
El código que descargaste contiene varios elementos que admiten composición y que usarás y editarás a lo largo de este codelab.
Abre TodoScreen.kt
y observa el elemento TodoScreen
que admite composición.
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
/* ... */
}
Si deseas ver lo que muestra este elemento que admite composición, usa el panel de vista previa en Android Studio. Para ello, haz clic en el ícono de dividir en la esquina superior derecha .
Este elemento muestra una lista editable de tareas pendientes, pero no tiene ningún estado propio. Recuerda que el estado es cualquier valor que pueda cambiar; sin embargo, ninguno de los argumentos de TodoScreen puede modificarse.
items
: Una lista inmutable de elementos para mostrar en la pantallaonAddItem
: Un evento para cuando el usuario solicita agregar un elementoonRemoveItem
: Un evento para cuando el usuario solicita quitar un elemento
De hecho, este elemento que admite composición es uno sin estado. Solo muestra la lista de elementos que se pasó y no tiene forma de editarla directamente. En su lugar, recibe dos eventos onRemoveItem
y onAddItem
que pueden solicitar cambios.
Esto genera la siguiente pregunta: si es un elemento sin estado, ¿cómo puede mostrar una lista editable? Para ello, se usa una técnica llamada elevación de estado. La elevación de estado es el patrón en el que el estado se mueve hacia arriba a fin de dejar a un componente sin 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.
Resulta que la combinación de estos parámetros funciona para permitir que el llamador eleve el estado de este elemento que admite composición. A fin de ver cómo funciona, exploremos el bucle de actualización de la IU de este elemento.
- Evento: Cuando el usuario solicita que se agregue o quite un elemento,
TodoScreen
llama aonAddItem
oonRemoveItem
. - Estado de actualización: El llamador de
TodoScreen
puede responder a estos eventos actualizando el estado. - Estado de visualización: Cuando se actualice el estado, se volverá a llamar a
TodoScreen
con lositems
nuevos y podrá mostrarlos en la pantalla.
El llamador tiene la responsabilidad de determinar dónde y cómo conservar este estado. Puede almacenar items
de cualquier manera que resulte lógica, por ejemplo, en la memoria, o leerlos desde una base de datos de Room. TodoScreen
está desacoplado por completo de la forma en que se administra el estado.
Define un elemento TodoActivityScreen que admite composición
Abre TodoViewModel.kt
y busca un ViewModel
existente que defina una variable de estado y dos eventos.
TodoViewModel.kt
class TodoViewModel : ViewModel() {
// state: todoItems
private var _todoItems = MutableLiveData(listOf<TodoItem>())
val todoItems: LiveData<List<TodoItem>> = _todoItems
// event: addItem
fun addItem(item: TodoItem) {
/* ... */
}
// event: removeItem
fun removeItem(item: TodoItem) {
/* ... */
}
}
Queremos usar este ViewModel
para elevar el estado de TodoScreen
. Cuando hayamos terminado, crearemos un diseño de flujo unidireccional de datos similar a este:
Para comenzar a integrar TodoScreen
en TodoActivity
, abre TodoActivity.kt
y define una nueva función @Composable
, TodoActivityScreen(todoViewModel: TodoViewModel)
, y llámala desde setContent
en onCreate
.
En el resto de esta sección, compilaremos el elemento TodoActivityScreen
de a un paso a la vez. Puedes comenzar por llamar a TodoScreen
con un estado y eventos falsos de esta manera:
TodoActivity.kt
import androidx.compose.runtime.Composable
class TodoActivity : AppCompatActivity() {
private val todoViewModel by viewModels<TodoViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
StateCodelabTheme {
Surface {
TodoActivityScreen(todoViewModel)
}
}
}
}
}
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>() // in the next steps we'll complete this
TodoScreen(
items = items,
onAddItem = { }, // in the next steps we'll complete this
onRemoveItem = { } // in the next steps we'll complete this
)
}
Este elemento que admite composición será un puente entre el estado almacenado en nuestro ViewModel y el elemento TodoScreen
que ya está definido en el proyecto. Puedes cambiar TodoScreen
a fin de tomar el ViewModel
directamente, pero entonces TodoScreen
sería un poco menos reutilizable. Cuando optas por parámetros más simples, como List<TodoItem>
, TodoScreen
no se acopla al lugar específico en el que se elevó el estado.
Si ejecutas la app ahora mismo, verás que muestra un botón, pero, si haces clic en él, no hará nada. Esto se debe a que aún no conectamos nuestro ViewModel
a TodoScreen
.
Haz que los eventos fluyan hacia arriba
Ahora que tenemos todos los componentes que necesitamos (un ViewModel
, un puente TodoActivityScreen
que admite composición y TodoScreen
), conectemos todo a fin de mostrar una lista dinámica mediante el flujo unidireccional de datos.
En TodoActivityScreen
, pasa addItem
y removeItem
desde el ViewModel
.
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>()
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
Cuando TodoScreen
llama a onAddItem
o onRemoveItem
, podemos pasar la llamada al evento correcto en nuestro ViewModel
.
Pasa el estado hacia abajo
Ya conectamos los eventos de nuestro flujo unidireccional de datos; ahora tenemos que pasar el estado hacia abajo.
Edita TodoActivityScreen
a fin de observar el LiveData
de todoItems
mediante observeAsState
:
TodoActivity.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
Esta línea observará el LiveData
y nos permitirá usar el valor actual directamente como List<TodoItem>
.
Hay muchos elementos en esta línea, así que vamos a revisarlos:
val items: List<TodoItem>
declara una variableitems
de tipoList<TodoItem>
.todoViewModel.todoItems
es unLiveData<List<TodoItem>
delViewModel
..observeAsState
observa unLiveData<T>
y lo convierte en un objetoState<T>
de modo que Compose pueda reaccionar a los cambios de valor.listOf()
es un valor inicial para evitar posibles resultadosnull
antes de que se inicialiceLiveData
. Si no se pasara,items
seríaList<TodoItem>?
, que es anulable.by
es la sintaxis delegada de la propiedad en Kotlin, y nos permite desunir automáticamenteState<List<TodoItem>>
deobserveAsState
y convertirla en un elementoList<TodoItem>
normal.
Vuelve a ejecutar la app
Vuelve a ejecutar la app y verás una lista que se actualiza de forma dinámica. Cuando hagas clic en el botón en la parte inferior, se agregarán nuevos elementos, que se quitarán cuando hagas clic en ellos.
En esta sección, descubrimos cómo compilar un diseño de flujo unidireccional de datos en Compose mediante ViewModels
. También vimos cómo usar un elemento sin estado que admite composición a fin de mostrar una IU con estado mediante una técnica llamada elevación de estado. Además, exploramos cómo pensar en las IU dinámicas en términos de estado y eventos.
En la siguiente sección, veremos cómo agregar memoria a funciones que admiten composición.
5. Memoria en Compose
Ahora que aprendimos a usar Compose con ViewModels para compilar un flujo unidireccional de datos, exploremos cómo Compose puede interactuar con el estado de forma interna.
En la última sección, viste cómo Compose actualiza la pantalla llamando nuevamente a los elementos que admiten composición. Se trata de un proceso llamado recomposición. Pudimos mostrar una lista dinámica llamando a TodoScreen
una vez más.
En esta sección y en la siguiente, veremos cómo hacer elementos que admiten composición con estado.
En esta sección, analizaremos cómo agregar memoria a una función que admite composición. Este componente es fundamental y lo necesitaremos a los efectos de agregar estado a Compose en la siguiente sección.
Diseño desordenado
Simulación del diseñador
Para esta sección, un nuevo diseñador de tu equipo te dio una simulación en función de las últimas tendencias de diseño, con un diseño desordenado. El principio fundamental del diseño desordenado consiste en tomar un buen diseño y agregarle cambios que parezcan aleatorios para que resulte "interesante".
En este diseño, el tono de cada ícono se ajusta a un valor alfa aleatorio entre 0.3 y 0.9.
Agrega la aleatorización a un elemento que admite composición
Para comenzar, abre TodoScreen.kt
y busca el elemento TodoRow
que admite composición. Este elemento describe una sola fila en la lista de tareas pendientes.
Define un elemento val iconAlpha
nuevo con un valor de randomTint()
. Es un número de punto flotante entre 0.3 y 0.9, como solicitó nuestro diseñador. Luego, establece el tono del ícono.
TodoScreen.kt
import androidx.compose.material.LocalContentColor
@Composable
fun TodoRow(todo: TodoItem, onItemClicked: (TodoItem) -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
val iconAlpha = randomTint()
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
Si vuelves a revisar la vista previa, verás que el ícono ahora tiene un color de tono aleatorio.
Explora la recomposición
Vuelve a ejecutar la app para probar el nuevo diseño desordenado, notarás de inmediato que los tonos parecen cambiar todo el tiempo. Tu diseñador te dice que, si bien estábamos por hacer esto de forma aleatoria, esto parece demasiado.
App con íconos que cambian de tono cuando cambia la lista
¿Qué sucede aquí? Resulta que el proceso de recomposición está llamando a randomTint
para cada fila en la pantalla cada vez que cambia la lista.
La recomposición es el proceso de volver a llamar a los elementos que admiten composición con entradas nuevas para actualizar el árbol de composición. En este caso, cuando se vuelva a llamar a TodoScreen
con una lista nueva, LazyColumn
volverá a componer todos los elementos secundarios en la pantalla. Esto llamará a TodoRow
nuevamente y generará un nuevo tono aleatorio.
Compose genera un árbol, pero es un poco diferente del árbol de IU que quizás conozcas del sistema de vistas de Android. En lugar de un árbol de widgets de IU, Compose genera un árbol de elementos que admiten composición. Podemos visualizar TodoScreen
de la siguiente manera:
Árbol de TodoScreen
Cuando Compose ejecuta la composición la primera vez, compila un árbol de cada elemento que admite composición al que se llamó. Luego, durante la recomposición, actualiza el árbol con los nuevos elementos que se llamen.
El ícono se actualiza cada vez que se recompone TodoRow
porque TodoRow
tiene un efecto secundario oculto. Un efecto secundario es cualquier cambio visible fuera de la ejecución de una función que admite composición.
La llamada a Random.nextFloat()
actualiza la variable aleatoria interna que se usa en un generador de números más o menos aleatorios. Así es como Random
muestra un valor diferente cada vez que solicitas un número al azar.
Presentación de la memoria para funciones que admiten composición
No queremos que cambie el tono cada vez que se recomponga TodoRow
. Para ello, necesitamos un lugar mediante el que se recuerde el tono que usamos en la última composición. Compose nos permite almacenar valores en el árbol de composición de modo que podamos actualizar TodoRow
a fin de almacenar el iconAlpha
en el árbol de composición.
Edita TodoRow
y rodea la llamada a randomTint
con remember
de la siguiente manera:
TodoScreen.kt
val iconAlpha: Float = remember(todo.id) { randomTint() }
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
En el nuevo árbol de composición de TodoRow
, puedes ver que se agregó iconAlpha
:
Árbol de TodoRow con remember
Si vuelves a ejecutar la app, verás que el tono no se actualiza cada vez que cambia la lista. En su lugar, cuando se realiza la recomposición, se muestra el valor anterior que almacenó remember
.
Si observas con atención la llamada a remember, verás que pasaremos todo.id
como el argumento key
.
remember(todo.id) { randomTint() }
Una llamada a remember tiene dos partes:
- argumentos clave: La "clave" que usa remember, esta es la parte que se pasa entre paréntesis (en este caso, pasamos
todo.id
como clave) - cálculo: Una expresión lambda que calcula un valor nuevo que se recordará y se pasa en una lambda final (en este caso, calculamos un valor aleatorio con
randomTint()
)
La primera vez que se realice la composición, remember siempre llamará a randomTint
y recordará el resultado en la siguiente recomposición. También hará un seguimiento del todo.id
que también se pasó. Luego, durante la recomposición, omitirá la llamada a randomTint
y mostrará el valor recordado, a menos que se pase un nuevo todo.id
a TodoRow
.
La recomposición de un elemento que admite composición debe ser idempotente. Cuando rodeamos la llamada a randomTint
con remember
, omitimos la llamada de aleatorización durante la recomposición, a menos que cambie el elemento de tareas pendientes. Como resultado, TodoRow
no tiene efectos secundarios, siempre produce el mismo resultado cada vez que se recompone con la misma entrada y es idempotente.
Haz que se puedan controlar los valores recordados
Si ejecutas la app ahora, observarás que muestra un tono aleatorio en cada ícono. A tu diseñador le complace que esto siga los principios del diseño desordenado y lo aprueba para el envío.
Pero antes de que lo verifiques y hagas ese envío, debes realizar un cambio menor en el código. Por el momento, no hay forma de que el llamador de TodoRow
especifique el tono. Hay muchas razones por las que tal vez quieran hacerlo (por ejemplo, el vicepresidente de Productos observa esta pantalla y solicita una revisión a fin de eliminar el desorden antes de que se envíe la app).
Para permitir que el llamador controle este valor, simplemente mueve la llamada de remember a un argumento predeterminado de un parámetro iconAlpha
nuevo.
@Composable
fun TodoRow(
todo: TodoItem,
onItemClicked: (TodoItem) -> Unit,
modifier: Modifier = Modifier,
iconAlpha: Float = remember(todo.id) { randomTint() }
) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
Ahora el llamador obtiene el mismo comportamiento de forma predeterminada: TodoRow
calcula un randomTint
. Sin embargo, se puede especificar cualquier alfa que se desee. Si permites que el llamador controle el alphaTint
, este elemento será más reutilizable. En otra pantalla, es posible que el diseñador quiera mostrar todos los íconos con un alfa de 0.7.
También hay un error muy sutil en nuestro uso de remember
. Intenta agregar suficientes filas de tareas pendientes a fin de desplazarte algunas fuera de la pantalla. Para ello, haz clic de forma repetitiva en "Add random todo" y, luego, desplázate. A medida que te desplaces, notarás que los íconos cambian de alfa cada vez que se desplazan nuevamente a la pantalla.
En las próximas secciones, exploraremos el estado y su elevación, lo que te brindará las herramientas que necesitas para corregir errores como estos.
6. Estado en Compose
En la última sección, aprendimos cómo las funciones que admiten composición tienen memoria. Ahora, exploraremos el uso de esa memoria a fin de agregar estado a un elemento que admite composición.
Entrada de tarea pendiente (estado: expandido) 
Entrada de tarea pendiente (estado: contraído) 
Nuestro diseñador pasó de querer un diseño desordenado y ahora quiere uno posmoderno. El nuevo diseño de entrada de tareas pendientes ocupa el mismo espacio que un encabezado contraíble y tiene dos estados principales: expandido y contraído. Se mostrará la versión expandida cuando el texto no esté vacío.
Para compilar esto, primero nos enfocaremos en el texto y el botón, y luego veremos cómo agregar los íconos de ocultamiento automático.
La edición de texto en una IU es una función con estado. El usuario actualiza el texto que se muestra actualmente cada vez que escribe un carácter o incluso cuando cambia la selección. En el sistema de vistas de Android, este estado es interno de EditText
y se expone a través de objetos de escucha de onTextChanged
. Sin embargo, dado que Compose está diseñado para el flujo unidireccional de datos, esto no sería apropiado.
TextField
en Compose es un elemento sin estado que admite composición. Al igual que la TodoScreen
, que muestra una lista cambiante de tareas pendientes, un TextField
simplemente muestra lo que le indiques y emite eventos cuando el usuario escribe.
Crea un elemento TextField con estado que admita composición
A fin de comenzar a explorar el estado en Compose, crearemos un componente con estado para mostrar un TextField
editable.
Para comenzar, abre TodoScreen.kt
y agrega la siguiente función:
TodoScreen.kt
import androidx.compose.runtime.mutableStateOf
@Composable
fun TodoInputTextField(modifier: Modifier) {
val (text, setText) = remember { mutableStateOf("") }
TodoInputText(text, setText, modifier)
}
Esta función usa remember
para agregar memoria a sí misma, en la cual almacena un mutableStateOf
a fin de crear una MutableState<String>
, que es un tipo integrado de Compose que proporciona un contenedor de estado observable.
Debido a que pasaremos inmediatamente un valor y un evento de método set a TodoInputText
, desestructuraremos el objeto MutableState
en un método get y un método set.
Así de simple. Creamos un estado interno en TodoInputTextField
.
Para ver cómo funciona, define otro elemento TodoItemInput
que admite composición que muestre el TodoInputTextField
y un Button
.
TodoScreen.kt
import androidx.compose.ui.Alignment
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
// onItemComplete is an event will fire when an item is completed by the user
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputTextField(Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = { /* todo */ },
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
}
TodoItemInput
tiene un solo parámetro, un evento onItemComplete
. Cuando el usuario completa un TodoItem
, se activa el evento. Este patrón de pasar una expresión lambda es la manera principal en la que defines eventos personalizados en Compose.
Además, actualiza el elemento TodoScreen
que admite composición a fin de llamar a TodoItemInput
en el elemento TodoItemInputBackground
en el segundo plano que ya está definido en el proyecto:
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
Column {
// add TodoItemInputBackground and TodoItem at the top of TodoScreen
TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
TodoItemInput(onItemComplete = onAddItem)
}
...
Prueba TodoItemInput
Dado que acabamos de definir un elemento fundamental de la IU que admite composición para el archivo, es una buena idea agregarle una @Preview
. Esto nos permitirá explorar ese elemento de forma aislada, y los lectores del archivo también podrán obtener una vista previa rápidamente.
En TodoScreen.kt
, agrega una nueva función de vista previa a la parte inferior:
TodoScreen.kt
@Preview
@Composable
fun PreviewTodoItemInput() = TodoItemInput(onItemComplete = { })
Ahora puedes ejecutar ese elemento que admite composición en la vista previa interactiva o bien en un emulador para depurar ese elemento de forma aislada.
Cuando hagas esto, verás que aparece de forma correcta un campo de texto editable que le permite al usuario editar el texto. Cada vez que escriben un carácter, se actualiza el estado, lo que activa la recomposición y la actualización del TextField
que se muestra al usuario.
Haz que el clic en el botón agregue un elemento
Ahora queremos que el botón "Add" agregue un TodoItem
. Para hacerlo, necesitaremos acceso al text
desde el TodoInputTextField
.
Si observas una parte del árbol de composición de TodoItemInput
, verás que almacenamos el estado del texto dentro de TodoInputTextField
.
Árbol de composición de TodoItemInput (los elementos integrados que admiten composición están ocultos)
Esta estructura no nos permitirá activar onClick
, ya que onClick
necesita acceder al valor actual del elemento text
. Lo que queremos hacer es exponer el estado del text
a TodoItemInput
y usar un flujo unidireccional de datos al mismo tiempo.
El flujo unidireccional de datos se aplica a la arquitectura de alto nivel y al diseño de un solo elemento que admite composición cuando se usa Jetpack Compose. Queremos que los eventos siempre fluyan hacia arriba y que el estado siempre lo haga hacia abajo.
Esto significa que queremos que el estado fluya hacia abajo desde TodoItemInput
y que los eventos lo hagan hacia arriba.
Diagrama de flujo unidireccional de datos para TodoItemInput
Para lograrlo, necesitaremos mover el estado del elemento secundario que admite composición, TodoInputTextField
, al elemento superior TodoItemInput
.
Árbol de composición de TodoItemInput con la elevación del estado (los elementos integrados que admiten composición están ocultos)
Este patrón se conoce como elevación de estado. "Elevaremos" (o levantaremos) el estado de un elemento componible a fin de dejarlo sin estado. La elevación de estado es el patrón principal para compilar diseños de flujo unidireccional de datos en Compose.
Para comenzar la elevación de estado, puedes refactorizar cualquier estado interno T
de un elemento que admite composición con el par de parámetros (value: T, onValueChange: (T) -> Unit)
.
Edita TodoInputTextField
a los efectos de elevar el estado mediante la adición de parámetros (value, onValueChange)
:
TodoScreen.kt
// TodoInputTextField with hoisted state
@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
TodoInputText(text, onTextChange, modifier)
}
Este código agrega un parámetro value
y onValueChange
a TodoInputTextField
. El parámetro de valor es text
, y el parámetro onValueChange
es onTextChange
.
A continuación, ya que se elevó el estado, quitaremos el estado recordado de TodoInputTextField
.
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 el texto. Eso ayuda a evitar errores.
- Encapsulación: Solo
TodoItemInput
podrá modificar el estado, mientras que otros componentes podrán enviar eventos aTodoItemInput
. Cuando se realiza esta elevación, solo uno de los elementos que admiten composición tendrá estado, a pesar de que varios de ellos lo usen. - Capacidad de compartir: El estado elevado puede compartirse como un valor inmutable con varios elementos que admiten composición. Aquí, usaremos el estado en
TodoInputTextField
yTodoEditButton
. - Capacidad de interceptar:
TodoItemInput
pueden decidir ignorar o modificar eventos antes de cambiar su estado. Por ejemplo,TodoItemInput
podría darles formato de emoji a los :emoji-codes: a medida que el usuario escribe. - Separación: El estado para
TodoInputTextField
se puede almacenar en cualquier lugar. Por ejemplo, podríamos optar por respaldar este estado con una base de datos de Room que se actualice cada vez que se escribe un carácter sin modificarTodoInputTextField
.
Ahora, agrega el estado en TodoItemInput
y pásalo a TodoInputTextField
:
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputTextField(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = { /* todo */ },
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
}
Ya elevamos el estado y podemos usar el valor actual del elemento texto para impulsar el comportamiento del TodoEditButton
. Finaliza la devolución de llamada y enable
el botón solo cuando el texto no esté en blanco, según el diseño:
TodoScreen.kt
// edit TodoItemInput
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text)) // send onItemComplete event up
setText("") // clear the internal text
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank() // enable if text is not blank
)
Usamos la misma variable de estado, text
, en dos elementos diferentes que admiten composición. Si elevamos el estado, podemos compartirlo de esta manera. Logramos hacerlo y, al mismo tiempo, hicimos que solo TodoItemInput
sea un elemento que admite composición con estado.
Vuelve a ejecutarla
Vuelve a ejecutar la app y verás que ahora puedes agregar elementos de tareas pendientes. ¡Felicitaciones! Aprendiste a agregar el estado a un elemento que admite composición y a elevarlo.
Limpieza de código
Antes de continuar, intercala el elemento TodoInputTextField
. Acabamos de agregarlo en esta sección para explorar la elevación de estado. Si observas el código de TodoInputText
que se proporcionó con el codelab, verás que ya eleva el estado siguiendo los patrones que analizamos en esta sección.
Cuando termines, tu archivo TodoItemInput
debería verse así:
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text))
setText("")
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
}
}
En la próxima sección, seguiremos compilando este diseño y agregaremos los íconos. Usarás las herramientas que aprendimos en esta sección para elevar el estado y compilar IU interactivas con flujo unidireccional de datos.
7. IU dinámica basada en el estado
En la última sección, aprendiste a agregar el estado a un elemento que admite composición y a usar la elevación de estado para hacer que un elemento que admite composición no tenga estado.
Ahora, exploraremos la compilación de una IU dinámica basada en el estado. En la simulación del diseñador, debemos mostrar la fila de íconos cuando el texto no esté en blanco.
Entrada de tareas pendientes (estado: expandido, sin texto en blanco) 
Entrada de tareas pendientes (estado: contraído; con texto en blanco) 
Deriva elementos iconsVisible a partir del estado
Abre TodoScreen.kt
y crea una nueva variable de estado para conservar el icon
seleccionado actualmente y un nuevo val
iconsVisible
que sea verdadero cada vez que el texto no esté en blanco.
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
// ...
Agregamos un segundo fragmento de estado, icon
, que contiene el ícono seleccionado actualmente.
El valor iconsVisible
no agrega un estado nuevo a TodoItemInput
. No hay forma de que TodoItemInput
lo cambie de forma directa. En cambio, se basa por completo en el valor del elemento text
. Independientemente del valor del elemento text
que se encuentra en esta recomposición, se configurará iconsVisible
según corresponda y podremos usarlo para mostrar la IU correcta.
Podríamos agregar otro fragmento de estado a TodoItemInput
a fin de controlar cuándo los íconos serán visibles, pero si observas de cerca las especificaciones, la visibilidad se basará por completo en el texto que se haya ingresado. Si estableciéramos dos estados, sería fácil para ellos estar desincronizados.
En cambio, preferimos tener una única fuente de información. En este elemento que admite composición, solo necesitamos que text
sea un estado y que iconsVisible
se pueda basar en text
.
Continúa editando TodoItemInput
a fin de mostrar AnimatedIconRow
en función del valor de iconsVisible
. Si iconsVisible
es verdadero, muestra una AnimatedIconRow
. Si es falso, muestra un Espaciador con 16.dp
.
TodoScreen.kt
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
Column {
Row( /* ... */ ) {
/* ... */
}
if (iconsVisible) {
AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Si vuelves a ejecutar la app, verás que los íconos se animan cuando ingresas texto.
Aquí cambiaremos dinámicamente el árbol de composición en función del valor de iconsVisible
. A continuación, se muestra un diagrama del árbol de composición para ambos estados.
Este tipo de lógica de muestra condicional es equivalente a la visibilidad perdida en el sistema de vistas de Android.
Árbol de composición de TodoItemInput cuando cambia el elemento iconsVisible
Si vuelves a ejecutar la app, verás que la fila de íconos se muestra correctamente, pero si haces clic en "Add", el ícono no aparece en la fila de tareas pendientes agregada. Esto se debe a que no hemos actualizado el evento para pasar el nuevo estado del ícono. Hagamos eso.
Actualiza el evento de modo que use el ícono
Edita TodoEditButton
en TodoItemInput
para usar el nuevo estado del icon
en el objeto de escucha onClick
.
TodoScreen.kt
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
Puedes usar el nuevo estado del icon
directamente en el objeto de escucha onClick
. También lo restableceremos al valor predeterminado cuando el usuario termine de ingresar un TodoItem
.
Si ejecutas la app ahora, verás una entrada interactiva de tareas pendientes con botones animados. Bien hecho.
Finaliza el diseño con una imeAction
Cuando le muestres la app a tu diseñador, este te indicará que debes enviar el elemento de tarea pendiente de la acción de IME en el teclado. Es el botón azul que se encuentra en la esquina inferior derecha:
Teclado de Android con ImeAction.Done
TodoInputText
te permite responder a imeAction con su evento onImeAction
.
Queremos que onImeAction
tenga el mismo comportamiento que el TodoEditButton
. Podríamos duplicar el código, pero sería difícil mantenerlo con el tiempo, ya que sería fácil actualizar solo uno de los eventos.
Extraigamos el evento a una variable de modo que podamos usarla para la onImeAction
de TodoInputText
y el elemento onClick
de TodoEditButton
.
Vuelve a editar TodoItemInput
a fin de declarar una nueva función lambda submit
que controle el usuario que realiza una acción de envío. Luego, pasa la función lambda recién definida a TodoInputText
y TodoEditButton
.
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
onImeAction = submit // pass the submit callback to TodoInputText
)
TodoEditButton(
onClick = submit, // pass the submit callback to TodoEditButton
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
if (iconsVisible) {
AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Si lo deseas, puedes extraer la lógica de esta función. Sin embargo, como este elemento que admite composición se ve bastante bien, nos detendremos aquí.
Esta es una de las grandes ventajas de Compose. Si declaras tu IU en Kotlin, podrás compilar las abstracciones necesarias de modo que el código se desacople y pueda volver a usarse.
Para controlar el trabajo con el teclado, TextField
proporciona dos parámetros:
keyboardOptions
: Se usa para habilitar que se muestre la acción de IME lista.keyboardActions
: Se usa para especificar la acción que se activará en respuesta a acciones específicas de IME; en nuestro caso, una vez que se presiona Done, se debe llamar asubmit
y ocultar el teclado.
A los efectos de controlar el teclado en pantalla, usaremos LocalSoftwareKeyboardController.current
. Dado que esta es una API experimental, tendremos que anotar la función con @OptIn(ExperimentalComposeUiApi::class)
.
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputText(
text: String,
onTextChange: (String) -> Unit,
modifier: Modifier = Modifier,
onImeAction: () -> Unit = {}
) {
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = text,
onValueChange = onTextChange,
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
maxLines = 1,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
onImeAction()
keyboardController?.hide()
}),
modifier = modifier
)
}
Vuelve a ejecutar la app para probar los nuevos íconos
Ejecuta la app nuevamente. Verás que los íconos se muestran y se ocultan automáticamente a medida que cambia el estado del texto. También puedes cambiar la selección del ícono. Cuando presiones el botón "Add", verás que se genera un nuevo TodoItem basado en los valores de entrada.
¡Felicitaciones! Aprendiste acerca del estado en Compose, la elevación de estado y la forma de compilar IU dinámicas basadas en el estado.
En las próximas secciones, exploraremos cómo pensar en crear componentes reutilizables que interactúen con el estado.
8. Cómo extraer elementos sin estado que admiten composición
Tu diseñador hoy está trabajando en una tendencia de diseño nueva. Atrás quedaron las IU desordenadas y el posmodernismo: el diseño de esta semana sigue la tendencia de diseño "interactiva y neomoderna". Les preguntaste qué significaba eso, y la respuesta fue un poco confusa e involucraba emojis, pero, de todos modos, estas son las simulaciones.
Simulación para el modo de edición
El diseñador dice que reutiliza la misma IU que la entrada con los botones transformados en emojis guardados y listos.
Al final de la última sección, dejamos TodoItemInput
como un elemento con estado que admite composición. Esto estaba bien cuando solo se trataba de ingresar tareas pendientes, pero ahora que se trata de un editor, tendrá que admitir la elevación de estado.
En esta sección, aprenderás a extraer el estado de un elemento con estado que admite composición de modo que se convierta en un elemento sin estado. Esto nos permitirá reutilizar el mismo elemento para agregar y editar tareas pendientes.
Convierte TodoItemInput en un elemento sin estado que admite composición
Para comenzar, debemos elevar el estado desde TodoItemInput
. ¿Dónde lo ubicaremos? Podríamos ponerlo directamente en TodoScreen
, pero ya funciona muy bien con el estado interno y un evento finalizado. No queremos cambiar esa API.
Lo que podemos hacer es dividir en dos partes el elemento que admite composición: una con estado y otra sin estado.
Abre TodoScreen.kt
y divide TodoItemInput
en dos elementos que admiten composición. Luego, cambia el nombre del elemento con estado a TodoItemEntryInput
, ya que solo es útil para ingresar TodoItems
nuevos.
TodoScreen.kt
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
TodoItemInput(
text = text,
onTextChange = setText,
icon = icon,
onIconChange = setIcon,
submit = submit,
iconsVisible = iconsVisible
)
}
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean
) {
Column {
Row(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text,
onTextChange,
Modifier
.weight(1f)
.padding(end = 8.dp),
submit
)
TodoEditButton(
onClick = submit,
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
if (iconsVisible) {
AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Esta transformación es muy importante de entender cuando se usa Compose. Tomamos un elemento con estado que admite composición, TodoItemInput
, y lo dividimos en dos elementos. Uno con estado (TodoItemEntryInput
) y otro sin estado (TodoItemInput
).
El elemento sin estado que admite composición tiene todo el código relacionado con la IU, y el elemento con estado no tiene ningún código relacionado con la IU. De esta manera, hacemos que el código de la IU se pueda volver a usar en situaciones en las que queremos respaldar el estado de manera diferente.
Vuelve a ejecutar la aplicación
Vuelve a ejecutar la aplicación para confirmar que la entrada de tareas pendientes siga funcionando.
¡Felicitaciones! Extrajiste correctamente un elemento sin estado que admite composición a partir de un elemento con estado sin cambiar su API.
En la próxima sección, exploraremos cómo esto nos permite reutilizar la lógica de la IU en diferentes ubicaciones sin acoplar la IU con el estado.
9. Usa el Estado en ViewModel
Para revisar la simulación interactiva neomoderna de nuestro diseñador, necesitaremos agregar un fragmento de estado que represente el elemento de edición actual.
Simulación para el modo de edición
Ahora debemos decidir dónde agregar el estado para este editor. Podríamos compilar otro elemento "TodoRowOrInlineEditor
" con estado que admite composición y que controle la visualización o la edición de un elemento, pero solo queremos mostrar un editor a la vez. Si observamos el diseño, la sección superior también cambia cuando se está en modo de edición. Por lo tanto, tendremos que elevar el estado para permitir que se comparta.
Árbol de estados de TodoActivity
Como ambos TodoItemEntryInput
y TodoInlineEditor
necesitan conocer el estado actual del editor para ocultar la entrada en la parte superior de la pantalla, debemos elevar el estado a como mínimo TodoScreen
. La pantalla es el elemento que admite composición de nivel más bajo en la jerarquía que es un elemento superior común de todos los elementos que admiten composición y que necesitan saber sobre la edición.
Sin embargo, dado que el editor se deriva de la lista y la mutará, debería estar ubicado junto a ella. Queremos elevar el estado al nivel que podría modificarse. La lista se encuentra en TodoViewModel
, así que la agregaremos exactamente allí.
Convierte el elemento TodoViewModel de modo que use mutableStateListOf
En esta sección, agregarás el estado del editor en TodoViewModel
y, en la siguiente sección, lo usarás para compilar un editor directo.
Al mismo tiempo, exploraremos el uso de mutableStateListOf
en un objeto ViewModel
y veremos cómo simplifica el código del estado en comparación con LiveData<List>
cuando se segmenta a Compose.
mutableStateListOf
nos permite crear una instancia de MutableList
que es observable. Esto significa que podemos trabajar con todoItems de la misma manera en que trabajamos con MutableList, lo que elimina la sobrecarga de trabajar con LiveData<List>
.
Abre TodoViewModel.kt
y reemplaza el elemento todoItems
existente por mutableStateListOf
:
TodoViewModel.kt
import androidx.compose.runtime.mutableStateListOf
class TodoViewModel : ViewModel() {
// remove the LiveData and replace it with a mutableStateListOf
//private var _todoItems = MutableLiveData(listOf<TodoItem>())
//val todoItems: LiveData<List<TodoItem>> = _todoItems
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
// event: addItem
fun addItem(item: TodoItem) {
todoItems.add(item)
}
// event: removeItem
fun removeItem(item: TodoItem) {
todoItems.remove(item)
}
}
La declaración de todoItems
es corta y captura el mismo comportamiento que la versión de LiveData
.
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
Cuando se especifica private set
, restringimos las operaciones de escritura para este objeto de estado a un método set privado que solo se puede ver dentro de ViewModel
.
Actualiza TodoActivityScreen para usar el nuevo ViewModel
Abre TodoActivity.kt
y actualiza la TodoActivityScreen
para usar el nuevo ViewModel
.
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
TodoScreen(
items = todoViewModel.todoItems,
onAddItem = todoViewModel::addItem,
onRemoveItem = todoViewModel::removeItem
)
}
Ejecuta la app nuevamente y verás que funciona con el nuevo ViewModel. Cambiaste el estado de modo que use mutableStateListOf
. Ahora exploremos cómo crear un estado de editor.
Define el estado de editor
Ahora, es el momento de agregar el estado a nuestro editor. A fin de evitar la duplicación del texto de tareas pendientes, editaremos la lista directamente. Para ello, en lugar de conservar el texto actual que estamos editando, conservaremos un índice de lista para el elemento actual del editor.
Abre TodoViewModel.kt
y agrega un estado de editor.
Define un elemento private var currentEditPosition
nuevo que conserve la posición de edición actual. Contendrá el índice de la lista que estamos editando en este momento.
Luego, expón el currentEditItem
para componer mediante un método get. Aunque esta es una función regular de Kotlin, currentEditPosition
es observable para Compose al igual que un elemento State<TodoItem>
.
TodoViewModel.kt
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class TodoViewModel : ViewModel() {
// private state
private var currentEditPosition by mutableStateOf(-1)
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
// state
val currentEditItem: TodoItem?
get() = todoItems.getOrNull(currentEditPosition)
// ..
Cuando un elemento que admite composición llama a currentEditItem
, observará cambios en todoItems
y currentEditPosition
. Si cambia algún valor, el elemento que admite composición llamará nuevamente al método get a fin de obtener el nuevo valor.
Define eventos de editor
Definimos nuestro estado de editor y ahora tendremos que definir eventos que los elementos que admiten composición pueden llamar con el fin de controlar la edición.
Crea tres eventos: onEditItemSelected(item: TodoItem)
, onEditDone()
y onEditItemChange(item: TodoItem)
.
Los eventos onEditItemSelected
y onEditDone
solo cambian la currentEditPosition
. Si cambias currentEditPosition
, Compose volverá a componer cualquier elemento que admita composición y que lea currentEditItem
.
TodoViewModel.kt
class TodoViewModel : ViewModel() {
...
// event: onEditItemSelected
fun onEditItemSelected(item: TodoItem) {
currentEditPosition = todoItems.indexOf(item)
}
// event: onEditDone
fun onEditDone() {
currentEditPosition = -1
}
// event: onEditItemChange
fun onEditItemChange(item: TodoItem) {
val currentItem = requireNotNull(currentEditItem)
require(currentItem.id == item.id) {
"You can only change an item with the same id as currentEditItem"
}
todoItems[currentEditPosition] = item
}
}
El evento onEditItemChange
actualiza la lista en la currentEditPosition
. Esto cambiará el valor que muestran currentEditItem
y todoItems
al mismo tiempo. Antes de hacerlo, haremos algunas verificaciones de seguridad para asegurarnos de que el llamador no esté intentando escribir el elemento incorrecto.
Finaliza la edición cuando se quiten elementos
Actualiza el evento removeItem
a fin de cerrar el editor actual cuando se quite un elemento.
TodoViewModel.kt
// event: removeItem
fun removeItem(item: TodoItem) {
todoItems.remove(item)
onEditDone() // don't keep the editor open when removing items
}
Vuelve a ejecutar la app
Eso es todo. Actualizaste tu ViewModel
para usar MutableState
y viste que puede simplificar el código de estado observable.
En la siguiente sección, agregaremos una prueba para este ViewModel
y, luego, avanzaremos hacia la compilación de la IU de edición.
Como se realizaron muchas ediciones en esta sección, aquí se incluye una lista completa de TodoViewModel
después de aplicar todos los cambios:
TodoViewModel.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class TodoViewModel : ViewModel() {
private var currentEditPosition by mutableStateOf(-1)
var todoItems = mutableStateListOf<TodoItem>()
private set
val currentEditItem: TodoItem?
get() = todoItems.getOrNull(currentEditPosition)
fun addItem(item: TodoItem) {
todoItems.add(item)
}
fun removeItem(item: TodoItem) {
todoItems.remove(item)
onEditDone() // don't keep the editor open when removing items
}
fun onEditItemSelected(item: TodoItem) {
currentEditPosition = todoItems.indexOf(item)
}
fun onEditDone() {
currentEditPosition = -1
}
fun onEditItemChange(item: TodoItem) {
val currentItem = requireNotNull(currentEditItem)
require(currentItem.id == item.id) {
"You can only change an item with the same id as currentEditItem"
}
todoItems[currentEditPosition] = item
}
}
10. Estado de prueba en ViewModel
Te recomendamos que pruebes tu ViewModel
a fin de asegurarte de que la lógica de tu aplicación sea la correcta. En esta sección, escribiremos una prueba para mostrar cómo probar un modelo de vistas con State<T>
como estado.
Agrega una prueba a TodoViewModelTest
Abre TodoViewModelTest.kt
en el directorio test/
y agrega una prueba para quitar un elemento:
TodoViewModelTest.kt
import com.example.statecodelab.util.generateRandomTodoItem
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class TodoViewModelTest {
@Test
fun whenRemovingItem_updatesList() {
// before
val viewModel = TodoViewModel()
val item1 = generateRandomTodoItem()
val item2 = generateRandomTodoItem()
viewModel.addItem(item1)
viewModel.addItem(item2)
// during
viewModel.removeItem(item1)
// after
assertThat(viewModel.todoItems).isEqualTo(listOf(item2))
}
}
En esta prueba, se muestra cómo probar State<T>
que los eventos modifican directamente. En la sección anterior, crea un nuevo ViewModel
y, luego, agrega dos elementos a todoItems
.
El método que estamos probando es removeItem
, que quita el primer elemento de la lista.
Por último, usaremos aserciones de Verdad para declarar que la lista solo contiene el segundo elemento.
No tenemos que hacer ningún trabajo adicional para leer todoItems
en una prueba si la prueba generó las actualizaciones directamente (como lo hacemos aquí cuando llamamos a removeItem
); solo se trata de un List<TodoItem>
.
El resto de las pruebas para este ViewModel
siguen el mismo patrón básico, por lo que las omitiremos como ejercicios en este codelab. Puedes agregar más pruebas del ViewModel
si deseas confirmar que funciona o abrir TodoViewModelTest
en el módulo finalizado y consultar más pruebas.
En la próxima sección, agregaremos el nuevo modo de edición a la IU.
11. Cómo reutilizar elementos sin estado que admiten composición
Finalmente, está todo listo para implementar nuestro diseño interactivo neomoderno. Recuerda que esto es lo que intentamos compilar:
Simulación para el modo de edición
Pasa el estado y los eventos a TodoScreen
Acabamos de definir todos los estados y eventos que necesitaremos para esta pantalla en TodoViewModel. Ahora actualizaremos TodoScreen a fin de tomar el estado y los eventos que necesitará para mostrar la pantalla.
Abre TodoScreen.kt
y cambia la firma de TodoScreen
de modo que agregues lo siguiente:
- El elemento que se está editando actualmente es
currentlyEditing: TodoItem?
. - Estos son los tres eventos nuevos:
onStartEdit: (TodoItem) -> Unit
, onEditItemChange: (TodoItem) -> Unit
y onEditDone: () -> Unit
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
currentlyEditing: TodoItem?,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onStartEdit: (TodoItem) -> Unit,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit
) {
// ...
}
Estos son solo el estado y el evento nuevos que definimos en el ViewModel
.
Luego, en TodoActivity.kt
, pasa los valores nuevos a TodoActivityScreen
.
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
TodoScreen(
items = todoViewModel.todoItems,
currentlyEditing = todoViewModel.currentEditItem,
onAddItem = todoViewModel::addItem,
onRemoveItem = todoViewModel::removeItem,
onStartEdit = todoViewModel::onEditItemSelected,
onEditItemChange = todoViewModel::onEditItemChange,
onEditDone = todoViewModel::onEditDone
)
}
Esto solo pasa el estado y los eventos que requiere nuestra nueva TodoScreen
.
Define un editor directo que admite composición
Crea un nuevo elemento que admite composición en TodoScreen.kt
que use el elemento TodoItemInput
sin estado para definir un editor directo.
TodoScreen.kt
@Composable
fun TodoItemInlineEditor(
item: TodoItem,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit,
onRemoveItem: () -> Unit
) = TodoItemInput(
text = item.task,
onTextChange = { onEditItemChange(item.copy(task = it)) },
icon = item.icon,
onIconChange = { onEditItemChange(item.copy(icon = it)) },
submit = onEditDone,
iconsVisible = true
)
Este elemento que admite composición no tiene estado. Solo muestra el item
pasado y usa los eventos a fin de solicitar que se actualice el estado. Como ya extrajimos un elemento TodoItemInput
sin estado que admite composición, podemos usarlo con facilidad en este contexto sin estado.
En este ejemplo, se muestra la capacidad de reutilización de los elementos sin estado que admiten composición. Aunque el encabezado usa un TodoItemEntryInput
con estado en la misma pantalla, podemos elevar el estado hasta el ViewModel
para el editor directo.
Usa el editor directo en LazyColumn.
En la LazyColumn
, en TodoScreen
, muestra TodoItemInlineEditor
si el elemento actual se está editando; de lo contrario, muestra TodoRow
.
Además, comienza la edición cuando hagas clic en un elemento (en lugar de quitarlo como antes).
TodoScreen.kt
// fun TodoScreen()
// ...
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(top = 8.dp)
) {
items(items) { todo ->
if (currentlyEditing?.id == todo.id) {
TodoItemInlineEditor(
item = currentlyEditing,
onEditItemChange = onEditItemChange,
onEditDone = onEditDone,
onRemoveItem = { onRemoveItem(todo) }
)
} else {
TodoRow(
todo,
{ onStartEdit(it) },
Modifier.fillParentMaxWidth()
)
}
}
}
// ...
El elemento LazyColumn
que admite composición es el equivalente de Compose de una RecyclerView
. Solo volverá a componer los elementos de la lista necesarios para mostrar la pantalla actual, y, a medida que el usuario se desplace, descartará los elementos que admiten composición que salieron de la pantalla y creará nuevos para los elementos que entren en ella.
Prueba el nuevo editor interactivo
Vuelve a ejecutar la app y, cuando hagas clic en una fila de tareas pendientes, se abrirá el editor interactivo.
Utilizamos la misma IU sin estado que admite composición para dibujar el encabezado con estado y la experiencia de edición interactiva. Y, al hacerlo, no ingresamos estados duplicados.
Esto ya está comenzando a tomar forma, aunque ese botón para agregar parezca estar fuera de lugar y debamos cambiar el encabezado. Terminaremos el diseño en los próximos pasos.
Cambia el encabezado durante la edición
A continuación, terminaremos el diseño del encabezado y, luego, exploraremos cómo cambiar el botón por botones de emojis que el diseñador desee para su diseño interactivo neomoderno.
Regresa al elemento TodoScreen
que admite composición y haz que el encabezado responda a los cambios en el estado de editor. Si el valor de currentlyEditing
es null
, mostraremos TodoItemEntryInput
y pasaremos elevation = true
a TodoItemInputBackground
. Si currentlyEditing
no es null
, pasa elevation = false
a TodoItemInputBackground
y muestra texto que indique "Editing item" en el mismo fondo.
TodoScreen.kt
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.text.style.TextAlign
@Composable
fun TodoScreen(
items: List<TodoItem>,
currentlyEditing: TodoItem?,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onStartEdit: (TodoItem) -> Unit,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit
) {
Column {
val enableTopSection = currentlyEditing == null
TodoItemInputBackground(elevate = enableTopSection) {
if (enableTopSection) {
TodoItemEntryInput(onAddItem)
} else {
Text(
"Editing item",
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(16.dp)
.fillMaxWidth()
)
}
}
// ..
Una vez más, cambiaremos el árbol de composición cuando se realice una nueva composición. Cuando está habilitada la sección superior, mostramos TodoItemEntryInput
. De lo contrario, mostramos un elemento Text
que admite composición y muestra "Editing item".
TodoItemInputBackground
, que estaba en el código de inicio, se anima automáticamente y cambia de tamaño y su elevación, por lo que, cuando ingresas al modo de edición, este código se anima automáticamente entre los estados.
Vuelve a ejecutar la app
Vuelve a ejecutar la app, y verás una animación entre los estados de edición y no edición. Ya casi terminamos de compilar este diseño.
En la próxima sección, exploraremos cómo estructurar el código para los botones de emojis.
12. Cómo usar ranuras para pasar secciones de la pantalla
Los elementos sin estado que admiten composición y que muestran una IU compleja pueden terminar teniendo muchos parámetros. Si no son demasiados parámetros y configuran directamente el elemento que admite composición, no habrá problemas. Sin embargo, a veces necesitas pasar parámetros para configurar los elementos secundarios de un elemento que admite composición.
En nuestro diseño interactivo neomoderno, el diseñador quiere que mantengamos el botón Add en la parte superior, pero que lo reemplacemos por dos botones de emojis para el editor directo. Podríamos agregar más parámetros a TodoItemInput
a fin de manejar este caso, pero no está claro que TodoItemInput
sea responsable por ellos.
Necesitamos una forma de modo que un elemento que admite composición admita una sección de botones preconfigurados. Esto permitirá que el llamador configure los botones como sea necesario sin compartir todo el estado necesario para configurarlos con TodoItemInput
.
De esta manera, se reducirá la cantidad de parámetros que se pasan a los elementos sin estado que admiten composición y se volverán más reutilizables.
El patrón que se usa a los efectos de pasar una sección preconfigurada es el de las ranuras. Las ranuras son parámetros para un elemento que admite composición que permiten que el llamador describa una sección de la pantalla. Encontrarás ejemplos de ranuras en las API integradas que admiten composición. Uno de los ejemplos que se usan con mayor frecuencia es Scaffold
.
Scaffold
es el elemento que admite composición utilizado a fin de describir una pantalla completa en Material Design, como la topBar
, la bottomBar
y el cuerpo de la pantalla.
En lugar de proporcionar cientos de parámetros a los efectos de configurar cada sección de la pantalla, Scaffold
expone las ranuras que puedes completar con los elementos que admiten composición que desees. Esto reduce la cantidad de parámetros para Scaffold
y hace que sea más reutilizable. Si deseas compilar una topBar
personalizada, Scaffold
podrá mostrarla.
@Composable
fun Scaffold(
// ..
topBar: @Composable (() -> Unit)? = null,
bottomBar: @Composable (() -> Unit)? = null,
// ..
bodyContent: @Composable (PaddingValues) -> Unit
) {
Define una ranura en TodoItemInput
Abre TodoScreen.kt
y define un nuevo parámetro @Composable () -> Unit
en el elemento TodoItemInput
sin estado llamado buttonSlot
.
TodoScreen.kt
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
buttonSlot: @Composable () -> Unit
) {
// ...
Esta es una ranura genérica que el llamador puede completar con los botones deseados. Lo usaremos para especificar diferentes botones en el encabezado y en los editores directos.
Muestra el contenido de buttonSlot
Reemplaza la llamada a TodoEditButton
con el contenido de la ranura.
TodoScreen.kt
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
buttonSlot: @Composable() () -> Unit,
) {
Column {
Row(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text,
onTextChange,
Modifier
.weight(1f)
.padding(end = 8.dp),
submit
)
// New code: Replace the call to TodoEditButton with the content of the slot
Spacer(modifier = Modifier.width(8.dp))
Box(Modifier.align(Alignment.CenterVertically)) { buttonSlot() }
// End new code
}
if (iconsVisible) {
AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Podríamos llamar directamente a buttonSlot()
, pero necesitamos conservar la align
para centrar lo que el llamador nos pase de forma vertical. A tal fin, colocamos la ranura en un Box
, que es un elemento básico que admite composición.
Actualiza TodoItemEntryInput
con estado para usar la ranura
Ahora debemos actualizar los llamadores de modo que usen buttonSlot
. Primero, actualicemos TodoItemEntryInput
:
TodoScreen.kt
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
val (text, onTextChange) = remember { mutableStateOf("") }
val (icon, onIconChange) = remember { mutableStateOf(TodoIcon.Default)}
val submit = {
if (text.isNotBlank()) {
onItemComplete(TodoItem(text, icon))
onTextChange("")
onIconChange(TodoIcon.Default)
}
}
TodoItemInput(
text = text,
onTextChange = onTextChange,
icon = icon,
onIconChange = onIconChange,
submit = submit,
iconsVisible = text.isNotBlank()
) {
TodoEditButton(onClick = submit, text = "Add", enabled = text.isNotBlank())
}
}
Como buttonSlot
es el último parámetro de TodoItemInput
, podemos usar la sintaxis de expresión lambda final. Luego, en la lambda, llama a TodoEditButton
como lo hicimos antes.
Actualiza TodoItemInlineEditor
para usar la ranura
Para finalizar la refactorización, cambia TodoItemInlineEditor
a fin de usar también la ranura:
TodoScreen.kt
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.TextButton
@Composable
fun TodoItemInlineEditor(
item: TodoItem,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit,
onRemoveItem: () -> Unit
) = TodoItemInput(
text = item.task,
onTextChange = { onEditItemChange(item.copy(task = it)) },
icon = item.icon,
onIconChange = { onEditItemChange(item.copy(icon = it)) },
submit = onEditDone,
iconsVisible = true,
buttonSlot = {
Row {
val shrinkButtons = Modifier.widthIn(20.dp)
TextButton(onClick = onEditDone, modifier = shrinkButtons) {
Text(
text = "\uD83D\uDCBE", // floppy disk
textAlign = TextAlign.End,
modifier = Modifier.width(30.dp)
)
}
TextButton(onClick = onRemoveItem, modifier = shrinkButtons) {
Text(
text = "❌",
textAlign = TextAlign.End,
modifier = Modifier.width(30.dp)
)
}
}
}
)
Aquí estamos pasando buttonSlot
como un parámetro con nombre. Luego, en buttonSlot
, haremos una Fila que contenga los dos botones para el diseño de editor directo.
Vuelve a ejecutar la app
Vuelve a ejecutar la app y prueba el editor directo.
En esta sección, personalizamos el elemento sin estado que admite composición con una ranura, lo que permitió al llamador controlar una sección de la pantalla. Cuando usas ranuras, evitamos acoplar TodoItemInput
con todos los diferentes diseños que se podrían agregar en el futuro.
Cuando agregues parámetros a los elementos sin estado que admiten composición a fin de personalizar los elementos secundarios, evalúa si las ranuras podrían ser un mejor diseño. Las ranuras tienden a hacer que los elementos que admiten composición sean más reutilizables y, al mismo tiempo, permitan que la cantidad de parámetros resulte manejable.
13. Felicitaciones
¡Felicitaciones! Completaste correctamente este codelab y aprendiste a estructurar el estado mediante el flujo unidireccional de datos en una app de Jetpack Compose.
Aprendiste a pensar en el estado y los eventos a fin de extraer en Compose elementos sin estado que admiten composición, y viste cómo reutilizar un elemento complejo en diferentes situaciones en la misma pantalla. También aprendiste a integrar un ViewModel con Compose mediante LiveData y MutableState.
¿Qué sigue?
Consulta los otros codelabs sobre la ruta de aprendizaje de Compose.
Apps de ejemplo
- JetNews demuestra cómo usar el flujo unidireccional de datos para usar elementos con estado que admiten composición a fin de administrar el estado en una pantalla compilada con elementos sin estado.