Efectos secundarios y estados avanzados en Jetpack Compose

1. Introducción

En este codelab, aprenderás conceptos avanzados relacionados con las APIs de estado y efectos secundarios en Jetpack Compose. Verás cómo crear un contenedor de estado para elementos componibles con estado y cuya lógica no sea trivial, cómo crear corrutinas y llamar a funciones de suspensión a partir del código de Compose, y cómo activar efectos secundarios para lograr diferentes casos de uso.

Para obtener más asistencia mientras realizas este codelab, consulta el siguiente código:

Qué aprenderás

Requisitos

Qué compilarás

En este codelab, comenzarás con una aplicación sin terminar, la app de estudio de material de Crane, y agregarás funciones para mejorar la app.

b2c6b8989f4332bb.gif

2. Cómo prepararte

Obtén el código

El código para este codelab se puede encontrar en el repositorio de GitHub de android-compose-codelabs. Para clonarlo, ejecuta lo siguiente:

$ git clone https://github.com/android/codelab-android-compose

También tienes la opción de descargar el repositorio como archivo ZIP:

Revisa la app de ejemplo

El código que acabas de descargar contiene código para todos los codelabs de Compose disponibles. Para completar este codelab, abre el proyecto AdvancedStateAndSideEffectsCodelab en Android Studio.

Te recomendamos que comiences con el código de la rama main y sigas el codelab paso a paso a tu propio ritmo.

Durante el codelab, recibirás fragmentos de código que deberás agregar al proyecto. En algunos lugares, también deberás quitar el código que se mencionará explícitamente en los comentarios de los fragmentos de código.

Familiarízate con el código y ejecuta la app de ejemplo

Tómate un momento para explorar la estructura del proyecto y ejecutar la app.

162c42b19dafa701.png

Cuando ejecutes la app desde la rama principal, verás que algunas funcionalidades, como el panel lateral o la carga de destinos de vuelo, no funcionan. Con eso trabajarás en los próximos pasos del codelab.

b2c6b8989f4332bb.gif

Pruebas de la IU

La app incluye pruebas de IU muy básicas que están disponibles en la carpeta androidTest. Deben aprobar las pruebas para las ramas main y end en todo momento.

[Opcional] Visualización del mapa en la pantalla de detalles

No es necesario que muestres el mapa de la ciudad en la pantalla de detalles. Sin embargo, si deseas hacerlo, debes obtener una clave de API personal como se indica en la documentación de Maps. Incluye esa clave en el archivo local.properties de la siguiente manera:

// local.properties file
google.maps.key={insert_your_api_key_here}

Solución del codelab

Para obtener la rama end con Git, usa el siguiente comando:

$ git clone -b end https://github.com/android/codelab-android-compose

Como alternativa, puedes descargar el código de la solución aquí:

Preguntas frecuentes

3. Canalización de producción del estado de la IU

Como posiblemente hayas notado cuando ejecutaste la app desde la rama main, la lista de destinos de vuelos está vacía.

Para solucionar este problema, debes completar dos pasos:

  • Agrega la lógica en ViewModel para producir el estado de la IU. En tu caso, esta es la lista de destinos sugeridos.
  • Consume el estado de la IU desde la IU misma, lo que hará que se muestre en la pantalla.

En esta sección, completarás el primer paso.

Una buena arquitectura para una aplicación se organiza en capas de modo que se cumplan las prácticas recomendadas básicas de diseño de sistemas, como la separación de problemas y la capacidad de prueba.

La producción del estado de la IU hace referencia al proceso en el que la app accede a la capa de datos, aplica reglas empresariales si es necesario y expone el estado de la IU que se consumirá desde la IU.

La capa de datos de esta aplicación ya está implementada. Ahora, producirás el estado (la lista de destinos sugeridos) para que la IU pueda consumirlo.

Puedes usar varias APIs para producir el estado de la IU. Las alternativas se resumen en la documentación Tipos de salida en canalizaciones de producción de estado. En general, se recomienda usar StateFlow de Kotlin para producir el estado de la IU.

