Universidad del Quindío
Programa de Ingeniería de Sistemas y Computación
Título: Navegación en Jetpack Compose
Docente: Carlos Andrés Florez V.

Navegación en Jetpack Compose (Parte 2)

Introducción

La navegación es un aspecto crucial en el desarrollo de aplicaciones móviles, ya que permite a los usuarios moverse entre diferentes pantallas y funcionalidades de la aplicación. En Jetpack Compose, la navegación se maneja a través de la biblioteca Navigation Compose, que facilita la creación y gestión de rutas dentro de una aplicación.

En la clase anterior, aprendimos a navegar entre pantallas cuando el usuario interactúa con botones. En esta clase nos enfocaremos en composables que permiten organizar mejor la navegación, como NavigationBar, NavigationDrawer, y Tabs.

Tabs

Las pestañas (Tabs) son una forma común de organizar el contenido en una aplicación. Para crear pestañas, podemos usar el componente TabRow junto con Tab. A continuación, se muestra un ejemplo básico de cómo implementar pestañas en una aplicación:

@Composable
fun TabsExample() {
    // Definir las pestañas
    val tabs = listOf("Home", "Profile", "Settings")
    // Estado para la pestaña seleccionada
    var selectedTabIndex by remember { mutableStateOf(0) }
    Column {
        // Crear la fila de pestañas 
        TabRow(selectedTabIndex = selectedTabIndex) {
            tabs.forEachIndexed { index, title ->
                // Crear cada pestaña con su título y acción de selección
                Tab(
                    selected = selectedTabIndex == index,
                    onClick = { selectedTabIndex = index },
                    text = { Text(title) }
                )
            }
        }
        // Cargar el contenido correspondiente a la pestaña seleccionada
        when (selectedTabIndex) {
            0 -> HomeScreen()
            1 -> ProfileScreen()
            2 -> SettingsScreen()
        }
    }
}

Tabs de material:

Como ejemplo de diseño, las pestañas de material se ven así:

Tabs

La barra de navegación (Navigation Bar) es una forma de proporcionar acceso rápido a las principales secciones de una aplicación. Podemos usar el componente NavigationBar para crear una barra de navegación en la parte inferior de la pantalla. Un NavigationBar típicamente contiene varios NavigationBarItem, cada uno representando un elemento de navegación, similar a las pestañas.

Algo importante a tener en cuenta es que NavigationBar debe estar contenido dentro de un Scaffold, que es un componente que proporciona una estructura básica para la pantalla, incluyendo áreas para la barra de navegación, la barra superior, y el contenido principal. Además, típicamente, se utiliza junto con NavHost para gestionar la navegación entre diferentes pantallas. Dado que requiere la creación de múltiples composables y la configuración de un sistema de navegación, su implementación completa puede ser más extensa.

Como ejemplo de diseño, la barra de navegación de material se ve así:

Navigation Bar

Aclaración sobre el grafo de navegación

Tenga en cuenta que el grafo de navegación para la barra de navegación inferior es diferente al grafo de navegación principal de la aplicación, ya que se utiliza para gestionar la navegación dentro de una sección específica de la aplicación (por ejemplo, la sección de usuarios), mientras que el grafo de navegación principal gestiona la navegación entre las diferentes secciones de la aplicación (por ejemplo, entre login, registro, y dashboard).

Gracias a esta estructura, podemos tener una navegación más organizada y modular, donde cada sección de la aplicación tiene su propio grafo de navegación interno que se encarga de gestionar las pantallas específicas de esa sección.

A manera de ejemplo, un NavigationBar en la sección de usuarios podría ver algo así:

Navigation Bar *Fuente: Stack Overflow

No es que se navegue a una pantalla diferente, sino que se navega a diferentes secciones dentro de la misma pantalla principal (en este caso, la pantalla del dashboard para usuarios). Esto es lo que se conoce como navegación anidada (nested navigation), donde un NavHost está contenido dentro de otro NavHost.

Grafo de navegación de nuestra aplicación

El grafo de navegación de nuestra aplicación se verá algo así después de implementar la barra de navegación en el dashboard:

App Navigation Graph
├── Login
├── Register
└── MainScreen (decide según el rol)
    ├── UserNavigation (rol USER, con NavigationBar)
    │   ├── HomeUser
    │   ├── Search
    │   └── Profile
    └── AdminNavigation (rol ADMIN, con NavigationBar)
        ├── HomeAdmin
        └── Profile

