Saltar al contenido
K
Jetpack ComposePaso a paso38 min de lectura · 5 min de máquina Intermedio

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 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):

ejemplo mínimo: pestañas con NavigationBar (con comentarios)
@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:

  1. PantallaHoy lleva 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.
  2. AppAgenda es una barra inferior con tres pestañas, «Hoy», «Semana» y «Notas», que recuerda la activa aunque gires. Dentro de la zona de contenido va otro Scaffold con la barra superior centrada que lleva el título de la pestaña activa. Ninguno de los dos Scaffold reserva el hueco de las barras del sistema. En «Hoy» va PantallaHoy; 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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/pestanas_de_la_agenda/AppAgenda.kt
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.

app
kotlin+java → com.sebastiannarvaez.marcador
MainActivity.ktse modifica
ui
AppMarcador.ktse crea
feature
marcador
ui
PantallaMarcador.ktse modifica
Un fichero nuevo y dos que cambian, en la vista Android.

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): importa WindowInsets y pásale contentWindowInsets = WindowInsets(0) a su Scaffold. La línea va nueva, justo después de modifier = modifier,. Si el import no aparece solo, escribe WindowInsets y pulsa Option+Enter: Android Studio propone el import correcto.
  • MainActivity.kt: dentro de setContent, donde llamabas a PantallaMarcador(…), llama a AppMarcador(…) con los mismos argumentos, y cambia el import de PantallaMarcador por el de AppMarcador. 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 de AppMarcador.kt, interior es el nombre que se le da al hueco del Scaffold de dentro; es un nombre libre, y no se reutiliza paddingValues porque ese es el hueco del Scaffold de fuera y el de dentro necesita el suyo.
  • PantallaMarcador.kt, la barra inferior: si añadiste .navigationBarsPadding() en la Row de bottomBar (la propuesta opcional de Compose: Scaffold, slots y tema), quítalo, y quita también su import. Desde aquí el NavigationBar ya 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
app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
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))
            }
        }
    }
}
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
import androidx.compose.foundation.layout.WindowInsets
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
        contentWindowInsets = WindowInsets(0),

Y en MainActivity.kt, el import y la línea de setContent:

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
import com.sebastiannarvaez.marcador.ui.AppMarcador
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
                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, variante gratisDebug) sin errores en rojo.
  • La barra inferior muestra «Marcador», «Partidos» y «Tablas»; «Partidos» desplaza 18 filas.
  • «Tablas» empieza por 1. Rayo FC con 15 pts y termina con los goleadores, con Nico #11 y 11 goles arriba.
  • 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. 1.Subes un gol en «Marcador», vas a «Partidos» y vuelves: los goles están a 0. ¿Por qué?

  2. 2.¿Qué hace contentWindowInsets = WindowInsets(0) en un Scaffold?

  3. 3.En AppBiblioteca, la pestaña activa se guarda con rememberSaveable. Estás en «Autores» y giras el móvil. ¿Dónde quedas?

  4. 4.¿Cómo se sabe qué pestaña está activa en el AppMarcador de esta lección?

  5. 5.Marcador solo tiene texto en sus pestañas. ¿Qué le pasas al parámetro icon de NavigationBarItem?

Faltan 5 de 5