Setup

dependencies {
    implementation("androidx.navigation:navigation-compose:2.8.4")
    // Si usás Hilt:
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
}

En Compose, el NavGraph se define en código Kotlin en lugar de XML. NavHost es el composable que gestiona qué pantalla se muestra:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "productos"
    ) {
        composable("productos") {
            ProductosScreen(
                onNavADetalle = { id -> navController.navigate("detalle/$id") }
            )
        }

        composable(
            route = "detalle/{productoId}",
            arguments = listOf(navArgument("productoId") { type = NavType.IntType })
        ) { backStackEntry ->
            val id = backStackEntry.arguments?.getInt("productoId") ?: return@composable
            DetalleScreen(
                productoId = id,
                onNavAtras = { navController.navigateUp() }
            )
        }

        composable("perfil") {
            PerfilScreen()
        }
    }
}

El NavController no debe pasarse al ViewModel (rompería el principio de que el ViewModel no conoce la UI). La navegación se maneja mediante eventos del ViewModel que el Screen composable observa:

// En el ViewModel — emite un evento
fun onProductoGuardado() {
    viewModelScope.launch {
        repo.guardar(uiState.value.producto)
        _eventos.send(ProductosEvento.NavAtras)
    }
}

// En el Screen composable — observa el evento y navega
LaunchedEffect(Unit) {
    viewModel.eventos.collect { evento ->
        when (evento) {
            is ProductosEvento.NavADetalle ->
                navController.navigate("detalle/${evento.id}")
            ProductosEvento.NavAtras ->
                navController.navigateUp()
        }
    }
}

Type-safe Navigation — Navigation 2.8+

Navigation 2.8 introduce type-safe routes con @Serializable. Elimina los strings de rutas y los argumentos tipados de forma manual:

// Definí tus rutas como objetos o data classes serializables
@Serializable object Productos
@Serializable data class Detalle(val productoId: Int)
@Serializable object Perfil

// NavHost con type-safe routes
NavHost(navController, startDestination = Productos) {
    composable<Productos> {
        ProductosScreen(
            onNavADetalle = { id -> navController.navigate(Detalle(id)) }
        )
    }

    composable<Detalle> { backStackEntry ->
        val args: Detalle = backStackEntry.toRoute()
        DetalleScreen(
            productoId = args.productoId,
            onNavAtras = navController::navigateUp
        )
    }

    composable<Perfil> { PerfilScreen() }
}

// Navegar — type-safe, sin strings
navController.navigate(Detalle(productoId = 42))
navController.navigate(Perfil)

Preferí type-safe navigationSi tu proyecto usa Navigation 2.8+, usá siempre las rutas tipadas. Elimina toda una clase de bugs (typos en strings, tipos incorrectos) y hace el código más legible.

Bottom Navigation con Compose

// Definir los tabs
sealed class TabDestino(val ruta: String, val icono: ImageVector, val label: String) {
    object Productos : TabDestino("productos", Icons.Default.ShoppingCart, "Productos")
    object Buscar   : TabDestino("buscar",    Icons.Default.Search,       "Buscar")
    object Perfil   : TabDestino("perfil",    Icons.Default.Person,       "Perfil")
}

val tabs = listOf(TabDestino.Productos, TabDestino.Buscar, TabDestino.Perfil)

@Composable
fun AppConBottomNav() {
    val navController = rememberNavController()
    val navBackStack by navController.currentBackStackEntryAsState()
    val rutaActual = navBackStack?.destination?.route

    Scaffold(
        bottomBar = {
            NavigationBar {
                tabs.forEach { tab ->
                    NavigationBarItem(
                        selected = rutaActual == tab.ruta,
                        onClick = {
                            navController.navigate(tab.ruta) {
                                // Evitar múltiples copias del mismo destino en el back stack
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = { Icon(tab.icono, contentDescription = tab.label) },
                        label = { Text(tab.label) }
                    )
                }
            }
        }
    ) { padding ->
        NavHost(
            navController = navController,
            startDestination = TabDestino.Productos.ruta,
            modifier = Modifier.padding(padding)
        ) {
            composable(TabDestino.Productos.ruta) { ProductosScreen(...) }
            composable(TabDestino.Buscar.ruta) { BuscarScreen(...) }
            composable(TabDestino.Perfil.ruta) { PerfilScreen(...) }
        }
    }
}

Grafos anidados — organizar la navegación

NavHost(navController, startDestination = "auth") {

    // Grafo de autenticación
    navigation(startDestination = "login", route = "auth") {
        composable("login") { LoginScreen(...) }
        composable("registro") { RegistroScreen(...) }
    }

    // Grafo principal (post-login)
    navigation(startDestination = "home", route = "main") {
        composable("home") { HomeScreen(...) }
        composable("detalle/{id}") { DetalleScreen(...) }
        composable("perfil") { PerfilScreen(...) }
    }
}

// Navegar y limpiar el back stack de auth:
navController.navigate("main") {
    popUpTo("auth") { inclusive = true }
}

Resultado entre pantallas — SavedStateHandle

// En Compose + Hilt, el resultado de una pantalla a otra
// se pasa via SavedStateHandle del ViewModel de la pantalla ANTERIOR

// En DetalleViewModel — lee el resultado cuando vuelve
@HiltViewModel
class DetalleViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle
) : ViewModel() {
    // Navigation pone los argumentos aquí automáticamente
    val productoId: Int = checkNotNull(savedStateHandle["productoId"])
}

// Para resultados de retorno (como startActivityForResult):
// La pantalla B guarda el resultado en su back stack entry
// La pantalla A lo lee cuando vuelve al foco