Se você já construiu uma tela com Jetpack Compose, em algum momento precisa responder a uma pergunta inevitável: como o usuário sai daqui e vai para lá? A resposta canônica, estável e amplamente adotada em produção é o Navigation Compose — o componente do AndroidX que traz NavHost, NavController e grafos de navegação declarativos para o mundo das funções composáveis.

Neste guia completo, você vai dominar o Navigation Compose do zero ao avançado: configuração, NavHost, NavController, rotas type-safe, argumentos, bottom navigation, grafos aninhados, deep links, salvamento de estado e boas práticas de arquitetura. Se você está começando, leia antes o tutorial de Compose para iniciantes e o conteúdo sobre Kotlin para Android para fixar a base.

O que é o Navigation Compose?

O Navigation Compose é a integração oficial da biblioteca AndroidX Navigation com o Jetpack Compose. Enquanto a navegação clássica nasceu no mundo de Activities e Fragments (NavigationUI, NavHostFragment, gráficos em XML), esta versão substitui o XML por composables e expõe o NavController como algo que você segura no nível do seu app.

Os pilares são três:

  1. NavController — o objeto central que conhece a back stack, o destino atual e expõe navigate() e popBackStack().
  2. NavHost — um composable que desenha o destino correspondente ao estado atual do NavController. É nele que você registra todas as telas.
  3. Destination / Route — a definição de cada tela, que pode receber argumentos tipados e ser protegida por lógica.

A grande vantagem sobre soluções caseiras (listas de estado com when) é que o Navigation Compose resolve, de forma testada, os problemas difíceis que aparecem em todo app real: back stack, state saving, deep links, transições, resultado entre telas e integração com o botão de voltar do sistema.

Dependências e configuração

No seu build.gradle.kts (módulo :app), adicione o artefato navigation-compose:

dependencies {
    val navVersion = "2.8.5"
    implementation("androidx.navigation:navigation-compose:$navVersion")
}

Se você gerencia versões em catálogo — o que recomendamos, veja o guia de Version Catalog — basta registrar a lib no libs.versions.toml:

[versions]
navigationCompose = "2.8.5"