Para producir el estado de la IU, sigue estos pasos:

  1. Abre home/MainViewModel.kt.
  2. Define una variable privada _suggestedDestinations de tipo MutableStateFlow para representar la lista de destinos sugeridos y establece una lista vacía como valor inicial.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. Define una segunda variable inmutable suggestedDestinations de tipo StateFlow. Esta es la variable pública de solo lectura que se puede consumir desde la IU. Exponer una variable de solo lectura mientras usas la variable mutable de forma interna es una práctica recomendada. De esta manera, te aseguras de que el estado de la IU no se pueda modificar, a menos que sea a través de ViewModel, lo que la convierte en la única fuente de confianza. La función de extensión asStateFlow convierte el flujo de mutable a inmutable.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. En el bloque init de ViewModel, agrega una llamada desde destinationsRepository para obtener los destinos de la capa de datos.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  1. Por último, quita los comentarios de los usos de la variable interna _suggestedDestinations que encuentres en esta clase para que se pueda actualizar correctamente con eventos que provengan de la IU.

Eso es todo. Completaste el primer paso. Ahora, ViewModel puede producir el estado de la IU. En el siguiente paso, consumirás este estado desde la IU.

4. Cómo consumir un elemento Flow de manera segura desde ViewModel

La lista de destinos de vuelos aún está vacía. En el paso anterior, generaste el estado de la IU en MainViewModel. Ahora, consumirás el estado de la IU que expone MainViewModel para mostrarlo en la IU.

Abre el archivo home/CraneHome.kt y observa el elemento componible CraneHomeContent.

Hay un comentario TODO arriba de la definición de suggestedDestinations que se asigna a una lista vacía recordada. Esto es lo que se muestra en la pantalla: una lista vacía. En este paso, corregirás eso y mostrarás los destinos sugeridos que expone MainViewModel.

66ae2543faaf2e91.png

Abre home/MainViewModel.kt y observa el StateFlow suggestedDestinations que se inicializa en destinationsRepository.destinations y se actualiza cuando se llama a las funciones updatePeople o toDestinationChanged.

La idea es que la IU del elemento componible CraneHomeContent se actualice cada vez que se emita un nuevo elemento en el flujo de datos de suggestedDestinations. Puedes usar la función collectAsStateWithLifecycle(). collectAsStateWithLifecycle() recopila valores de StateFlow y representa el valor más reciente a través de la API de estado de Compose de manera optimizada para los ciclos de vida. De esta manera, el código de Compose que lee ese valor de estado se recompone en emisiones nuevas.

Para comenzar a usar la API de collectAsStateWithLifecycle, primero agrega la siguiente dependencia en app/build.gradle. La variable lifecycle_version ya está definida en el proyecto con la versión adecuada.

dependencies {
    implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}

Vuelve al elemento componible CraneHomeContent y reemplaza la línea que asigna suggestedDestinations por una llamada a collectAsStateWithLifecycle en la propiedad suggestedDestinations de ViewModel:

import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
    // ...
}

Si ejecutas la app, verás que se completa la lista de destinos y que estos cambian cada vez que modificas la cantidad de personas que viajan.

d656748c7c583eb8.gif

5. LaunchedEffect y rememberUpdatedState

En el proyecto, hay un archivo home/LandingScreen.kt que no se usa en este momento. Quieres agregar una pantalla de destino a la app, que posiblemente se pueda usar para cargar todos los datos necesarios en segundo plano.

La pantalla de destino ocupará toda la pantalla y mostrará el logotipo de la app en el centro. Lo ideal sería mostrar la pantalla y, después de cargar todos los datos, notificar al llamador que se puede descartar la pantalla de destino con una devolución de llamada onTimeout.

Las corrutinas de Kotlin son la forma recomendada de realizar operaciones asíncronas en Android. Por lo general, una app usa corrutinas para cargar elementos en segundo plano cuando se inicia. Jetpack Compose ofrece APIs que hacen que el uso de corrutinas sea seguro dentro de la capa de IU. Como esta app no se comunica con un backend, usarás la función delay de las corrutinas para simular la carga de elementos en segundo plano.

Un efecto secundario de Compose es un cambio en el estado de la app que ocurre fuera del alcance de una función de componibilidad. El cambio de estado para ocultar o mostrar la pantalla de destino ocurrirá en la devolución de llamada de onTimeout, y, como antes de llamar a onTimeout se deben cargar los elementos con las corrutinas, el cambio debe producirse en el contexto de una corrutina.

