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
- Cómo observar flujos de datos desde el código de Compose para actualizar la IU
- Cómo crear un contenedor de estado para elementos componibles con estado
- API de efectos secundarios, como
LaunchedEffect
,rememberUpdatedState
,DisposableEffect
,produceState
yderivedStateOf
- Cómo crear corrutinas y llamar a funciones de suspensión en elementos componibles con la API de
rememberCoroutineScope
Requisitos
- Versión más reciente de Android Studio
- Tener experiencia con la sintaxis de Kotlin, incluidas las funciones de lambdas.
- Tener experiencia básica con Compose. Antes de este codelab, considera realizar el codelab de los principios básicos de Jetpack Compose.
- Conceptos básicos de estado en Compose, como el flujo unidireccional de datos (UDF), los ViewModels, la elevación de estado, los elementos componibles con estado o sin estado, las API de ranuras y las API de estado
remember
ymutableStateOf
. Para obtener este conocimiento, procura leer la documentación de estado y Jetpack Compose o completar el codelab Estado en Jetpack Compose. - Conocimientos básicos de las corrutinas de Kotlin
- Conocimientos básicos del ciclo de vida de los elementos componibles
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.
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.
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.
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:
- Abre
home/MainViewModel.kt
. - Define una variable privada
_suggestedDestinations
de tipoMutableStateFlow
para representar la lista de destinos sugeridos y establece una lista vacía como valor inicial.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
- Define una segunda variable inmutable
suggestedDestinations
de tipoStateFlow
. 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 deViewModel
, lo que la convierte en la única fuente de confianza. La función de extensiónasStateFlow
convierte el flujo de mutable a inmutable.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
- En el bloque init de
ViewModel
, agrega una llamada desdedestinationsRepository
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
}
- 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
.
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.
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.
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 enopenDrawer
. 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.
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.
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 tipoString
, al igual que el que tienes enCraneEditableUserInput
. Es importante usarmutableStateOf
para que Compose realice un seguimiento de los cambios del valor y lo recomponga cuando se produzcan.text
es unvar
, con unset
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 eventoupdateText
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 inicializartext
. - La lógica para saber si
text
es la sugerencia o no está en la propiedadisHint
, 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.
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.
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: