Saltar al contenido
K
Favoritos con RoomPaso a paso28 min Intermedio

Deslizar y deshacer en Android

Al terminar: Quitar un favorito deslizando la fila y ofrecer «Deshacer» con un aviso que no se pierde al girar la pantalla.

Quitar todos los favoritos de golpe ya se puede; quitar uno todavía exige volver a la rejilla y desmarcar su corazón. Al terminar esta lección bastará con deslizar su fila hacia la izquierda: la fila se va, abajo sale un aviso «Pikachu quitado de favoritos» con un botón Deshacer, y si lo pulsas el favorito vuelve a su sitio de antes, en medio de la lista y no arriba del todo.

Lo difícil de esta lección no es el gesto, sino el aviso. Un aviso que sale una sola vez parece cosa de la pantalla, pero la pantalla se destruye y se vuelve a crear cada vez que giras el móvil, y un aviso que se pierde por girar es un fallo que alguien acabará sufriendo. Por eso el aviso vive en el estado, y esa decisión es lo que se cuenta primero.

androidApp
src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites
FavoritesScreen.ktse modifica
Un solo fichero, y ninguno del módulo compartido.

El concepto · Un aviso es un mensaje de una sola vez

Un Snackbar es el aviso breve que sale abajo, sobre el contenido, con un texto y, a veces, un botón («Quitado de favoritos · Deshacer»). Se va solo a los pocos segundos. Es un componente de Material (el sistema de diseño de Google), y su equivalente en iOS no existe: por eso esta lección es solo de Android.

La pregunta de diseño es quién decide que el aviso salga. Es el ViewModel (quitar es una decisión suya), pero quien lo pinta es la pantalla. Hay dos formas de comunicar «saca un aviso»:

  • Como un evento: el ViewModel lo lanza al aire («¡aviso!») y la pantalla, si está escuchando en ese instante, lo recoge. Si en ese instante la pantalla se está recreando porque has girado el móvil, nadie escucha y el aviso se pierde.
  • Como un estado: el ViewModel lo deja puesto en FavoritesUiState.quitado y se queda ahí. La pantalla lo ve en cuanto está lista, lo enseña y, cuando termina, le dice al ViewModel que ya lo vio (mensajeMostrado()) para que lo borre. Si la pantalla se recrea a mitad, el mensaje sigue puesto y se enseña otra vez.

Esa es la respuesta a «¿por qué no un Channel o un SharedFlow, que parecen hechos para avisos?». Un Channel es una cola por la que se mandan mensajes que se leen una sola vez, y un SharedFlow es un flujo que reparte cada valor a quien esté escuchando en ese instante (Quitar con deshacer lo muestra con un ejemplo). El ViewModel vive más que la pantalla: si el aviso sale mientras nadie escucha (al girar, al cambiar de pestaña), se pierde. Como estado, espera a que la pantalla lo vea y lo consuma.

Un ejemplo con otros datos: la lista de la compra

Un ejemplo solo para leer (no lo escribas en el proyecto), con una lista de la compra: deslizas un producto, sale el aviso y «Deshacer» lo devuelve a su sitio. Es la misma jugada que harás en la Pokédex, con el estado dentro de la propia pantalla en vez de en un ViewModel, para ver las piezas juntas.

ejemplo mínimo: deslizar para quitar y «Deshacer» en una lista de la compra (con comentarios)
@Composable
fun ListaDeLaCompra() {
    // mutableStateListOf: una lista que Compose vigila; si le quitas o pones algo, repinta.
    val productos = remember { mutableStateListOf("Pan", "Leche", "Huevos") }
    // Pair<Int, String> es un par de valores (aquí, posición y nombre); el «?» y el null
    // dicen que puede estar vacío: no hay nada quitado.
    var quitado by remember { mutableStateOf<Pair<Int, String>?>(null) }
    val avisos = remember { SnackbarHostState() }                           // quien pinta los Snackbar
 
    // Mientras haya algo quitado, se enseña el aviso; al acabar, se olvida.
    LaunchedEffect(quitado) {
        // Si no hay nada quitado, sal del efecto; si lo hay, `val (a, b) =` abre el par en sus dos valores.
        val (posicion, nombre) = quitado ?: return@LaunchedEffect
        val resultado = avisos.showSnackbar(
            message = "$nombre quitado",
            actionLabel = "Deshacer",                 // el botón del aviso
            duration = SnackbarDuration.Short,        // unos 4 segundos
        )
        if (resultado == SnackbarResult.ActionPerformed) productos.add(posicion, nombre)
        quitado = null
    }
 
    Scaffold(snackbarHost = { SnackbarHost(avisos) }) { relleno ->
        LazyColumn(Modifier.padding(relleno)) {
            items(productos, key = { it }) { producto ->
                val umbral = SwipeToDismissBoxDefaults.positionalThreshold
                // Settled: la fila está en reposo, sin deslizar.
                val estado = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, umbral) }
                SwipeToDismissBox(
                    state = estado,
                    enableDismissFromStartToEnd = false,          // solo hacia la izquierda
                    onDismiss = {                                 // la fila ya salió de la pantalla
                        val posicion = productos.indexOf(producto)
                        if (posicion >= 0) {
                            productos.removeAt(posicion)
                            quitado = posicion to producto       // `a to b` crea el par (a, b)
                        }
                    },
                    backgroundContent = {                         // lo que asoma debajo al deslizar
                        Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.errorContainer))
                    },
                ) {
                    Text(producto, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(20.dp))
                }
            }
        }
    }
}