A diferencia de un diseño con una pantalla separada por rol, usaremos un único MainScreen que recibe el rol del usuario y, a partir de él, decide qué barra de navegación mostrar y qué grafo interno cargar (UserNavigation o AdminNavigation). Así reutilizamos el Scaffold, la barra superior y la barra inferior para ambos roles.

Tabs vs Navigation Bar

Aunque tanto las pestañas (Tabs) como la barra de navegación (Navigation Bar) se utilizan para organizar el contenido y facilitar la navegación, tienen diferencias clave en su uso y propósito:

Característica Tabs Navigation Bar
Ubicación Generalmente en la parte superior de la pantalla Generalmente en la parte inferior de la pantalla
Limite de elementos Puede manejar un número mayor de secciones (hasta 5-6) Ideal para un número limitado de secciones (3-5)
Uso principal Organizar contenido relacionado en secciones dentro de una misma pantalla Proporcionar acceso rápido a las principales secciones de la aplicación
Navegación Cambia el contenido dentro de la misma pantalla Navega a diferentes pantallas o secciones de la aplicación
Ejemplo de uso Pestañas para diferentes categorías de productos en una aplicación de compras Barra de navegación para acceder a Home, Search, Profile en una aplicación de redes sociales

Implementación de Navigation Bar

Vamos a integrar un NavigationBar en el proyecto que hemos estado desarrollando en las clases anteriores. A continuación, se muestra un ejemplo de cómo implementar una barra de navegación en una aplicación Jetpack Compose:

1. Crear la estructura de paquetes

Supongamos que una vez que se inicie sesión, el usuario verá una pantalla principal con una barra de navegación en la parte inferior. Dicha barra permitirá al usuario navegar entre tres secciones principales: “Inicio”, “Busqueda” y “Perfil”.

Para organizar mejor el código, crearemos una estructura de archivos y carpetas como la siguiente:

features/
├── dashboard/                              # Sección principal después del login
│   ├── MainScreen.kt                       # Contenedor: Scaffold + navegación según el rol
│   ├── DashboardRoutes.kt                  # Rutas internas del dashboard
│   ├── admin/                              # Navegación de administradores
│   │   ├── AdminDestination.kt             # Items de la barra inferior (admin)
│   │   └── AdminNavigation.kt              # NavHost interno (admin)
│   ├── component/                          # Componentes reutilizables
│   │   ├── NavItem.kt                      # Contrato común de un item de navegación
│   │   ├── BottomNavigationBar.kt
│   │   └── TopAppBar.kt
│   └── user/                               # Navegación de usuarios
│       ├── UserDestination.kt             # Items de la barra inferior (usuario)
│       └── UserNavigation.kt              # NavHost interno (usuario)
├── home/
│   └── HomeScreen.kt
├── login/
│   ├── LoginScreen.kt
│   └── LoginViewModel
├── register/
│   ├── RegisterScreen.kt
│   └── RegisterViewModel
├── report/
│   ├── create/
│   │   ├── CreateReportScreen.kt
│   │   └── CreateReportViewModel
│   ├── detail/
│   │   ├── ReportDetailScreen.kt
│   │   └── ReportDetailViewModel
│   └── list/
│       ├── ReportListScreen.kt
│       └── ReportListViewModel
└── user/
    ├── profile/
    │   ├── ProfileScreen.kt
    │   └── ProfileViewModel
    └── search/
        ├── SearchScreen.kt
        └── SearchViewModel

2. Crear el componente principal MainScreen

En el archivo MainScreen.kt, crearemos el componente principal que contendrá el Scaffold con la barra superior y la NavigationBar. Lo importante es que MainScreen recibe el rol del usuario y, a partir de él, decide qué items mostrar y qué grafo interno cargar.

package com.example.demoapp.features.dashboard

import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.demoapp.domain.model.UserRole
import com.example.demoapp.features.dashboard.admin.AdminDestination
import com.example.demoapp.features.dashboard.admin.AdminNavigation
import com.example.demoapp.features.dashboard.component.BottomNavigationBar
import com.example.demoapp.features.dashboard.component.NavItem
import com.example.demoapp.features.dashboard.component.TopAppBar
import com.example.demoapp.features.dashboard.user.UserDestination
import com.example.demoapp.features.dashboard.user.UserNavigation

@Composable
fun MainScreen(
    role: UserRole,
    onLogout: () -> Unit
) {
    val navController = rememberNavController()

    // Items de la barra inferior según el rol del usuario
    val items: Array<out NavItem> = when (role) {
        UserRole.ADMIN -> AdminDestination.entries.toTypedArray()
        UserRole.USER -> UserDestination.entries.toTypedArray()
    }

    // Título de la barra superior: el label del item cuya ruta es la pantalla actual
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination
    val title = items.firstOrNull {
        currentDestination?.route == it.route::class.qualifiedName
    }?.label ?: ""

    // Estructura Scaffold (barra superior, barra inferior y contenido)
    Scaffold(
        topBar = {
            TopAppBar(
                title = title,
                logout = onLogout // Función para cerrar sesión, que se pasa desde el componente padre
            )
        },
        bottomBar = {
            // La barra inferior es reutilizable: recibe los items según el rol
            BottomNavigationBar(
                items = items,
                navController = navController
            )
        }
    ) { padding ->
        // El contenido (grafo interno) también depende del rol
        when (role) {
            UserRole.USER -> UserNavigation(navController = navController, padding = padding)
            UserRole.ADMIN -> AdminNavigation(navController = navController, padding = padding)
        }
    }
}

Observe que MainScreen no conoce las pantallas concretas: solo arma el Scaffold y delega el contenido en UserNavigation o AdminNavigation (cada uno con su propio NavHost, distinto del principal de la aplicación). Gracias a que tanto UserDestination como AdminDestination implementan la interfaz NavItem, la misma BottomNavigationBar sirve para ambos roles.

3. Definir los items de navegación (NavItem, UserDestination, AdminDestination)

Para que la barra inferior sea reutilizable, definimos primero un contrato común que todo item de navegación debe cumplir. En el archivo component/NavItem.kt:

package com.example.demoapp.features.dashboard.component

import androidx.compose.ui.graphics.vector.ImageVector
import com.example.demoapp.features.dashboard.DashboardRoutes

// Contrato común para cada item de la barra de navegación inferior
interface NavItem {
    val route: DashboardRoutes
    val label: String
    val icon: ImageVector
}

Luego, definimos los items del rol usuario en user/UserDestination.kt:

package com.example.demoapp.features.dashboard.user

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.graphics.vector.ImageVector
import com.example.demoapp.features.dashboard.DashboardRoutes
import com.example.demoapp.features.dashboard.component.NavItem

enum class UserDestination(
    override val route: DashboardRoutes,
    override val label: String,
    override val icon: ImageVector,
) : NavItem {
    HOME(DashboardRoutes.HomeUser, "Home", Icons.Default.Home),
    SEARCH(DashboardRoutes.Search, "Buscar", Icons.Default.Search),
    PROFILE(DashboardRoutes.Profile, "Perfil", Icons.Default.AccountCircle)
}

Y los items del rol administrador en admin/AdminDestination.kt:

package com.example.demoapp.features.dashboard.admin

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Home
import androidx.compose.ui.graphics.vector.ImageVector
import com.example.demoapp.features.dashboard.DashboardRoutes
import com.example.demoapp.features.dashboard.component.NavItem

enum class AdminDestination(
    override val route: DashboardRoutes,
    override val label: String,
    override val icon: ImageVector,
) : NavItem {
    HOME(DashboardRoutes.HomeAdmin, "Home", Icons.Default.Home),
    PROFILE(DashboardRoutes.Profile, "Perfil", Icons.Default.AccountCircle)
}

4. Crear la barra de navegación inferior BottomNavigationBar

En el archivo component/BottomNavigationBar.kt, crearemos el componente de la barra de navegación inferior. A diferencia del enfoque inicial, ahora recibe los items como parámetro (Array<out NavItem>), por lo que sirve tanto para usuarios como para administradores.

package com.example.demoapp.features.dashboard.component

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState

@Composable
fun BottomNavigationBar(
    items: Array<out NavItem>, // Items de navegación (usuario o administrador)
    navController: NavHostController
){
    // Obtener la entrada actual de la pila de navegación
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination

    // Crear la barra de navegación inferior
    NavigationBar(
        modifier = Modifier.fillMaxWidth(),
    ){
        // Iteramos cada item recibido
        items.forEach { destination ->

            // Verificar si el item está seleccionado
            val isSelected = currentDestination?.route == destination.route::class.qualifiedName

            NavigationBarItem(
                label = {
                    Text(text = destination.label)
                },
                onClick = {
                    // Navegar a la ruta correspondiente al item seleccionado
                    navController.navigate(destination.route){
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
                icon = {
                    Icon(
                        imageVector = destination.icon,
                        contentDescription = destination.label
                    )
                },
                selected = isSelected
            )
        }
    }
}

5. Crear el top app bar TopAppBar

En el archivo component/TopAppBar.kt, crearemos el componente de la barra superior.

package com.example.demoapp.features.dashboard.component

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun TopAppBar(
    title: String,
    logout: () -> Unit
){
    // Crear la barra superior centrada con título y botón de cierre de sesión
    CenterAlignedTopAppBar(
        modifier = Modifier.fillMaxWidth(),
        title = {
            Text(
                text = title
            )
        },
        actions = {
            // En esta sección agregamos el botón de cierre de sesión
            IconButton(
                onClick = {
                    logout()
                }
            ) {
                Icon(
                    imageVector = Icons.AutoMirrored.Filled.Logout,
                    contentDescription = null
                )
            }
        }
    )
}

6. Configurar la navegación interna (UserNavigation y AdminNavigation)

Cada rol tiene su propio NavHost interno. En el archivo user/UserNavigation.kt configuramos la navegación de la sección de usuarios:

package com.example.demoapp.features.dashboard.user

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.example.demoapp.features.dashboard.DashboardRoutes
import com.example.demoapp.features.report.create.CreateReportScreen
import com.example.demoapp.features.report.detail.ReportDetailScreen
import com.example.demoapp.features.report.list.ReportListScreen
import com.example.demoapp.features.user.profile.ProfileScreen
import com.example.demoapp.features.user.search.SearchScreen

@Composable
fun UserNavigation(
    navController: NavHostController,
    padding: PaddingValues
){

    NavHost(
        navController = navController,
        startDestination = DashboardRoutes.HomeUser
    ) {

        composable<DashboardRoutes.HomeUser> {
            // La pantalla principal de la sección de usuarios que muestra la lista de reportes
            ReportListScreen(
                padding = padding, // Se pasa el padding del Scaffold para evitar solapamientos
                onNavigateToReportDetail = {
                    navController.navigate(DashboardRoutes.ReportDetail(it))
                },
                onNavigateToCreateReport = {
                    navController.navigate(DashboardRoutes.CreateReport)
                }
            )
        }

        composable<DashboardRoutes.Search> {
            SearchScreen() // Debe crear este composable en el paquete user/search
        }

        composable<DashboardRoutes.Profile> {
            ProfileScreen() // Debe crear este composable en el paquete user/profile
        }

        composable<DashboardRoutes.ReportDetail> {
            val args = it.toRoute<DashboardRoutes.ReportDetail>()
            ReportDetailScreen(
                padding = padding, // Se pasa el padding del Scaffold para evitar solapamientos
                reportId = args.reportId
            )
        }

        composable<DashboardRoutes.CreateReport> {
            CreateReportScreen(
                padding = padding, // Se pasa el padding del Scaffold para evitar solapamientos
                onNavigateBack = {
                    navController.popBackStack() // Al crear el reporte, se regresa a la lista
                }
            )
        }
    }

}

Note que ReportListScreen recibe la función onNavigateToCreateReport (la que invoca el botón flotante agregado en la actividad de la guía anterior) y que CreateReportScreen recibe onNavigateBack para regresar a la lista una vez creado el reporte. Si en su implementación usó otros nombres, ajústelos para que coincidan.

Y en admin/AdminNavigation.kt, la navegación de la sección de administradores (más simple por ahora; agréguele las pantallas que su proyecto necesite):

package com.example.demoapp.features.dashboard.admin

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.example.demoapp.features.dashboard.DashboardRoutes
import com.example.demoapp.features.user.profile.ProfileScreen

@Composable
fun AdminNavigation(
    navController: NavHostController,
    padding: PaddingValues
){

    NavHost(
        navController = navController,
        startDestination = DashboardRoutes.HomeAdmin
    ) {

        composable<DashboardRoutes.HomeAdmin> {
            // Pantalla principal del administrador. Créela según las necesidades de su proyecto.
            Box(modifier = Modifier.padding(padding)) {
                Text(text = "Inicio administrador")
            }
        }

        composable<DashboardRoutes.Profile> {
            ProfileScreen()
        }
    }

}

Así, cada sección gestiona sus propias pantallas desde su barra de navegación inferior, y MainScreen solo decide cuál de los dos grafos cargar según el rol.

7. Definir las rutas del dashboard DashboardRoutes

En el archivo DashboardRoutes.kt, definiremos las rutas internas del dashboard. Incluimos las rutas de ambas secciones (usuario y administrador); note que Profile es compartida por los dos roles.

package com.example.demoapp.features.dashboard

import kotlinx.serialization.Serializable

sealed class DashboardRoutes {

    // --- Sección de usuarios ---
    @Serializable
    data object HomeUser : DashboardRoutes()

    @Serializable
    data object Search : DashboardRoutes()

    @Serializable
    data class ReportDetail(val reportId : String) : DashboardRoutes()

    @Serializable
    data object CreateReport : DashboardRoutes()

    // --- Sección de administradores ---
    @Serializable
    data object HomeAdmin : DashboardRoutes()

    // --- Compartida ---
    @Serializable
    data object Profile : DashboardRoutes()
}

8. Actualizar la navegación principal de la aplicación

Finalmente, debemos actualizar la navegación principal para que, una vez que el usuario inicie sesión, se dirija a MainScreen. Como MainScreen necesita el rol, por ahora lo pasamos de forma fija en cada ruta del dashboard.

Agregue el siguiente bloque en el archivo AppNavigation.kt:

composable<MainRoutes.HomeUser> {
    MainScreen(
        role = UserRole.USER,
        onLogout = {
            // Lógica para cerrar sesión y regresar a la pantalla de login
            navController.navigate(MainRoutes.Login) {
                popUpTo(MainRoutes.Login) { inclusive = true } // Evitar regresar a la pantalla anterior
            }
        }
    )
}

composable<MainRoutes.HomeAdmin> {
    MainScreen(
        role = UserRole.ADMIN,
        onLogout = {
            navController.navigate(MainRoutes.Login) {
                popUpTo(MainRoutes.Login) { inclusive = true }
            }
        }
    )
}

Y ajuste la navegación desde la pantalla de inicio de sesión en LoginScreen.kt para que navegue a MainRoutes.HomeUser después de un inicio de sesión exitoso:

composable<MainRoutes.Login> {
    LoginScreen(
        onNavigateToReports = {
            navController.navigate(MainRoutes.HomeUser)
        }
    )
}

Por último, no olvide agregar las rutas HomeUser y HomeAdmin en el archivo MainRoutes.kt.

⚠️ Importante: Quite del MainRoutes.kt y de AppNavigation.kt cualquier referencia a ReportListScreen, ReportDetailScreen y CreateReportScreen, ya que ahora estas pantallas se gestionan dentro de la navegación interna del dashboard.

💡 Por ahora el rol se pasa de forma fija (UserRole.USER / UserRole.ADMIN). En la guía de Data Store la sesión del usuario quedará persistida, y AppNavigation determinará el rol a partir de ella, llamando a MainScreen(role = session.role, ...) sin necesitar las rutas HomeUser/HomeAdmin.

9. Probar la aplicación

Ejecute la aplicación y verifique que, después de iniciar sesión, el usuario sea dirigido a MainScreen con la barra de navegación inferior correspondiente a su rol. Asegúrese de que cada ícono en la barra de navegación funcione correctamente y que el título en la barra superior se actualice según la sección seleccionada.

10. Sección de administradores

Ya dejamos lista la estructura para administradores (AdminDestination + AdminNavigation), reutilizando MainScreen, BottomNavigationBar y TopAppBar. Complete las pantallas propias del administrador (por ejemplo, la gestión de los reportes: validarlos, rechazarlos o marcarlos como resueltos) dentro de AdminNavigation, agregando las rutas que necesite en DashboardRoutes y sus items en AdminDestination.


Actividad práctica

1. Búsqueda de reportes

Investigue cómo implementar la funcionalidad de búsqueda en la pantalla de búsqueda (SearchScreen), de manera que el usuario pueda buscar reportes por título o por tipo. Esto implica usar un SearchBar para que los usuarios puedan ingresar términos de búsqueda y mostrar los resultados correspondientes. Así mismo, debe crear un nuevo ViewModel (SearchViewModel) para manejar la lógica de búsqueda (por ahora puede filtrar sobre la misma lista de reportes de ejemplo; en la guía de arquitectura esta lógica pasará al repositorio compartido).

Para más información, consulte la documentación oficial en Material Design Components o en Jetpack Compose Components.

2. Perfil de usuario

Implemente la pantalla de perfil (ProfileScreen) donde los usuarios puedan ver y editar su información personal, como nombre, correo electrónico, etc.

3. Resto de pantallas

Cree todas las pantallas que faltan para completar la navegación del proyecto, incluyendo sus respectivos ViewModels según sea necesario. Además, cree las demás entidades del dominio que se requieran para estas funcionalidades.