Para llamar a funciones de suspensión de forma segura dentro de un elemento componible, usa la API de LaunchedEffect, que activa un efecto secundario con alcance de corrutina en Compose.

Cuando LaunchedEffect ingresa a la composición, inicia una corrutina con el bloque de código pasado como un parámetro. La corrutina se cancelará si LaunchedEffect sale de la composición.

Si bien el siguiente código no es correcto, veamos cómo usar esta API y analicemos por qué es incorrecto el código. Más adelante en este paso, llamarás al elemento LandingScreen componible.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Algunas APIs de efectos secundarios, como LaunchedEffect, toman una cantidad variable de claves como parámetro que se usa para reiniciar el efecto cuando cambia una de esas claves. ¿Detectaste el error? No queremos reiniciar LaunchedEffect si los emisores de esta función de componibilidad pasan un valor de lambda onTimeout diferente. De esta manera, se iniciaría de nuevo delay, y no cumplirías con los requisitos.

Tiene una solución. Para activar el efecto secundario solo una vez durante el ciclo de vida de este elemento componible, usa una constante como clave, por ejemplo, LaunchedEffect(Unit) { ... }. Sin embargo, ahora hay un problema diferente.

Si onTimeout cambia mientras el efecto secundario está en curso, no hay garantía de que se llamará al último onTimeout cuando finalice el efecto. Para garantizar que se llame al último onTimeout, recuerda usar onTimeout con la API de rememberUpdatedState. Esta API captura y actualiza el valor más reciente:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(Unit) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Deberías usar rememberUpdatedState cuando una expresión de objeto o lambda de larga duración hace referencia a parámetros o valores calculados durante la composición, que puede ser común cuando se trabaja con LaunchedEffect.

Cómo mostrar la pantalla de destino

Ahora, debes mostrar la pantalla de destino cuando se abre la app. Abre el archivo home/MainActivity.kt y consulta el elemento MainScreen componible al que se llama primero.

En el elemento MainScreen componible, simplemente puedes agregar un estado interno que realice un seguimiento para saber si se debe mostrar el destino:

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

Si ejecutas la app ahora, deberías ver que LandingScreen aparece y desaparece después de 2 segundos.

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

En este paso, harás que funcione el panel lateral de navegación. Actualmente, no sucede nada si intentas presionar el menú de opciones.

Abre el archivo home/CraneHome.kt y consulta el elemento CraneHome componible para ver dónde debes abrir el panel lateral de navegación: en la devolución de llamada openDrawer.

En CraneHome, tienes un scaffoldState que contiene un DrawerState. DrawerState tiene métodos para abrir y cerrar el panel lateral de navegación de manera programática. Sin embargo, si intentas escribir scaffoldState.drawerState.open() en la devolución de llamada openDrawer, aparecerá un error. Esto se debe a que la función open es una función de suspensión. Nos encontramos nuevamente en el dominio de las corrutinas.

Además de las APIs para proteger las corrutinas de las llamadas desde la capa de IU, algunas APIs de Compose son funciones de suspensión. Un ejemplo de esto es la API para abrir el panel lateral de navegación. Las funciones de suspensión, además de la ejecución de código asíncrono, también ayudan a representar conceptos que ocurren con el tiempo. Debido a que la apertura del panel lateral requiere tiempo, movimiento y posibles animaciones, se ve perfectamente reflejado con la función de suspensión, que suspende la ejecución de la corrutina donde se la llamó hasta que finaliza y reanuda la ejecución.

Se debe llamar a scaffoldState.drawerState.open() dentro de una corrutina. ¿Qué puedes hacer? openDrawer es una función de devolución de llamada simple, por lo tanto:

  • No puedes simplemente llamar a las funciones de suspensión que contiene porque openDrawer no se ejecuta en el contexto de una corrutina.
  • No puedes usar LaunchedEffect como antes porque no podemos llamar a elementos componibles en openDrawer. No participamos en la composición.

Quieres lanzar una corrutina. ¿Qué alcance deberíamos usar? Idealmente, quieres un elemento CoroutineScope que siga el ciclo de vida de su sitio de llamada. Si usas la API de rememberCoroutineScope, se muestra un CoroutineScope vinculado al punto en la composición donde lo llamas. El alcance se cancelará automáticamente una vez que salga de la composición. Con ese alcance, puedes iniciar corrutinas cuando no estás en la composición, por ejemplo, en la devolución de llamada openDrawer.

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

