Cómo usar el estado en Jetpack Compose

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:

  1. Una barra de notificaciones que se muestra cuando no se puede establecer una conexión de red
  2. Una entrada de blog y los comentarios asociados
  3. Las animaciones con efectos de propagación en botones que se reproducen cuando un usuario hace clic
  4. 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.

b5c4dc05d1e54d5a.png

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 y LiveData 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

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.

b059413b0cf9113a.png

Abre el proyecto en Android Studio

  1. En la ventana Welcome to Android Studio, selecciona c01826594f360d94.png Open an Existing Project.
  2. Selecciona la carpeta [Download Location]/StateCodelab (sugerencia: Asegúrate de seleccionar el directorio StateCodelab que contiene build.gradle).
  3. Cuando Android Studio haya importado el proyecto, prueba si puedes ejecutar los módulos start y finished.

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 un TodoItem
  • 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 codelab
  • TodoViewModel.kt: Un ViewModel 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:

f415ca9336d83142.png

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

879ed27ccab2eed3.gif

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:

  1. Pruebas: Como el estado de la IU está entrelazado con las Views, puede ser difícil probar este código.
  2. 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.
  3. 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.
  4. 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.

8a331b9c1b392bef.png

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.

El estado fluye desde el viewmodel hasta la actividad, mientras que los eventos fluyen desde la actividad hasta el viewmodel.

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:

7998ef0a441d4b3.png

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 52dd4dd99bae0aaf.png.

4cedcddc3df7c5d6.png

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 pantalla
  • onAddItem: Un evento para cuando el usuario solicita agregar un elemento
  • onRemoveItem: 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 a onAddItem o onRemoveItem.
  • 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 los items 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:

f555d7b9be40144c.png

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.

a195c5b4d2a5ea0f.png

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 variable items de tipo List<TodoItem>.
  • todoViewModel.todoItems es un LiveData<List<TodoItem> del ViewModel.
  • .observeAsState observa un LiveData<T> y lo convierte en un objeto State<T> de modo que Compose pueda reaccionar a los cambios de valor.
  • listOf() es un valor inicial para evitar posibles resultados null antes de que se inicialice LiveData. Si no se pasara, items sería List<TodoItem>?, que es anulable.
  • by es la sintaxis delegada de la propiedad en Kotlin, y nos permite desunir automáticamente State<List<TodoItem>> de observeAsState y convertirla en un elemento List<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.

7998ef0a441d4b3.png

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

40a46273d161497a.png

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.

cdb483885e713651.png

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

2e53e9411aeee11e.gif

¿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

6f5faa4342c63d88.png

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

El diagrama muestra a iconAlpha como un elemento secundario nuevo de TodoRow en el árbol de composición.

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:

  1. 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)
  2. 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) 721446d6a55fcaba.png

Entrada de tarea pendiente (estado: contraído) 6f46071227df3625.png

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.

Se muestra PreviewTodoItemInput ejecutándose con el estado interactivo.

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)

Árbol: TodoItemInput con los elementos secundarios TodoInputTextField y TodoEditButton.  El estado del texto es un elemento secundario de TodoInputTextField.

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

Diagrama: TodoItemInput está en la parte superior, y el estado fluye hacia abajo a TodoInputTextField. Los eventos fluyen hacia arriba desde TodoInputTextField hasta 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)

e2ccddf8af39d228.png

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 a TodoItemInput. 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 y TodoEditButton.
  • 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 modificar TodoInputTextField.

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.

767719165c35039e.png

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) 721446d6a55fcaba.png

Entrada de tareas pendientes (estado: contraído; con texto en blanco) 6f46071227df3625.png

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

ceb75cf0f13a1590.png

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.

3d8320f055510332.gif

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

6ee2444445ec12be.png

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 a submit 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 modo de edición reutiliza la misma IU que el modo de entrada de texto, pero incorpora el editor en la lista.

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

El modo de edición reutiliza la misma IU que el modo de entrada de texto, pero incorpora el editor en la lista.

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

d32f2646a3f5ce65.png

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

El modo de edición reutiliza la misma IU que el modo de entrada de texto, pero incorpora el editor en la lista.

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.

Imagen que muestra la app en este punto del codelab

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

99c4d82c8df52606.gif

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.

Se muestra el diseño con el botón Add en la barra de herramientas y los botones de emojis en el editor directo

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.

ae3f79834a615ed0.gif

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.

Documentos de referencia