Efectos secundarios y estados avanzados en Jetpack Compose

En este codelab, aprenderás conceptos avanzados relacionados con las API de estado y efectos secundarios en Jetpack Compose. Veremos 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.

Qué aprenderás

Requisitos

Qué compilarás

En este codelab, comenzaremos con una app sin terminar, la app de estudio de material de Crane, y agregaremos funciones para mejorar la app.

1fb85e2ed0b8b592.gif

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/googlecodelabs/android-compose-codelabs

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

Descargar 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 Arctic Fox.

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.

37d39b9ac4a9d2fa.png

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

1fb85e2ed0b8b592.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/googlecodelabs/android-compose-codelabs

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

Descargar el código final

Preguntas frecuentes

Como posiblemente hayas notado cuando ejecutaste la app desde la rama main, la lista de destinos de vuelos está vacía. Para ver qué sucede, abre el archivo home/CraneHome.kt y consulta 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, corregiremos eso y mostraremos los destinos sugeridos que expone MainViewModel.

9cadb1fd5f4ced3c.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.

Queremos que nuestra IU del elemento componible CraneHomeContent se actualice cada vez que se emita un nuevo elemento en el flujo de datos de suggestedDestinations. Podemos usar la función StateFlow.collectAsState(). Cuando se usa en una función que admite composición, collectAsState() recopila valores de StateFlow y representa el valor más reciente mediante la API de estado de Compose. De esta manera, el código de Compose que lee ese valor de estado se recompone en emisiones nuevas.

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

import androidx.compose.runtime.collectAsState

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

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

4ec666a2d1ac0903.gif

En el proyecto, hay un archivo home/LandingScreen.kt que no se usa en este momento. Queremos 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 emisor que se puede descartar la pantalla de destino mediante la 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 las API que hacen que el uso de corrutinas sea seguro dentro de la capa de IU. Como esta app no se comunica con un backend, usaremos 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 que admite composición. El cambio de estado para ocultar o mostrar la pantalla de destino ocurrirá en la devolución de llamada de onTimeout y, desde que se llama a onTimeout, se deben cargar los elementos con las corrutinas y el cambio de estado debe ocurrir en el contexto de una corrutina.

Para llamar a funciones de suspensión de forma segura dentro de un elemento, 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, veremos cómo usar esta API y analizaremos por qué el siguiente código es incorrecto. Más adelante en este paso, llamaremos al elemento componible LandingScreen.

// home/LandingScreen.kt file

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

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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 API 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 el efecto si onTimeout cambia.

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(true) { ... }. Sin embargo, no podemos protegerte contra los cambios en onTimeout en este momento.

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 esto mediante la captura y la actualización del valor nuevo, usa la API de rememberUpdatedState:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

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

Cómo mostrar la pantalla de destino

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

En el elemento componible MainScreen, simplemente podemos 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.

fda616dda280aa3e.gif

En este paso, haremos 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 componible CraneHome para ver dónde debemos abrir el panel lateral de navegación: en la devolución de llamada openDrawer.

En CraneHome, tenemos 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 API para proteger las corrutinas de las llamadas desde la capa de IU, algunas API 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é podemos hacer? openDrawer es una función de devolución de llamada simple, por lo tanto:

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

Queremos poder lanzar una corrutina. ¿Qué alcance deberíamos usar? Idealmente, queremos un elemento CoroutineScope que siga el ciclo de vida de su sitio de llamada. Para ello, usa la API de rememberCoroutineScope. 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 de menú de opciones.

ad44883754b14efe.gif

LaunchedEffect vs. rememberCoroutineScope

En este caso, no era posible usar LaunchedEffect porque necesitábamos 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 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.

¿Notaste que, si presionas Elegir destino, puedes editar el campo y filtrar las ciudades según lo que ingresaste en la búsqueda? Es probable que también notes que, cuando modificas Elegir destino, también cambia el estilo del texto.

99dec71d23aef084.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

Como CraneEditableUserInput es un componente reutilizable, creemos 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)

    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 tenemos 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 elemento var, lo que permite que mute directamente desde afuera de la clase.
  • 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 tendremos 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, podemos 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 creamos en nuestro proyecto. Por lo tanto, debemos indicarle a rememberSaveable cómo guardar y restablecer una instancia de la clase mediante 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 nuestro caso, en lugar de crear una implementación personalizada de Saver para la clase EditableUserInputState, podemos usar algunas de las API de Compose existentes, comolistSaver omapSaver (que almacena los valores que se deben guardar en un elemento List o Map) a fin de reducir la cantidad de código que debemos 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, agreguemos 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, usamos un objeto listSaver como detalle de implementación para almacenar y restablecer una instancia de EditableUserInputState en el objeto Saver.

Ahora, podemos usar este objeto Saver en rememberSaveable (en lugar de remember) en el método rememberEditableUserInputState que creamos 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