Si ejecutas la app, verás que se abre el panel lateral de navegación cuando presionas el ícono del menú de opciones.

92957c04a35e91e3.gif

Comparación entre LaunchedEffect y rememberCoroutineScope

En este caso, no era posible usar LaunchedEffect porque necesitabas activar la llamada para crear una corrutina en una devolución de llamada normal que estaba fuera de la composición.

Si observas el paso de la pantalla de destino que usó LaunchedEffect, ¿puedes usar rememberCoroutineScope y llamar a scope.launch { delay(); onTimeout(); } en lugar de usar LaunchedEffect?

Podrías haber hecho eso y hubiera funcionado, pero no sería correcto. Como se explica en la documentación Acerca de Compose, Compose puede llamar a esos elementos en cualquier momento. LaunchedEffect garantiza que el efecto secundario se ejecutará cuando la llamada a ese elemento componible pase a la composición. Si usas rememberCoroutineScope y scope.launch en el cuerpo de LandingScreen, la corrutina se ejecutará cada vez que Compose llame a LandingScreen, sin importar si esa llamada pasa a la composición o no. Por lo tanto, desperdiciarás recursos, y no se ejecutará este efecto secundario en un entorno controlado.

7. Crea un contenedor del estado

¿Notaste que, si presionas Choose Destination, puedes editar el campo y filtrar las ciudades según lo que ingresaste en la búsqueda? Probablemente también notaste que, cuando modificas Choose Destination, también cambia el estilo del texto.

dde9ef06ca4e5191.gif

Abre el archivo base/EditableUserInput.kt. El elemento componible con estado CraneEditableUserInput permite algunos parámetros, como hint y caption, que corresponden al texto opcional junto al ícono. Por ejemplo, caption To aparece cuando buscas un destino.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

¿Por qué?

La lógica para actualizar textState y determinar si lo que se muestra corresponde a la sugerencia o no se encuentra en el cuerpo del elemento componible CraneEditableUserInput. Esto presenta algunos inconvenientes:

  • El valor de TextField no se eleva y, por lo tanto, no se puede controlar desde el exterior, lo que dificulta las pruebas.
  • La lógica de ese elemento componible podría volverse más compleja y el estado interno podría dejar de estar sincronizado con mayor facilidad.

Si creas un contenedor de estado responsable del estado interno de este elemento componible, puedes centralizar todos los cambios de estado en un solo lugar. De esta forma, es más difícil que el estado no esté sincronizado y que la lógica relacionada se agrupe en una sola clase. Además, este estado se puede elevar fácilmente y puede consumirse de los emisores de este elemento componible.

En este caso, elevar el estado es una buena idea, ya que este es un componente de IU de bajo nivel que podría reutilizarse en otras partes de la app. Por lo tanto, cuanto más flexible y controlable sea, mejor.

Cómo crear el contenedor de estado

Dado que CraneEditableUserInput es un componente reutilizable, crea una clase normal como contenedor de estado llamada EditableUserInputState en el mismo archivo, con el siguiente aspecto:

// base/EditableUserInput.kt file

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

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)
       private set

    fun updateText(newText: String) {
       text = newText
    }

    val isHint: Boolean
        get() = text == hint
}

La clase debería tener las siguientes características:

  • text es un estado mutable del tipo String, al igual que el que tienes en CraneEditableUserInput. Es importante usar mutableStateOf para que Compose realice un seguimiento de los cambios del valor y lo recomponga cuando se produzcan.
  • text es un var, con un set privado, por lo que no se puede mutar directamente desde fuera de la clase. En lugar de hacer pública esta variable, puedes exponer un evento updateText para modificarlo, lo que hace que la clase sea la única fuente de confianza.
  • La clase toma un elemento initialText como dependencia que se utiliza para inicializar text.
  • La lógica para saber si text es la sugerencia o no está en la propiedad isHint, que realiza la verificación a pedido.

Si la lógica se vuelve más compleja en el futuro, solo tienes que realizar cambios en una clase: EditableUserInputState.