[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
implementation(libs.androidx.navigation.compose)

A regra de ouro do Navigation Compose é uma única instância de NavController por app, geralmente retida no nível mais alto da sua UI. Em Compose, você a cria com rememberNavController():

@Composable
fun KotlinBrasilApp() {
    val navController = rememberNavController()
    KotlinBrasilTheme {
        Scaffold { innerPadding ->
            KotlinNavHost(
                navController = navController,
                modifier = Modifier.padding(innerPadding),
            )
        }
    }
}

Por que segurar o NavController lá em cima? Porque a barra inferior, a top bar e o NavHost precisam enxergar o mesmo controlador para reagir ao destino atual (por exemplo, esconder a bottom bar na tela de login). Em apps que usam injeção de dependência, o guia de Hilt com Compose mostra como fornecer ViewModels que conversam com a navegação sem acoplar UI a regras de negócio.

O NavHost recebe o NavController, o destino inicial e um bloco composable { } para cada tela:

@Composable
fun KotlinNavHost(
    navController: NavController,
    modifier: Modifier = Modifier,
) {
    NavHost(
        navController = navController,
        startDestination = "home",
        modifier = modifier,
    ) {
        composable("home") {
            HomeScreen(
                onOpenArticle = { id -> navController.navigate("article/$id") },
            )
        }
        composable(
            route = "article/{articleId}",
            arguments = listOf(navArgument("articleId") { type = NavType.StringType }),
        ) { backStackEntry ->
            val articleId = backStackEntry.arguments?.getString("articleId").orEmpty()
            ArticleScreen(articleId = articleId)
        }
    }
}

Aqui já aparecem três conceitos chave: a rota ("home", "article/{articleId}"), o placeholder de argumento ({articleId}) e o navArgument que define o tipo esperado. O backStackEntry entregue ao composable de destino carrega os argumentos já parseados.

Rotas type-safe com objetos serializáveis

A partir do AndroidX Navigation 2.8.0, você pode abandonar as strings mágicas e usar rotas serializáveis, que dão segurança de tipos em tempo de compilação. Defina cada destino como um object ou data class marcado com @Serializable (conceito explicado em detalhe no nosso glossário de Serialization e no artigo de serialização avançada e polimorfismo):

import kotlinx.serialization.Serializable

@Serializable object Home
@Serializable data class Article(val articleId: String)
@Serializable data class Search(val query: String = "")

@Composable
fun KotlinNavHost(navController: NavController) {
    NavHost(navController, startDestination = Home) {
        composable<Home> {
            HomeScreen(onOpenArticle = { id ->
                navController.navigate(Article(id))
            })
        }
        composable<Article> { backStackEntry ->
            val article: Article = backStackEntry.toRoute()
            ArticleScreen(articleId = article.articleId)
        }
        composable<Search> { backStackEntry ->
            val search: Search = backStackEntry.toRoute()
            SearchScreen(initialQuery = search.query)
        }
    }
}

As vantagens são diretas: o compilador reclama se você passar o tipo errado, não há concatenação de string para montar URLs e os argumentos opcionais viram parâmetros com valor padrão. Sempre que possível, prefira esta abordagem em vez de rotas como string.

Argumentos opcionais e valores padrão

Argumentos opcionais precisam de defaultValue e de nullable = true. Com rotas type-safe, isso desaparece — basta usar val query: String = "". Se você ainda está em strings, o equivalente é:

composable(
    route = "search?q={query}",
    arguments = listOf(navArgument("query") {
        type = NavType.StringType
        defaultValue = ""
        nullable = true
    }),
)

Bottom navigation integrada

Um padrão clássico é casar NavigationBar (Material 3) com o NavHost. O truque é manter a back stack do destino raiz de cada aba para preservar estado ao trocar de tab:

@Composable
fun MainScreen(navController: NavController) {
    val items = listOf(Tab.Home, Tab.Search, Tab.Profile)
    Scaffold(
        bottomBar = {
            val currentBackStack by navController.currentBackStackEntryAsState()
            val currentDestination = currentBackStack?.destination
            NavigationBar {
                items.forEach { tab ->
                    NavigationBarItem(
                        selected = currentDestination?.hierarchy?.any { it.route == tab.route } == true,
                        onClick = {
                            navController.navigate(tab.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = { Icon(tab.icon, contentDescription = tab.label) },
                        label = { Text(tab.label) },
                    )
                }
            }
        },
    ) { innerPadding ->
        KotlinNavHost(navController, Modifier.padding(innerPadding))
    }
}

Os detalhes que fazem a diferença são: popUpTo com saveState = true, launchSingleTop = true e restoreState = true. Sem eles, toda vez que o usuário volta a uma aba você recria a tela do zero e perde a posição de rolagem. O design da barra segue as recomendações de Material 3 Expressive com Compose e os layouts de Compose.

Grafos aninhados

Para fluxos fechados (checkout, onboarding, wizard), use navigation { } dentro do NavHost para criar um subgrafo com sua própria rota de entrada:

navigation(startDestination = Checkout.Cart, route = "checkout") {
    composable<Checkout.Cart> { CartScreen(onNext = { navController.navigate(Checkout.Address) }) }
    composable<Checkout.Address> { AddressScreen(onNext = { navController.navigate(Checkout.Payment) }) }
    composable<Checkout.Payment> { PaymentScreen(onDone = { navController.popBackStack() }) }
}

Assim você navega para "checkout" e o usuário fica preso ao fluxo até terminar — o botão de voltar do sistema respeita a back stack interna.

O NavHost aceita deepLinks por destino, conectando rotas internas a URLs reais (o que casca bem com o guia de App Links e deep links com Kotlin):

composable<Article>(
    deepLinks = listOf(navDeepLink { uriPattern = "https://kotlin.dev.br/artigos/{articleId}" }),
) { entry ->
    val article: Article = entry.toRoute()
    ArticleScreen(article.articleId)
}

Depois registre o intent-filter correspondente no AndroidManifest.xml para que links externos (notificações, e-mails, busca) abram direto na tela certa.

Resultado entre telas

Para devolver um valor (selecionar um item, criar algo), use savedStateHandle:

// Tela que pede um resultado
val result = navController.currentBackStackEntry
    ?.savedStateHandle
    ?.getStateFlow<String>("selectedCategory", "")
    ?.collectAsState()

navController.navigate("categoryPicker")

// Tela que devolve o resultado
navController.previousBackStackEntry?.savedStateHandle?.set("selectedCategory", "coroutines")
navController.popBackStack()

Como o savedStateHandle sobrevive a mudanças de configuração, o resultado chega de forma confiável. Para testar esses fluxos reativos, o guia de testes com Turbine e StateFlow é leitura obrigatória.

Estado, back stack e boas práticas

Algumas regras que evitam bugs difíceis em produção:

  • Nunca passe o NavController para dentro de ViewModels ou repositórios. A navegação é uma preocupação de UI.
  • Centralize as rotas em um único lugar (objetos @Serializable ou constantes) para evitar strings duplicadas.
  • Use popUpTo com cuidado: esquecer o saveState é a causa nº 1 de telas que “resetam” ao trocar de aba.
  • Para telas que devem sobreviver a morte do processo, confie no savedStateHandle e persista dados críticos em DataStore, não em remember.
  • Modele estados de UI com sealed classes para que cada destino saiba exatamente quais eventos de navegação pode emitir.

Quando considerar Navigation 3

O Navigation Compose descrito aqui é a solução estável e é o que a maioria dos apps em produção usa hoje. Existe uma versão mais nova, o Navigation 3, que trata a navegação como estado puro com back stack tipada e integração ainda mais idiomática com Compose. Vale acompanhar — já cobrimos o guia do Navigation 3 com Compose — mas, para novos projetos em 2026 que precisam de estabilidade e material de referência maduro, o Navigation Compose clássico continua sendo a escolha segura.

Conclusão

O Navigation Compose é a fundação de navegação do Android moderno. Dominar NavController, NavHost, rotas type-safe, bottom navigation, grafos aninhados, deep links e savedStateHandle te coloca em posição de arquitetar qualquer fluxo — do app mais simples ao e-commerce com checkout multistep. Comece com rotas serializáveis desde o primeiro dia, mantenha o NavController isolado na UI e deixe que padrões como saveState/restoreState façam o trabalho pesado de preservar a experiência do usuário.

Se quiser ir além, conecte navegação a boas práticas de injeção de dependência com Hilt, layouts de Compose e persistência com DataStore para construir apps Android robustos e mantíveis com Kotlin.