Usaremos EditableUserInputState en lugar de text y isHint, pero no queremos usarlo solo como un estado interno en CraneEditableUserInput, ya que no hay forma de que el emisor que admite composición pueda controlar el estado. En cambio, queremos 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, debemos cambiar los parámetros de la función que admite composición y asignarle un valor predeterminado en caso de que sea necesario. Para permitir CraneEditableUserInput con sugerencias vacías, agregaremos 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, debemos modificar 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.text = 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

Debido a que cambiamos la API de CraneEditableUserInput, debemos registrar todos los lugares a los que se llama para asegurarnos de pasar los parámetros adecuados.

El único lugar del proyecto en el que llamamos esta API es en el archivo home/SearchUserInput.kt. Ábrelo y ve a la función ToDestinationUserInput que admite composición. Deberías ver un error de compilación allí. Como la sugerencia ahora forma parte del contenedor de estado, y queremos una sugerencia personalizada para esta instancia de CraneEditableUserInput en la composición, debemos 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 queremos elevar el EditableUserInputState más alto en la jerarquía porque queremos vincular los otros elementos que admiten composición, como FlySearchContent, con este estado. ¿Cómo podemos llamar a la expresión lambda onToDestinationChanged desde ToDestinationUserInput y seguir usando este elemento componible?

Podemos 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 usamos LaunchedEffect y rememberUpdatedState, pero el código anterior también usa una API nueva. Usamos la API de snapshotFlow para convertir objetos State<T> de Compose a Flow. Cuando el estado leído en snapshotFlow muta, Flow emite el valor nuevo para el colector. En nuestro caso, convertimos el estado en un flujo para usar la potencia de los operadores de flujo. De esta forma, implementamos 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 mejoramos la calidad de esta parte del código. Si ejecutas la app ahora, deberías ver que todo funciona como antes.

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 componible CityMapView, se llama 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, queremos que siga el ciclo de vida de la actividad en la que se usa, en lugar del ciclo de vida de la composición. Eso significa que debemos crear un objeto LifecycleEventObserver para escuchar los eventos del ciclo de vida y llamar a los métodos correctos en MapView. Luego, debemos agregar este observador al ciclo de vida de la actividad actual.

Comencemos creando una función que muestre un objeto LifecycleEventObserver que llame a los métodos correspondientes en un objeto MapView dado de 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, debemos agregar este observador al ciclo de vida actual, que podemos obtener usando el objeto LifecycleOwner actual con la composición LocalLifecycleOwner local. Sin embargo, no es suficiente agregar el observador; también tenemos que quitarlo. Necesitamos un efecto secundario que indique cuándo se está abandonando la composición para que podamos realizar un código de limpieza. La API de efecto secundario que estamos 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 lo objetos key en DisposableEffect, si lifecycle o mapView cambia, se quitará el observador y se volverá a agregar al objeto lifecycle adecuado.

Con los cambios que acabamos 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.

En esta sección, mejoraremos el inicio de la pantalla de detalles. El elemento componible DetailsScreen 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. Mejoremos 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íamos mapear lo que debe mostrar la pantalla y el objeto UiState en la capa ViewModel mediante 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 collectAsState() que ya conoces.

Sin embargo, a los fines de este ejercicio, implementaremos una alternativa. Si quisiéramos mover la lógica de asignación de uiState al mundo de Compose, podríamos 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 nuestro caso de uso, podemos 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, te mostraremos los datos, la pantalla de carga o informaremos el error. Este es el código completo del elemento componible DetailsScreen:

// 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 mostrar los detalles de la ciudad.

18956feb88725ca5.gif

La última mejora que realizaremos 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.

59d2d10bd334bdb.gif

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

La solución para implementar el comportamiento que se muestra en el video no debería sorprenderte. Sin embargo, hay una API nueva que aún no hemos visto y es importante en este caso de uso: la API de derivedStateOf.

derivedStateOf se usa cuando quieres usar un objeto State de Compose que se deriva de otro State. El uso de esta función garantiza que el cálculo solo ocurrirá cuando cambie uno de los estados del cálculo.

Para calcular si el usuario pasó el primer elemento por medio de listState, solo debes verificar si listState.firstVisibleItemIndex > 0. Sin embargo, firstVisibleItemIndex se une en la API de mutableStateOf, lo que lo convierte en un estado observable de Compose. Nuestro cálculo también debe ser un estado de redacción, ya que queremos recomponer la IU para mostrar el botón.

Una implementación ineficiente y simple sería similar al siguiente ejemplo. No lo copies en tu proyecto. La implementación correcta se copiará en tu proyecto con el resto de la lógica para la pantalla más adelante:

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

Una alternativa mejor y más eficiente es usar la API de derivedStateOf que calcula showButton solo cuando cambia listState.firstVisibleItemIndex:

// 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
    }
}

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

// 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 com.google.accompanist.insets.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.

¡Felicitaciones! Completaste correctamente este codelab y aprendiste conceptos avanzados de estado y efectos secundarios relacionados con las API 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: