El back stack: dar forma a la pila
Navegar hacia adelante es trivial; lo difícil es decidir qué queda detrás. Esta lección trata la pila de navegación como la estructura de datos que es, examina con precisión qué hacen `popUpTo`, `inclusive`, `launchSingleTop`, `saveState` y `restoreState` y en qué orden se aplican, deriva de ahí los patrones canónicos de la barra inferior, del inicio de sesión y del formulario de varios pasos, y explica qué cambia con el gesto atrás predictivo de Android 14 en adelante, por qué obliga a declarar la intención de interceptar antes de que ocurra y cómo se adapta una aplicación que aún captura el `back stack` a la vieja usanza.
Toda aplicación mediocre comparte un mismo defecto y casi nadie lo formula en voz alta: navega bien hacia adelante y desastrosamente hacia atrás. El usuario alterna tres veces entre dos pestañas y necesita seis gestos para salir; inicia sesión y el botón de volver lo devuelve a la pantalla de credenciales que acaba de superar; completa un formulario de cinco pasos y al pulsar atrás desde la confirmación reaparece el paso cinco con los datos ya enviados. Ninguno de esos fallos es un error de programación en el sentido habitual: cada llamada individual hizo exactamente lo que se le pidió. El fallo está en no haber decidido nunca qué debía quedar detrás, y en confiar en que apilar sin criterio produciría por acumulación una historia coherente. La pila no se gestiona sola porque no puede: la pregunta de qué significa volver desde aquí es una pregunta de producto, no de biblioteca, y solo tiene una respuesta correcta cuando alguien la formula explícitamente.
- Describir la pila como estructura de datos y qué operación exacta ejecuta cada gesto.
- Explicar qué hacen
popUpTo,inclusiveylaunchSingleTopy en qué orden se aplican. - Derivar los patrones canónicos de pestañas, de inicio de sesión y de flujo lineal.
- Adaptar el manejo del gesto atrás al modelo predictivo de Android 14 en adelante.
Apilar, desapilar y las opciones que dan forma
La pila es una lista de entradas de la que solo se ve la última. Navegar apila; volver desapila y destruye lo desapilado, con todo lo que colgaba de ello: su estado guardado, sus vistas-modelo y sus efectos. Esa destrucción es el motivo de que la forma de la pila importe tanto, porque decide qué se pierde y cuándo.
Sobre esas dos operaciones básicas se aplican unas opciones que se pasan en la llamada y que conviene entender por separado antes de combinarlas.
navController.navigate(Detalle(id)) {
popUpTo(Catalogo) { inclusive = false; saveState = true }
launchSingleTop = true
restoreState = true
}
popUpTo desapila antes de apilar el destino nuevo, hasta encontrar el destino indicado. inclusive decide si ese destino límite también se desapila o si se conserva. launchSingleTop evita apilar un duplicado si el destino pedido ya está en la cima: en lugar de crear una entrada nueva, reutiliza la existente. Y la pareja saveState con restoreState archiva el estado de lo que se desapila y lo devuelve si esa rama se vuelve a visitar.
Esa última pareja merece precisión porque su asimetría desconcierta. saveState es una opción del recorte: dice que lo desapilado no se tire, sino que se guarde asociado a su destino. restoreState es una opción del apilado: dice que si existe un archivo para el destino que se va a apilar, se recupere en lugar de empezar de cero. Son independientes, y usar una sin la otra produce exactamente la mitad del efecto, que es el motivo habitual de que alguien concluya que la restauración de pestañas no funciona.
Conviene además distinguir las dos formas de retroceder, porque no son sinónimas. Desapilar quita la última entrada sin más. Subir un nivel consulta la relación jerárquica declarada y puede saltar varias entradas de golpe, lo que importa cuando el usuario entró por un enlace profundo y no hay historia real detrás. Para el gesto del sistema la primera es la correcta; para una flecha de la barra superior, la segunda suele serlo.
flowchart TD
START[Llamada a navigate con opciones] --> P{Hay popUpTo}
P -->|Si| POP[Desapila hasta el destino limite]
P -->|No| SINGLE
POP --> SAVE{saveState activo}
SAVE -->|Si| ARCH[Archiva el estado de lo desapilado]
SAVE -->|No| SINGLE
ARCH --> SINGLE{launchSingleTop y ya esta en la cima}
SINGLE -->|Si| REUSA[Reutiliza la entrada existente]
SINGLE -->|No| PUSH[Apila una entrada nueva]
PUSH --> REST{restoreState activo}
REST -->|Si| RECUP[Recupera el estado archivado]
REST -->|No| FIN[La pila queda con su forma nueva]
style ARCH fill:#a6e3a1,color:#11111b
style RECUP fill:#a6e3a1,color:#11111bEl orden del diagrama no es un detalle de implementación: es lo que explica los resultados que parecen contradictorios. Como el desapilado ocurre antes que el apilado, launchSingleTop se evalúa contra la pila ya recortada, no contra la original. Quien razona en el orden inverso predice mal cada vez que combina ambas opciones, y suele concluir que una de las dos no funciona.
Los tres patrones que resuelven casi todo
Pestañas con estado propio
Desapila hasta el destino inicial del grafo guardando estado, evita duplicados y restaura al volver. Cada pestaña conserva su desplazamiento y sus datos, y el gesto atrás sale de la aplicación desde cualquiera.
Salir de un vestíbulo
Tras iniciar sesión o completar la bienvenida, desapila el vestíbulo de forma inclusiva. La pantalla de credenciales deja de existir y volver atrás desde el inicio cierra la aplicación, que es lo correcto.
Flujo lineal cerrado
Al llegar a la confirmación de un proceso de varios pasos, desapila todos los pasos intermedios de forma inclusiva. Volver atrás lleva al punto de partida, no a un formulario ya enviado.
El patrón de pestañas merece verse escrito porque concentra las cuatro opciones a la vez y porque es el que más se copia sin entender.
fun NavController.irAPestana(destino: Any) {
navigate(destino) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
Sin saveState y restoreState cada cambio de pestaña destruiría la rama abandonada y la reconstruiría desde cero: el desplazamiento vuelve al principio y los datos se recargan. Sin launchSingleTop, pulsar dos veces la misma pestaña apila un duplicado. Y sin popUpTo, la pila crece indefinidamente y salir de la aplicación exige deshacer todo el recorrido histórico.
Los otros dos patrones comparten una intuición y conviene enunciarla porque generaliza: hay pantallas que existen para ser abandonadas. Un vestíbulo de sesión, una bienvenida, un formulario ya enviado, una pantalla de carga inicial. Volver a ellas no es una posibilidad razonable sino un fallo, y la forma de decirlo es desapilarlas de forma inclusiva en el mismo momento en que se superan.
// Salir de un vestibulo: el destino de partida deja de existir.
navController.navigate(Inicio) {
popUpTo(Vestibulo) { inclusive = true }
}
Existe una prueba mental barata que decide entre inclusivo y no inclusivo sin tener que recordar la semántica: pregúntate si el destino límite es un lugar al que quedaría alguna decisión por tomar. Si la respuesta es sí —el catálogo desde el que se abrió un detalle, la lista desde la que se entró en un elemento—, se conserva. Si es no —una pantalla superada, un formulario enviado, una carga terminada—, se desapila también.
Merece la pena señalar además un límite de estos patrones que rara vez se documenta: la forma de la pila corrige la historia, no las condiciones de entrada. Recortar el flujo de compra al confirmar evita que el usuario regrese al pago, pero no impide que un enlace profundo lo deposite ahí mañana sin haber pasado por el carrito. Lo primero es diseño de navegación; lo segundo es una comprobación de estado dentro del destino, y ninguna opción de la llamada la sustituye.
Hay un cuarto caso que no llega a patrón pero que aparece en cuanto la aplicación tiene enlaces profundos: el regreso masivo. Un usuario que ha bajado seis niveles y pulsa un botón de volver al inicio no debe deshacer seis veces; lo correcto es desapilar hasta el destino inicial de una sola operación, conservándolo. La diferencia con encadenar retrocesos no es de rendimiento sino de significado: se está afirmando que todo lo intermedio deja de ser un lugar al que regresar.
Al restaurar, el sistema no reproduce los gestos que se dieron: recupera la lista de entradas tal como quedó. Por eso el recorte que hiciste al superar un vestíbulo sigue vigente tras una restauración, y por eso una pila mal formada tampoco se arregla sola. Lo que se guardó es la forma final, no la historia que la produjo, que es otra manera de decir que la pila describe un lugar y no un recorrido.
Cuando el usuario quiere retroceder, la operación correcta es desapilar, no navegar hacia el destino previo. Navegar apila una entrada nueva aunque la ruta coincida, con lo que la aplicación acumula historia falsa: dos entradas del catálogo en la pila, un ViewModel nuevo que no comparte estado con el anterior y un gesto atrás que devuelve a un sitio por el que ya se pasó. El síntoma clásico es una pila que solo crece y una aplicación de la que cuesta salir.
El gesto atrás predictivo
Hasta Android 13 el sistema no sabía qué iba a pasar al volver hasta que la aplicación se lo decía, y por eso el gesto era instantáneo y opaco: el usuario lo completaba y descubría después a dónde había ido a parar. El modelo predictivo invierte ese orden. Mientras el dedo se desliza, el sistema muestra una vista previa animada de lo que hay detrás, y el gesto se puede cancelar a mitad devolviendo la pantalla a su sitio.
Para poder anticipar, el sistema necesita saber antes de que el gesto empiece si alguien va a interceptarlo. De ahí el cambio de contrato: la intercepción se declara de forma anticipada y con estado, en lugar de resolverse en el momento con una llamada que decide sobre la marcha. Una aplicación que captura el retroceso a la manera antigua no puede participar en la animación, porque el sistema no tiene forma de saber qué dibujar al otro lado.
var hayCambiosSinGuardar by remember { mutableStateOf(false) }
var mostrarDialogo by remember { mutableStateOf(false) }
BackHandler(enabled = hayCambiosSinGuardar) {
mostrarDialogo = true
}
La clave está en el parámetro que habilita la intercepción: cuando vale falso, la aplicación no participa y el sistema anima la salida con total libertad; cuando vale verdadero, el sistema sabe de antemano que habrá una interrupción y ajusta lo que muestra. Interceptar siempre y decidir dentro es precisamente lo que rompe el modelo, porque convierte cada retroceso en impredecible para quien tiene que dibujarlo.
Hay un segundo nivel de participación, más ambicioso, para los casos en que la propia aplicación quiere animarse durante el gesto en lugar de limitarse a reaccionar al final. En ese modo la intercepción recibe un flujo de eventos con el progreso del deslizamiento, y el contenido puede interpolar su salida a medida que el dedo avanza, replegarse si el gesto se cancela y completarse si llega al final. Es el mecanismo con el que un panel lateral se cierra siguiendo el dedo en lugar de desaparecer de golpe.
Conviene resistir la tentación de usarlo en todas partes. La transición entre destinos ya la anima el anfitrión, y competir con ella produce movimientos que se estorban. El progreso del gesto sirve para elementos que la navegación no gobierna: paneles, hojas inferiores, modos de edición dentro de una misma pantalla.
Capturar el retroceso desactiva una expectativa que el usuario tiene interiorizada desde hace más de una década, así que el listón debe ser alto: pérdida real de trabajo, un panel abierto que debe cerrarse antes, un proceso que no se puede abandonar a medias. Confirmar la salida, mostrar publicidad o intentar retener al usuario no son motivos, y el sistema penaliza esos usos con animaciones que se sienten rotas. Cuando dudes, no interceptes: la pila bien formada resuelve la mayoría de los casos que la intercepción pretendía arreglar.
Participar en la animación predictiva exige declararlo en el manifiesto y revisar cada punto donde la aplicación capture el retroceso. La conversión no es automática: una llamada que decide sobre la marcha se convierte en una intercepción con estado que expresa la misma condición, y hasta que no se convierten todas, el sistema mantiene el comportamiento antiguo para no producir animaciones incoherentes.
Conviene detenerse en qué representa realmente esta estructura, porque la respuesta explica por qué su diseño no puede delegarse en ninguna convención. La pila de navegación es un modelo del recorrido mental de una persona a través de tu aplicación, y la operación de volver no significa deshacer la última acción sino regresar al lugar desde el que se tomó la última decisión. Esos dos enunciados suenan parecidos y son profundamente distintos: la diferencia entre ellos es exactamente el conjunto de pantallas que deben desaparecer al superarse. Una pantalla de credenciales superada no es un lugar desde el que quede ninguna decisión pendiente, y por eso conservarla en la pila no es una comodidad sino una mentira sobre el estado del mundo, que además obliga al usuario a atravesar de nuevo un sitio del que ya salió. Un formulario enviado es peor todavía, porque volver a él sugiere que aún se puede editar algo que ya está consumado. Y en el otro extremo, una pestaña abandonada sí es un lugar con decisiones pendientes, y por eso destruir su estado al cambiar de pestaña se percibe como una traición: el usuario recuerda dónde estaba y la aplicación no. Todo el vocabulario técnico de esta lección —desapilar hasta un punto, hacerlo de forma inclusiva, archivar y recuperar, evitar duplicados en la cima— existe únicamente para poder expresar esas distinciones con precisión. Lo que se está escribiendo cuando se configuran esas opciones no es una optimización de memoria ni una preferencia de estilo de transición: es una afirmación sobre qué partes del recorrido siguen siendo lugares a los que tiene sentido regresar. Por eso ninguna biblioteca puede acertarlo por defecto, y por eso una aplicación en la que volver atrás siempre hace lo esperado transmite una sensación de solidez que ningún detalle visual consigue: comunica que alguien pensó en la persona que la usa, no solo en las pantallas que la componen.
- Dibuja la pila esperada tras cinco gestos concretos de tu aplicación y contrasta el dibujo con la pila real leída del controlador.
- Implementa el patrón de pestañas completo, quita después una opción cada vez y describe con exactitud qué se rompe en cada caso.
- Convierte un vestíbulo de sesión en desapilado inclusivo y comprueba que el gesto atrás desde el inicio cierra la aplicación.
- Cierra un flujo de tres pasos al llegar a la confirmación y razona por qué el desapilado debe ser inclusivo y no basta con recortar.
- Sustituye una intercepción incondicional del retroceso por una condicional y comprueba en un dispositivo con Android 14 o superior qué cambia en la animación.