Cómo recordar el contenedor de estado

Siempre se debe recordar a los contenedores de los estados para que permanezcan en la composición y no se cree uno nuevo constantemente. Una buena práctica es crear un método en el mismo archivo para eliminar el código estándar y evitar errores. En el archivo base/EditableUserInput.kt, agrega este código:

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

Si solo remember este estado, no sobrevivirá a las recreaciones de actividad. Para lograr esto, puedes usar la API de rememberSaveable, que se comporta de manera similar a remember, pero el valor almacenado también sobrevive a la actividad y la recreación del proceso. De forma interna, usa el mecanismo de estado de instancia guardado.

rememberSaveable hace todo esto sin trabajo adicional para los objetos que se pueden almacenar dentro de un elemento Bundle. Ese no es el caso de la clase EditableUserInputState que creaste en tu proyecto. Por lo tanto, debes indicarle a rememberSaveable cómo guardar y restablecer una instancia de la clase con un objeto Saver.

Cómo crear un objeto Saver personalizado

Un objeto Saver describe la manera en que un objeto se puede convertir en algo que tiene la cualidad Saveable. Las implementaciones de un objetoSaver deben anular dos funciones:

  • save para convertir el valor original en uno guardado.
  • restore para convertir el valor restablecido en una instancia de la clase original

Para este caso, en lugar de crear una implementación personalizada de Saver para la clase EditableUserInputState, puedes usar algunas de las APIs de Compose existentes, como listSaver o mapSaver (que almacena los valores que se deben guardar en un elemento List o Map) para reducir la cantidad de código que debes escribir.

Te recomendamos que coloques las definiciones de Saver cerca de la clase con la que trabaja. Como se debe acceder de manera estática, agrega Saver para EditableUserInputState en un companion object. En el archivo base/EditableUserInput.kt, agrega la implementación de Saver:

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

En este caso, usas un objeto listSaver como detalle de implementación para almacenar y restablecer una instancia de EditableUserInputState en el objeto Saver.

Ahora, puedes usar este objeto Saver en rememberSaveable (en lugar de remember) en el método rememberEditableUserInputState que creaste antes:

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

Con esto, el estado recordado EditableUserInput sobrevivirá al proceso y las recreaciones de actividad.

Cómo usar el contenedor de estado

Usarás EditableUserInputState en lugar de text y isHint, pero no quieres usarlo solo como un estado interno en CraneEditableUserInput, ya que no hay forma de que el emisor componible pueda controlar el estado. En cambio, quieres elevar EditableUserInputState para que los emisores puedan controlar el estado de CraneEditableUserInput. Si se eleva el estado, se podrá usar el elemento componible en las versiones preliminares y probarlo con mayor facilidad, ya que se puede modificar el estado desde el emisor.

Para ello, debes cambiar los parámetros de la función de componibilidad y asignarle un valor predeterminado en caso de que sea necesario. Para permitir CraneEditableUserInput con sugerencias vacías, agrega un argumento predeterminado:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

Probablemente notaste que el parámetro onInputChanged ya no está disponible. Dado que el estado se puede elevar, si los emisores quieren saber si la entrada cambió, pueden controlar el estado y pasar ese estado a esta función.

A continuación, debes ajustar el cuerpo de la función para usar el estado elevado en lugar del estado interno que se utilizó antes. Después de refactorizar, la función debería verse de la siguiente manera:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.updateText(it) },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

Emisores de contenedores de estado

Como cambiaste la API de CraneEditableUserInput, debes registrar todos los lugares a los que se llama para asegurarte de pasar los parámetros adecuados.

El único lugar del proyecto en el que llamas esta API es en el archivo home/SearchUserInput.kt. Ábrelo y ve a la función de componibilidad ToDestinationUserInput. Deberías ver un error de compilación allí. Como la sugerencia ahora forma parte del contenedor de estado, y quieres una sugerencia personalizada para esta instancia de CraneEditableUserInput en la composición, debes recordar el estado en el nivel de ToDestinationUserInput y pasarlo a CraneEditableUserInput:

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

El código anterior no tiene funcionalidad para notificar al emisor de ToDestinationUserInput cuando cambia la entrada. Debido a la estructura de la app, no quieres elevar el EditableUserInputState más alto en la jerarquía. No quieres vincular los otros elementos componibles, como FlySearchContent, con este estado. ¿Cómo puedes llamar a la expresión lambda onToDestinationChanged desde ToDestinationUserInput y seguir usando este elemento componible?

Puedes activar un efecto secundario con LaunchedEffect cada vez que cambie la entrada y llamar a la expresión lambda onToDestinationChanged:

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

Ya usaste LaunchedEffect y rememberUpdatedState, pero el código anterior también usa una API nueva. La API de snapshotFlow convierte objetos State<T> de Compose en un Flow. Cuando muta el estado leído en snapshotFlow, Flow emite el valor nuevo para el recopilador. En este caso, conviertes el estado en un flujo para usar la potencia de los operadores de flujo. Como consecuencia, implementas filter cuando text no es hint y collect los elementos emitidos para notificar al elemento superior que cambió el destino actual.

No hay cambios visuales en este paso del codelab, pero mejoras la calidad de esta parte del código. Si ejecutas la app ahora, deberías ver que todo funciona como antes.

8. DisposableEffect

Cuando presionas un destino, se abre la pantalla de detalles y puedes ver dónde está la ciudad en el mapa. Ese código se encuentra en el archivo details/DetailsActivity.kt. En el elemento CityMapView componible, llamas a la función rememberMapViewWithLifecycle. Si abres esta función, que está en el archivo details/MapViewUtils.kt, verás que no está conectada a ningún ciclo de vida. Solo recuerda un MapView y llama a onCreate:

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

Aunque la app se ejecuta correctamente, este problema se debe a que el objeto MapView no sigue el ciclo de vida correcto. Por lo tanto, no se sabrá cuándo la app pasará a segundo plano, cuándo se debe pausar la vista, etc. Tendremos que solucionar este problema.

Como MapView es un elemento View y no un elemento componible, quieres que siga el ciclo de vida de la actividad en la que se usa y también el ciclo de vida de la composición. Eso significa que debes crear un objeto LifecycleEventObserver para escuchar los eventos de ciclo de vida y llamar a los métodos correctos en MapView. Luego, debes agregar este observador al ciclo de vida de la actividad actual.

Para comenzar, crea una función que muestre un LifecycleEventObserver que llame a los métodos correspondientes en un objeto MapView dado un evento determinado:

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

Ahora, debes agregar este observador al ciclo de vida actual, que puedes obtener usando el objeto LifecycleOwner actual con la composición LocalLifecycleOwner local. Sin embargo, no es suficiente agregar el observador; también tienes que quitarlo. Necesitas un efecto secundario que te indique cuándo el efecto sale de la composición para que puedas realizar un código de limpieza. La API de efecto secundario que estás buscando es DisposableEffect.

DisposableEffect está diseñada para efectos secundarios que se deben limpiar después de que las claves cambian o el elemento componible deja la composición. El código rememberMapViewWithLifecycle final hace exactamente eso. Implementa las siguientes líneas en el proyecto:

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

El observador se agrega al objeto lifecycle actual y se quitará cada vez que cambie el ciclo de vida actual o cuando este elemento componible abandone la composición. Con los objetos key en DisposableEffect, si lifecycle o mapView cambian, se quitará el observador y se volverá a agregar el objeto lifecycle adecuado.

Con los cambios que acabas de realizar, el objeto MapView siempre seguirá el elemento lifecycle del objeto LifecycleOwner actual, y su comportamiento será como si se hubiera usado en el mundo de View.

Ejecuta la app y abre la pantalla de detalles para asegurarte de que MapView se procese correctamente. No hay cambios visuales en este paso.

9. produceState

En esta sección, mejorarás el inicio de la pantalla de detalles. El elemento DetailsScreen componible en el archivo details/DetailsActivity.kt obtiene el objeto cityDetails de manera síncrona desde el ViewModel y llama a DetailsContent si el resultado es correcto.

Sin embargo, cityDetails podría evolucionar y demorar más en cargarse en el subproceso de IU y podría usar corrutinas para mover la carga de los datos a un subproceso diferente. Mejorarás este código para agregar una pantalla de carga y mostrar el objeto DetailsContent cuando los datos estén listos.

