Pestañas con NavigationBar
Al terminar: Navegar entre pantallas con NavigationBar, ajustar los insets y reproducir el error de la clave repetida.
Al terminar esta lección, Marcador tendrá tres pestañas en una barra inferior: Marcador (lo que ya tenías), Partidos (18 partidos en una lista que se desplaza) y Tablas (la tabla de posiciones y los goleadores). Además habrás provocado a propósito el cierre inesperado (la app se cae) que da usar un Jugador como clave de fila, con su mensaje literal.
Importa porque casi ninguna app cabe en una sola pantalla, y una barra de pestañas es la forma más sencilla de moverse entre varias. También te enseña un límite que sorprende: lo que una pestaña recuerda se pierde cuando sales de ella. Temas: NavigationBar, los insets (el hueco que dejan las barras del sistema) y Scaffold dentro de otro.
NavigationBar: una barra de pestañas
NavigationBar es la barra inferior de Material 3 y cada pestaña es un NavigationBarItem(selected, onClick, icon, label): selected dice si está resaltada, onClick qué pasa al tocarla, label es su texto e icon su dibujo. icon es obligatorio; Marcador solo tiene texto y le pasa un slot vacío, icon = {}.
La barra va en el slot bottomBar de un Scaffold. Para saber qué pestaña está activa basta un número (0, 1, 2) guardado con rememberSaveable, y un when que, según ese número, dibuja una pantalla u otra. forEachIndexed (Secuencias y formato de tablas) recorre la lista de títulos con su posición, y esa posición es el número de pestaña.
Esto no es navegación de verdad: la pestaña activa es un Int y un when elige la pantalla. La navegación real llega en Navigation 3: la pila de pantallas.
Una consecuencia que verás en el emulador: al pasar a otra pestaña, el when deja de llamar a PantallaMarcador, que sale de la composición, y remember olvida lo suyo. rememberSaveable cubre rotar la pantalla, no esto: cuando un composable sale de la composición, también se pierde lo que guardaba con rememberSaveable (la pestaña activa sí sobrevive porque vive en AppMarcador, que no sale). Si subes un gol en «Marcador», vas a «Partidos» y vuelves, los goles están a 0 otra vez.
Los insets y un Scaffold dentro de otro
Android tiene dos barras del sistema: la barra de estado, arriba (hora, batería, cobertura), y la barra de gestos o de navegación, abajo (la rayita o los tres botones). Una app moderna se dibuja por debajo de las dos: tu contenido llega hasta el borde físico del móvil, y si no reservas un hueco, esas barras tapan lo que haya debajo. Ese hueco es lo que se llama insets. Ya lo viste en Compose: Scaffold, slots y tema, donde el botón de abajo podía quedar tapado.
Cada Scaffold reserva ese hueco por defecto para su contenido. Aquí hay un Scaffold externo con la barra inferior (AppMarcador) y, dentro, otro por pestaña (cada una necesita su propia barra superior con su título); PantallaMarcador ya tenía el suyo. Con dos Scaffold uno dentro de otro, los dos reservarían el hueco de arriba y sobraría un espacio:
Sin cambios (hueco doble) Con WindowInsets(0) en cada Scaffold
+-----------------------+ +-----------------------+
| barra de estado | | barra de estado |
| hueco 1 (externo) | +-----------------------+
| hueco 2 (interno) | | barra superior | <- reserva el de arriba
+-----------------------+ | contenido |
| barra superior | +-----------------------+
| contenido | | NavigationBar | <- reserva el de abajo
+-----------------------+ | barra de gestos |
| NavigationBar | +-----------------------+
| barra de gestos |
+-----------------------+El código pone contentWindowInsets = WindowInsets(0) en todos los Scaffold (el externo, los internos y PantallaMarcador), o sea «tú no reserves nada». El hueco no se pierde: lo reservan las propias barras, CenterAlignedTopAppBar el de la barra de estado (arriba) y NavigationBar el de la barra de gestos (abajo). Por eso también se toca PantallaMarcador: si no, su Scaffold seguiría reservando el de arriba dentro del externo.
Una barra de pestañas completa, en una biblioteca inventada (los datos y nombres no son los de Marcador; ListaDeLibros es la del ejemplo de la lección anterior):
@OptIn(ExperimentalMaterial3Api::class) // CenterAlignedTopAppBar aún es experimental
@Composable
fun AppBiblioteca() {
var pestana by rememberSaveable { mutableIntStateOf(0) } // qué pestaña está activa (0, 1…)
val titulos = listOf("Libros", "Autores")
Scaffold( // Scaffold EXTERNO: solo lleva la barra inferior
contentWindowInsets = WindowInsets(0), // no reserva el hueco: lo reserva la NavigationBar
bottomBar = {
NavigationBar { // la barra inferior de Material 3; reserva el de gestos
titulos.forEachIndexed { indice, titulo -> // recorre la lista con su posición
NavigationBarItem(
selected = pestana == indice, // resaltada si es la activa
onClick = { pestana = indice }, // al tocarla, pasa a ser la activa
icon = {}, // obligatorio; aquí, un slot vacío
label = { Text(titulo) },
)
}
}
},
) { paddingValues -> // hueco que ocupa la barra de abajo
Scaffold( // Scaffold INTERNO: lleva la barra de arriba
Modifier.padding(paddingValues), // respeta el hueco del externo
contentWindowInsets = WindowInsets(0), // tampoco reserva: lo reserva la barra superior
topBar = { CenterAlignedTopAppBar(title = { Text(titulos[pestana]) }) }, // reserva el de la barra de estado
) { interior -> // hueco del interno, con su propio nombre
// Al cambiar de pestaña, el `when` deja de llamar a ListaDeLibros: SALE de la composición
// y todo lo que recordaba (incluido su desplazamiento) se pierde. `pestana` no, porque vive aquí arriba.
when (pestana) { // elige qué pantalla se dibuja
0 -> ListaDeLibros(listOf("Rayuela"), emptyList(), Modifier.padding(interior))
else -> Text("Autores", Modifier.padding(interior))
}
}
}
}Lo que se ve en pantalla: arriba, una barra con el título «Libros» (el del Scaffold interno); abajo, otra con «Libros» (resaltada) y «Autores». En medio, el título «Libros» en grande y la fila 1. … Rayuela (el número a la izquierda y el título a la derecha, porque weight(1f) le da al número todo el sitio que sobra). En «Autores», la barra de arriba pasa a decir «Autores» y en medio solo está el texto «Autores».
Reto · Ejercicio: la agenda de tres pestañas
Crea la subcarpeta pestanas_de_la_agenda dentro de ejercicios (la carpeta de La rotación y la muerte del proceso): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.pestanas_de_la_agenda. Luego clic derecho sobre esa carpeta → New → Kotlin Class/File → File → AppAgenda. Escribe en ese fichero dos composables:
PantallaHoylleva un contador recordado (no guardado para el giro) que empieza en 0. Muestra «Tareas hechas hoy: N» y un botón «Hecha» que lo sube.AppAgendaes una barra inferior con tres pestañas, «Hoy», «Semana» y «Notas», que recuerda la activa aunque gires. Dentro de la zona de contenido va otroScaffoldcon la barra superior centrada que lleva el título de la pestaña activa. Ninguno de los dosScaffoldreserva el hueco de las barras del sistema. En «Hoy» vaPantallaHoy; en «Semana», el texto «Lunes: dentista»; en «Notas», «Comprar pilas».
El hueco del Scaffold de dentro necesita su propio nombre en la lambda (en la solución, interior). Añade una vista previa de AppAgenda y ábrela en modo interactivo (el botón que aparece al pasar el ratón sobre ella, explicado en Estado en Compose): pulsa «Hecha» tres veces, toca «Semana» y vuelve a «Hoy». Antes de probar, responde: ¿qué número crees que verás?
Debes ver «Tareas hechas hoy: 3» tras las pulsaciones; en «Semana», la barra de arriba dice «Semana» y en medio «Lunes: dentista»; y al volver a «Hoy», «Tareas hechas hoy: 0», mientras que la pestaña resaltada se conserva.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.pestanas_de_la_agenda
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun PantallaHoy(modifier: Modifier = Modifier) {
var hechas by remember { mutableIntStateOf(0) } // se pierde al salir de la pestaña
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Tareas hechas hoy: $hechas")
Button(onClick = { hechas++ }) { Text("Hecha") }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppAgenda(modifier: Modifier = Modifier) {
var pestana by rememberSaveable { mutableIntStateOf(0) }
val titulos = listOf("Hoy", "Semana", "Notas")
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0),
bottomBar = {
NavigationBar {
titulos.forEachIndexed { indice, titulo ->
NavigationBarItem(
selected = pestana == indice,
onClick = { pestana = indice },
icon = {},
label = { Text(titulo) },
)
}
}
},
) { paddingValues ->
Scaffold(
Modifier.padding(paddingValues),
topBar = { CenterAlignedTopAppBar(title = { Text(titulos[pestana]) }) },
contentWindowInsets = WindowInsets(0),
) { interior ->
when (pestana) {
0 -> PantallaHoy(Modifier.padding(interior))
1 -> Text("Lunes: dentista", Modifier.padding(interior).padding(16.dp))
else -> Text("Comprar pilas", Modifier.padding(interior).padding(16.dp))
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppAgendaPreview() {
MaterialTheme { AppAgenda() }
}Vuelves a ver 0 porque, al tocar «Semana», el when deja de llamar a PantallaHoy: sale de la composición y pierde lo que recordaba, incluido el contador. pestana, en cambio, vive en AppAgenda, que no sale, y por eso la barra de abajo sigue resaltando la pestaña correcta. Es lo mismo que pasaría con una lista de 200 libros desplazada hasta el 150: al volver, empezaría desde arriba.
Paso · las tres pestañas de Marcador
Objetivo. Crear AppMarcador, una raíz con una barra inferior de tres pestañas, y ponerla en lugar de PantallaMarcador en la Activity. Al terminar ves «Marcador», «Partidos» y «Tablas» abajo; «Partidos» desplaza 18 filas y «Tablas» muestra la clasificación y los goleadores.
Crear el fichero. En la vista Android, clic derecho sobre com.sebastiannarvaez.marcador (en app → kotlin+java) → New → Kotlin Class/File, escribe ui.AppMarcador (con ui. delante, para que quede dentro de la carpeta ui) y elige File. Esa carpeta ui es la de la plantilla, la que ya guarda el tema: en la vista Android la ves como ui.theme en una sola línea, porque theme era su única hija, y ahora se abre en ui con theme y AppMarcador.kt dentro. Comprueba que el editor se abre con package com.sebastiannarvaez.marcador.ui.
Por qué esta carpeta y no otra: las pestañas no son de ninguna feature (enlazan las pantallas de marcador y de partidos) ni son un servicio compartido como lo de core; son la cáscara de la app, y esa vive en ui, junto al tema. No instalas nada nuevo: NavigationBar está en Material 3, que ya tenías.
Cambios en ficheros que ya tienes. Ábrelos con doble clic en el panel:
PantallaMarcador.kt(feature → marcador → ui): importaWindowInsetsy pásalecontentWindowInsets = WindowInsets(0)a suScaffold. La línea va nueva, justo después demodifier = modifier,. Si elimportno aparece solo, escribeWindowInsetsy pulsa Option+Enter: Android Studio propone elimportcorrecto.MainActivity.kt: dentro desetContent, donde llamabas aPantallaMarcador(…), llama aAppMarcador(…)con los mismos argumentos, y cambia elimportdePantallaMarcadorpor el deAppMarcador. Los bloques de abajo muestran esa línea; si la tuya tiene otro aspecto, lo único que cambia es el nombre de la función. En el bloque deAppMarcador.kt,interiores el nombre que se le da al hueco delScaffoldde dentro; es un nombre libre, y no se reutilizapaddingValuesporque ese es el hueco delScaffoldde fuera y el de dentro necesita el suyo.PantallaMarcador.kt, la barra inferior: si añadiste.navigationBarsPadding()en laRowdebottomBar(la propuesta opcional de Compose: Scaffold, slots y tema), quítalo, y quita también suimport. Desde aquí elNavigationBarya reserva ese hueco y se sumaría dos veces.
Intenta escribir AppMarcador tú, a partir de esta descripción: recibe onVerDemos (la función que abre la pantalla de pruebas) y se la pasa a PantallaMarcador; tiene una barra inferior de tres pestañas, «Marcador», «Partidos» y «Tablas», que recuerda la activa aunque gires el móvil; la primera pestaña es PantallaMarcador, y las dos últimas, PantallaPartidos con DatosDeEjemplo.partidos y PantallaTablas con la tabla y los goleadores del torneo de ejemplo, cada una con su propia barra superior con el título centrado.
Debes ver 18 filas desplazables en «Partidos» (nombre del local, marcador y nombre del visitante), y en «Tablas» la clasificación con Rayo FC primero.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.sebastiannarvaez.marcador.feature.marcador.ui.PantallaMarcador
import com.sebastiannarvaez.marcador.feature.partidos.data.DatosDeEjemplo
import com.sebastiannarvaez.marcador.feature.partidos.ui.PantallaPartidos
import com.sebastiannarvaez.marcador.feature.partidos.ui.PantallaTablas
import com.sebastiannarvaez.marcador.feature.partidos.ui.goleadoresConGoles
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppMarcador(onVerDemos: () -> Unit, modifier: Modifier = Modifier) {
var pestana by rememberSaveable { mutableIntStateOf(0) }
val titulos = listOf("Marcador", "Partidos", "Tablas")
val torneo = DatosDeEjemplo.torneo
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0),
bottomBar = {
NavigationBar {
titulos.forEachIndexed { indice, titulo ->
NavigationBarItem(
selected = pestana == indice,
onClick = { pestana = indice },
icon = {},
label = { Text(titulo) },
)
}
}
},
) { paddingValues ->
when (pestana) {
0 -> PantallaMarcador(onVerDemos, Modifier.padding(paddingValues))
1 -> Scaffold(
Modifier.padding(paddingValues),
topBar = { CenterAlignedTopAppBar(title = { Text("Partidos") }) },
contentWindowInsets = WindowInsets(0),
) { interior -> PantallaPartidos(DatosDeEjemplo.partidos, Modifier.padding(interior)) }
else -> Scaffold(
Modifier.padding(paddingValues),
topBar = { CenterAlignedTopAppBar(title = { Text("Tablas") }) },
contentWindowInsets = WindowInsets(0),
) { interior ->
PantallaTablas(torneo.tablaDePosiciones(), torneo.goleadoresConGoles(), Modifier.padding(interior))
}
}
}
}import androidx.compose.foundation.layout.WindowInsets contentWindowInsets = WindowInsets(0),Y en MainActivity.kt, el import y la línea de setContent:
import com.sebastiannarvaez.marcador.ui.AppMarcador if (verPruebas) PantallaDePruebas(tabla) else AppMarcador(onVerDemos = { verPruebas = true })El resto de esos ficheros no cambia.
Pruébalo en el emulador
Arranca el emulador como en La Activity y ejecuta la app con Run (selector app, variante gratisDebug). Toca las tres pestañas. Sube un gol en «Marcador», ve a «Partidos» y vuelve: los goles están a 0, como se explicó arriba. En la pestaña «Marcador» verás dos barras inferiores, una encima de la otra: el botón «Pruebas de Android», que es de PantallaMarcador, y debajo la barra de pestañas. Es lo esperado.
Punto de control
- La app compila y se ejecuta con Run (selector
app, variantegratisDebug) sin errores en rojo. - La barra inferior muestra «Marcador», «Partidos» y «Tablas»; «Partidos» desplaza 18 filas.
- «Tablas» empieza por
1. Rayo FCcon15 ptsy termina con los goleadores, conNico #11y11 golesarriba. - Subes un gol en «Marcador», vas a «Partidos» y vuelves: los goles están a 0.
- Con la clave del
Jugador, la pestaña «Tablas» cierra la app; con"jugador-${jugador.nombre}", vuelve a funcionar.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Subes un gol en «Marcador», vas a «Partidos» y vuelves: los goles están a 0. ¿Por qué?
2.¿Qué hace
contentWindowInsets = WindowInsets(0)en unScaffold?3.En
AppBiblioteca, la pestaña activa se guarda conrememberSaveable. Estás en «Autores» y giras el móvil. ¿Dónde quedas?4.¿Cómo se sabe qué pestaña está activa en el
AppMarcadorde esta lección?5.Marcador solo tiene texto en sus pestañas. ¿Qué le pasas al parámetro
icondeNavigationBarItem?