Lo que se ve: tres filas, «Pan», «Leche» y «Huevos». Desliza «Leche» hacia la izquierda: la fila se va, asoma debajo un fondo rojizo mientras la arrastras y abajo sale «Leche quitado» con el botón «Deshacer». Si pulsas el botón, «Leche» vuelve a estar en medio, entre «Pan» y «Huevos». Si no pulsas nada, el aviso se va a los 4 segundos y «Leche» queda quitada.

Las piezas de colecciones, en una línea cada una: mutableStateListOf es una lista que Compose vigila, Pair<Int, String> guarda dos valores juntos, posicion to producto crea ese par y val (posicion, nombre) = … lo abre en sus dos valores (Colecciones y Lambdas y funciones de orden superior). Las piezas de Compose son las nuevas, en el orden en que aparecen:

  • SnackbarHostState es quien gestiona los avisos: los pone en cola y sabe cuál se ve. Se crea una vez (remember) y se le entrega a un SnackbarHost, el hueco donde se pintan. El Scaffold ya tiene un hueco para eso, el parámetro snackbarHost.
  • showSnackbar(...) es una función que espera (una función suspend, como las de Corrutinas y suspend): no termina cuando el aviso sale, sino cuando desaparece, y devuelve cómo: SnackbarResult.ActionPerformed si pulsaron el botón o SnackbarResult.Dismissed si se fue sin más. Por eso solo se puede llamar desde una corrutina, y por eso va dentro de un LaunchedEffect, que es la corrutina atada a la pantalla de Efectos en Compose: LaunchedEffect.
  • LaunchedEffect(quitado): la clave es el propio mensaje. Cuando cambia, Compose cancela el efecto anterior y lanza otro nuevo. Eso ocurre, por ejemplo, si quitas dos seguidos: el segundo aviso sustituye al primero, porque el primero se cancela.
  • duration = SnackbarDuration.Short son unos 4 segundos. Hay que escribirlo: si el aviso lleva botón y no das duración, el valor por defecto es Indefinite y no se iría nunca.
  • SwipeToDismissBox es la fila deslizable. backgroundContent es lo que asoma debajo mientras arrastras, el contenido de dentro es la fila, enableDismissFromStartToEnd = false deja deslizar solo hacia la izquierda, y onDismiss se llama cuando la fila ya salió de la pantalla. SwipeToDismissBoxState lleva la cuenta de cuánto la has arrastrado (Settled es «en reposo, sin deslizar»), y el umbral dice cuánto hay que arrastrar para que cuente como quitar.

Paso 1 · La fila deslizable

El archivo. FavoritesScreen.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt), el que creaste en Pestañas en Android. Añade esta función debajo de FavoritesScreen. Es el ejemplo de la lista de la compra con el nombre de la Pokédex:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
@Composable
private fun FilaDeslizable(favorito: FavoritePokemon, alPulsar: () -> Unit, alQuitar: () -> Unit) {
    val umbral = SwipeToDismissBoxDefaults.positionalThreshold
    // remember y no rememberSwipeToDismissBoxState (que lo guarda con
    // rememberSaveable): si la fila vuelve con «Deshacer», tiene que volver
    // entera, no deslizada fuera de la pantalla como la dejo el gesto.
    val estadoDeslizamiento = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, umbral) }
 
    SwipeToDismissBox(
        state = estadoDeslizamiento,
        enableDismissFromStartToEnd = false,   // solo hacia la izquierda
        // Se llama cuando la fila ya salio de la pantalla. `quitar` borra por
        // numero: si llegara dos veces, la segunda no encuentra nada.
        onDismiss = { alQuitar() },
        backgroundContent = {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.errorContainer)
                    .padding(horizontal = 24.dp),
                contentAlignment = Alignment.CenterEnd,
            ) {
                Icon(
                    Icons.Default.Delete,
                    contentDescription = null,
                    tint = MaterialTheme.colorScheme.onErrorContainer,
                )
            }
        },
    ) {
        Fila(favorito, alPulsar)
    }
}

Dos decisiones que parecen detalle y no lo son:

remember y no rememberSwipeToDismissBoxState(). La segunda es lo que verás en casi todos los ejemplos, pero guarda el estado con rememberSaveable (la variante que sobrevive a girar), y la LazyColumn conserva lo guardado por clave (key = { it.id }). Cuando un favorito se quita y luego se deshace, vuelve con la misma clave y, con la variante rememberSaveable, vuelve con el estado «deslizada fuera». El resultado, en el emulador: al pulsar «Deshacer» el favorito no vuelve. La fila renace deslizada fuera, onDismiss se dispara otra vez y quitar la borra de nuevo; por eso, tras pulsar «Deshacer», vuelve a salir el aviso (es el del segundo quitado). Con remember el estado nace nuevo al volver la fila, y la fila vuelve entera. (Si quieres verlo tú, cambia esa línea por rememberSwipeToDismissBoxState(), desliza un favorito y pulsa «Deshacer»; después vuelve a ponerla como está arriba.)

alQuitar llamará a quitar, no a toggle. quitar borra por número, guarda la fila entera para poder devolverla (Quitar con deshacer) y es inofensiva si llega dos veces: la segunda vez no encuentra la fila y no hace nada. toggle marca o desmarca, así que una segunda llamada volvería a marcar el favorito. Compose decide cuándo llama a onDismiss y puede llamarla más de una vez para la misma fila (por ejemplo, si la fila se vuelve a dibujar todavía deslizada fuera, como en el caso de arriba), así que mejor una función que no se rompa por repetirse.

Hasta el paso 2, FilaDeslizable saldrá en gris (sin usar) y varios nombres en rojo: faltan import. Se añaden todos juntos, una sola vez, al principio del paso 2.

Paso 2 · El aviso en la pantalla

Ahora la pantalla usa la fila nueva y enseña el aviso. En FavoritesScreen, tres cambios.

Primero, los import que faltan, los de la FilaDeslizable del paso 1 y los del aviso (añádelos con Option+Enter o a mano, junto a los que ya tienes):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.runtime.LaunchedEffect

Segundo, el estado y el efecto del aviso, dentro de FavoritesScreen, debajo de la línea de estadoHoja:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
    val avisos = remember { SnackbarHostState() }   // quien enseña los Snackbar de esta pantalla
 
    // El aviso es un mensaje de una sola vez que vive en el estado: se enseña
    // mientras `quitado` no sea null y, al terminar, se le dice al ViewModel
    // que ya se vio. Si la pantalla se recrea a mitad (al girar el movil), se
    // vuelve a enseñar: no se pierde.
    LaunchedEffect(estado.quitado) {
        val quitado = estado.quitado ?: return@LaunchedEffect
        val resultado = avisos.showSnackbar(
            message = "${quitado.name.replaceFirstChar { it.uppercase() }} quitado de favoritos",
            actionLabel = "Deshacer",
            // Con un boton, el valor por defecto es Indefinite: no se iria nunca.
            duration = SnackbarDuration.Short,
        )
        if (resultado == SnackbarResult.ActionPerformed) viewModel.deshacer() else viewModel.mensajeMostrado()
    }

Frase a frase. La clave del efecto es estado.quitado, igual que en el ejemplo: cada favorito quitado lanza un efecto nuevo. estado.quitado ?: return@LaunchedEffect dice «si no hay nada quitado, sal de aquí» (es la salida temprana de Nulabilidad: ?: actúa si el valor es null y return sale; la @LaunchedEffect pegada es una etiqueta que dice de qué bloque se sale, aquí el del efecto, y no de FavoritesScreen). Es la misma salida que usa el ejemplo de la lista de la compra. Después showSnackbar espera hasta que el aviso desaparece, y según cómo se cerró el aviso se lo cuenta al ViewModel: si pulsaron Deshacer, viewModel.deshacer(); si se fue solo, viewModel.mensajeMostrado().

La diferencia con el ejemplo es dónde vive el mensaje. En la lista de la compra era un remember de la pantalla, que se pierde al girar; aquí está en FavoritesUiState.quitado, dentro del ViewModel, que sobrevive al giro.

Tercero, el Scaffold y la lista. El Scaffold recibe el hueco de los avisos, y cada fila de la lista pasa a ser una FilaDeslizable que avisa al ViewModel con quitar. Son dos cambios, y el resto del fichero no cambia.

El primero: dentro de Scaffold(, entre la línea modifier = modifier.fillMaxSize(), y la de topBar = {, añade una línea:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
        snackbarHost = { SnackbarHost(avisos) },

El segundo: dentro de la lista, sustituye la llamada Fila(favorito, alPulsar = { alPulsar(favorito.id) }) por esta (la Fila pasa a vivir dentro de FilaDeslizable, que ya la usa):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
                    FilaDeslizable(
                        favorito = favorito,
                        alPulsar = { alPulsar(favorito.id) },
                        alQuitar = { viewModel.quitar(favorito) },
                    )

El HorizontalDivider de debajo se queda donde estaba. Con esto se acaba el rojo del paso 1.

El aviso cuelga del Scaffold y no de la lista. Por eso sigue ahí aunque la lista desaparezca: si quitas el último favorito, la pantalla pasa al texto «Todavía no hay favoritos» y el «Deshacer» sigue abajo, pulsable.

Paso 3 · Comprobar

Ejecuta la app (configuración androidApp, como en Ejecutar en Android). Marca tres Pokémon con el corazón, ve a Favoritos y comprueba, en este orden:

  • Deslizar la fila del medio hacia la izquierda: la fila se va y abajo, sobre la barra de pestañas, sale «Nombre quitado de favoritos» con Deshacer a la derecha.
  • No pulsar nada: a los 4 segundos más o menos el aviso se va y el favorito sigue quitado.
  • Quitar otro y pulsar Deshacer a tiempo: la fila vuelve a su sitio, en medio de los otros dos, entera y visible, y el aviso desaparece. Si tardas más de 4 segundos, el aviso ya se habrá ido: repítelo y pulsa antes.
  • Quitar dos seguidos, el segundo mientras el primer aviso sigue a la vista (el aviso dura unos 4 segundos, así que basta con ser rápido): el segundo aviso sustituye al primero, y «Deshacer» devuelve solo el último. El primero queda quitado.
  • Girar el móvil con el aviso puesto (botón de girar en la barra del emulador, o Cmd+← / Cmd+→): el aviso sigue ahí tras el giro, en horizontal; no se pierde. Al girar, el aviso empieza de nuevo y dura otros 4 segundos (el efecto se vuelve a lanzar con el mismo mensaje), y después se va solo. «Deshacer» sigue funcionando tras girar.

Punto de control

  • Deslizar una fila de Favoritos hacia la izquierda la quita y saca el aviso «Nombre quitado de favoritos» con Deshacer.
  • «Deshacer» devuelve el favorito a su sitio de antes, con la fila visible; sin pulsar, el aviso se va a los 4 segundos y el favorito sigue quitado.
  • Dos quitados seguidos, el segundo con el primer aviso aún a la vista: el segundo aviso sustituye al primero y «Deshacer» actúa sobre el último.
  • Girar el móvil con el aviso puesto no lo pierde.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test sigue en BUILD SUCCESSFUL con 51 tests en la JVM, 40 en Android host y 42 en iOS: la lección no añade ninguno, porque es pantalla (los dos tests de «quitar» y «deshacer» se escribieron en Quitar con deshacer).

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué el aviso de «Deshacer» va en el estado (FavoritesUiState.quitado) y no en un Channel o un SharedFlow?

  2. 2.Si creas el estado de la fila con rememberSwipeToDismissBoxState(), al pulsar «Deshacer» el favorito no vuelve. ¿Qué lo causa y cómo se evita?

  3. 3.¿Qué pasa si llamas a showSnackbar con actionLabel = "Deshacer" y sin duration?

  4. 4.El efecto del aviso es LaunchedEffect(estado.quitado). Si quitas dos favoritos seguidos, ¿qué ocurre?

  5. 5.¿Qué devuelve showSnackbar y cuándo?

  6. 6.¿Por qué alQuitar llama a viewModel.quitar(favorito) y no a viewModel.toggle(…)?

  7. 7.Giras el móvil mientras el aviso «Deshacer» está en pantalla. ¿Qué pasa y por qué?

Faltan 7 de 7