Una forma de modelar el estado de la pantalla es con la siguiente clase que abarca todas las posibilidades: los datos para mostrar en la pantalla y las señales de carga y error. Agrega la clase DetailsUiState al archivo DetailsActivity.kt:

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

Podrías asignar lo que debe mostrar la pantalla y el objeto UiState en la capa ViewModel usando un flujo de datos, un StateFlow de tipo DetailsUiState, que ViewModel actualiza cuando la información está lista y Compose recopila con la API de collectAsStateWithLifecycle() que ya conoces.

Sin embargo, para este ejercicio, implementarás una alternativa. Si quisieras mover la lógica de asignación de uiState al mundo de Compose, podrías usar la API de produceState.

produceState te permite convertir el estado que no es de Compose en un estado de Compose. Inicia una corrutina cuyo alcance es la composición, que puede enviar valores al objeto State que se muestra mediante la propiedad value. Al igual que con LaunchedEffect, produceState también usa claves para cancelar y reiniciar el cálculo.

En tu caso de uso, puedes usar produceState para emitir actualizaciones de uiState con un valor inicial de DetailsUiState(isLoading = true), de la siguiente manera:

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

A continuación, según el objeto uiState, muestras los datos o la pantalla de carga o informas el error. Este es el código completo del elemento DetailsScreen componible:

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

Si ejecutas la app, verás cómo aparece el ícono giratorio de carga antes de que se muestren los detalles de la ciudad.

aa8fd1ac660266e9.gif

10. derivedStateOf

La última mejora que realizarás en Crane será mostrar un botón para Desplazarse hacia arriba cada vez que te desplaces en la lista de destinos de vuelo después de pasar el primer elemento de la pantalla. Si presionas el botón, accederás al primer elemento de la lista.

2c112d73f48335e0.gif

Abre el archivo base/ExploreSection.kt que contiene este código. El elemento ExploreSection componible corresponde a lo que ves en el fondo del andamiaje.

Para calcular si el usuario pasó el primer elemento, usa LazyListState de LazyColumn y verifica si listState.firstVisibleItemIndex > 0.

Una implementación simple se vería de la siguiente manera:

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

Esta solución no es tan eficiente como podría ser, porque la función de componibilidad que lee showButton se recompone con la misma frecuencia que cambia firstVisibleItemIndex, lo que sucede seguido cuando te desplazas. En cambio, quieres que la función se recomponga solo cuando la condición cambie entre true y false.

Hay una API que te permite realizar estas acciones: la API de derivedStateOf.

listState es un State observable de Compose. Tu cálculo, showButton, también debe ser un State de Compose, ya que quieres que la IU se recomponga cuando cambie su valor, y que se oculte o muestre el botón.

Usa derivedStateOf cuando quieres utilizar un State de Compose que deriva de otro State. El bloque de cálculo derivedStateOf se ejecuta cada vez que cambia el estado interno, pero la función de componibilidad solo se recompone cuando el resultado del cálculo es diferente del último. Esto minimiza la cantidad de veces que se recomponen las funciones que leen showButton.

En este caso, usar la API de derivedStateOf es una alternativa mejor y más eficiente. También unirás la llamada con la API de remember para que el valor calculado sobreviva a la recomposición.

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

Debes conocer el nuevo código del elemento componible ExploreSection. Usas un Box para colocar el Button que se muestra condicionalmente sobre ExploreList: También usas rememberCoroutineScope para llamar a la función de suspensión listState.scrollToItem dentro de la devolución de llamada onClick de Button.

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

Si ejecutas la app, verás el botón en la parte inferior una vez que te desplaces y pases el primer elemento de la pantalla.

11. ¡Felicitaciones!

¡Felicitaciones! Completaste correctamente este codelab y aprendiste conceptos avanzados de estado y efectos secundarios relacionados con las APIs en una app de Jetpack Compose.

Aprendiste cómo crear contenedores de estado, efectos secundarios, como LaunchedEffect, rememberUpdatedState, DisposableEffect, produceState y derivedStateOf, y cómo usar corrutinas en Jetpack Compose.

¿Qué sigue?

Consulta los otros codelabs sobre la ruta de Compose y otras muestras de código, como Crane.

Documentación

Para obtener más información y orientación sobre estos temas, consulta la siguiente documentación: