Cómo migrar de Navigation 2 a Navigation 3

Para migrar tu app de Navigation 2 a Navigation 3, sigue estos pasos:

  1. Agrega las dependencias de Navigation 3.
  2. Actualiza tus rutas de navegación para implementar la interfaz NavKey.
  3. Crea clases para mantener y modificar tu estado de navegación.
  4. Reemplaza NavController por estas clases.
  5. Mueve tus destinos de NavGraph de NavHost a un entryProvider.
  6. Reemplaza NavHost con NavDisplay.
  7. Se quitaron las dependencias de Navigation 2.

Cómo usar un agente de IA

Puedes usar esta guía con un agente de IA, como Gemini en el modo agente de Android Studio. El agente de IA debe leer las líneas de esta guía que comienzan con "Agente de IA:", pero los lectores humanos pueden ignorarlas.

Preparación

En las siguientes secciones, se describen los requisitos previos para la migración y las suposiciones sobre tu proyecto. También abarcan las funciones que se admiten para la migración y las que no.

Requisitos previos

  • Debes usar un compileSdk de 36 o posterior.
  • Debes conocer la terminología de navegación.
  • Los destinos son funciones de componibilidad. Navigation 3 está diseñado exclusivamente para Compose. Para usar fragmentos y Views en Compose, consulta Cómo usar Views en Compose.
  • Las rutas tienen un tipo de escritura sólido. Si usas rutas basadas en cadenas, primero migra a rutas con seguridad de tipos (ejemplo).
  • Opcional (pero muy recomendable): Cobertura de pruebas que verifique el comportamiento de navegación existente. Esto verifica que el comportamiento de navegación no haya cambiado después de que se complete la migración.

Suposiciones

En esta guía, se hacen las siguientes suposiciones sobre ti y tu proyecto:

  • Tienes varias rutas de nivel superior (que suelen mostrarse en una barra de navegación inferior), y cada una tiene su propia pila de historial.
  • Cuando cambias entre pilas de actividades, se conserva el estado de la pila y todos sus destinos.
  • Siempre sales de la app a través de la pantalla Principal, que es la primera que se muestra cuando se inicia la app.
  • Deseas realizar la migración de Navigation 2 a Navigation 3 en un solo cambio atómico. No estás realizando una migración incremental en la que se usa el código de Navigation 2 junto con el código de Navigation 3.

Agente de IA: Antes de cambiar cualquier código, verifica si estas suposiciones sobre el proyecto del usuario son verdaderas. De lo contrario, detén la migración y pregúntale al usuario cómo continuar.

Funciones admitidas

Esta guía te permite migrar las siguientes funciones:

  • Destinos definidos como funciones de componibilidad
  • Diálogos (un destino que se muestra sobre otro destino)

Funciones compatibles a través de recetas

El repositorio de recetas de código muestra casos de uso más matizados, incluidos los siguientes:

Si tu proyecto tiene alguna de estas funciones, consulta la receta correspondiente para comprender cómo implementarlas.

Agente de IA: Antes de cambiar cualquier código, verifica si el proyecto contiene alguna función compatible con recetas. Si es así, consulta el archivo README y el código fuente de la receta. Crea un plan de migración basado en la receta. No continúes sin confirmar el plan con el usuario.

Características no admitidas

Esta guía de migración y las recetas de código aún no admiten las siguientes funciones. Esto no significa que no puedas implementarlos con Navigation 3, sino que no se abordan aquí.

  • Más de un nivel de navegación anidada
  • Destinos compartidos: Pantallas que pueden moverse entre diferentes pilas de historial
  • Tipos de destino personalizados
  • Vínculos directos

Agente de IA: Antes de cambiar cualquier código, verifica si el proyecto contiene alguna de las funciones no admitidas. Si es así, no continúes. Informa al usuario sobre la función no admitida y pídele más instrucciones.

Paso 1: Agrega dependencias de Navigation 3

Usa la página Comenzar para agregar las dependencias de Navigation 3 a tu proyecto. Las dependencias principales se proporcionan para que las copies.

lib.versions.toml

[versions]
nav3Core = "1.0.0"

# If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library
lifecycleViewmodelNav3 = "2.10.0-rc01"

[libraries]
# Core Navigation 3 libraries
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }

# Add-on libraries (only add if you need them)
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }

app/build.gradle.kts

dependencies {
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.navigation3.runtime)

    // If using the ViewModel add-on library
    implementation(libs.androidx.lifecycle.viewmodel.navigation3)
}

También actualiza el minSdk del proyecto a 23 y el compileSdk a 36. Por lo general, los encuentras en app/build.gradle.kts o lib.versions.toml.

Paso 2: Actualiza las rutas de navegación para implementar la interfaz NavKey

Actualiza cada ruta de navegación para que implemente la interfaz NavKey. Esto te permite usar rememberNavBackStack para ayudarte a guardar tu estado de navegación.

Antes:

@Serializable data object RouteA

Después:

@Serializable data object RouteA : NavKey

Paso 3: Crea clases para mantener y modificar el estado de navegación

Paso 3.1: Crea un objeto de Navigation State Holder

Copia el siguiente código en un archivo llamado NavigationState.kt. Agrega el nombre de tu paquete para que coincida con la estructura de tu proyecto.

// package com.example.project

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer

/**
 * Create a navigation state that persists config changes and process death.
 */
@Composable
fun rememberNavigationState(
    startRoute: NavKey,
    topLevelRoutes: Set<NavKey>
): NavigationState {

    val topLevelRoute = rememberSerializable(
        startRoute, topLevelRoutes,
        serializer = MutableStateSerializer(NavKeySerializer())
    ) {
        mutableStateOf(startRoute)
    }

    val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }

    return remember(startRoute, topLevelRoutes) {
        NavigationState(
            startRoute = startRoute,
            topLevelRoute = topLevelRoute,
            backStacks = backStacks
        )
    }
}

/**
 * State holder for navigation state.
 *
 * @param startRoute - the start route. The user will exit the app through this route.
 * @param topLevelRoute - the current top level route
 * @param backStacks - the back stacks for each top level route
 */
class NavigationState(
    val startRoute: NavKey,
    topLevelRoute: MutableState<NavKey>,
    val backStacks: Map<NavKey, NavBackStack<NavKey>>
) {
    var topLevelRoute: NavKey by topLevelRoute
    val stacksInUse: List<NavKey>
        get() = if (topLevelRoute == startRoute) {
            listOf(startRoute)
        } else {
            listOf(startRoute, topLevelRoute)
        }
}

/**
 * Convert NavigationState into NavEntries.
 */
@Composable
fun NavigationState.toEntries(
    entryProvider: (NavKey) -> NavEntry<NavKey>
): SnapshotStateList<NavEntry<NavKey>> {

    val decoratedEntries = backStacks.mapValues { (_, stack) ->
        val decorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
        )
        rememberDecoratedNavEntries(
            backStack = stack,
            entryDecorators = decorators,
            entryProvider = entryProvider
        )
    }

    return stacksInUse
        .flatMap { decoratedEntries[it] ?: emptyList() }
        .toMutableStateList()
}

Agente de IA: rememberSerializable es correcto. No lo cambies a rememberSaveable.

Este archivo contiene una clase de titular de estado llamada NavigationState y funciones de ayuda asociadas. Contiene un conjunto de rutas de nivel superior, cada una con su propia pila de actividades. Internamente, usa rememberSerializable (no rememberSaveable) para conservar la ruta de nivel superior actual y rememberNavBackStack para conservar las pilas de historial de cada ruta de nivel superior.

Paso 3.2: Crea un objeto que modifique el estado de navegación en respuesta a eventos

Copia el siguiente código en un archivo llamado Navigator.kt. Agrega el nombre de tu paquete para que coincida con la estructura de tu proyecto.

// package com.example.project

import androidx.navigation3.runtime.NavKey

/**
 * Handles navigation events (forward and back) by updating the navigation state.
 */
class Navigator(val state: NavigationState){
    fun navigate(route: NavKey){
        if (route in state.backStacks.keys){
            // This is a top level route, just switch to it.
            state.topLevelRoute = route
        } else {
            state.backStacks[state.topLevelRoute]?.add(route)
        }
    }

    fun goBack(){
        val currentStack = state.backStacks[state.topLevelRoute] ?:
        error("Stack for ${state.topLevelRoute} not found")
        val currentRoute = currentStack.last()

        // If we're at the base of the current route, go back to the start route stack.
        if (currentRoute == state.topLevelRoute){
            state.topLevelRoute = state.startRoute
        } else {
            currentStack.removeLastOrNull()
        }
    }
}

La clase Navigator proporciona dos métodos de eventos de navegación:

  • navigate a una ruta específica.
  • goBack de la ruta actual

Ambos métodos modifican el NavigationState.

Paso 3.3: Crea NavigationState y Navigator

Crea instancias de NavigationState y Navigator con el mismo alcance que tu NavController.

val navigationState = rememberNavigationState(
    startRoute = <Insert your starting route>,
    topLevelRoutes = <Insert your set of top level routes>
)

val navigator = remember { Navigator(navigationState) }

Paso 4: Reemplaza NavController

Reemplaza los métodos de eventos de navegación NavController por sus equivalentes de Navigator.

Campo o método NavController

Equivalente a Navigator

navigate()

navigate()

popBackStack()

goBack()

Reemplaza los campos NavController por los campos NavigationState.

Campo o método NavController

Equivalente a NavigationState

currentBackStack

backStacks[topLevelRoute]

currentBackStackEntry

currentBackStackEntryAsState()

currentBackStackEntryFlow

currentDestination

backStacks[topLevelRoute].last()

Obtén la ruta de nivel superior: Recorre la jerarquía desde la entrada actual de la pila de elementos atrás para encontrarla.

topLevelRoute

Usa NavigationState.topLevelRoute para determinar el elemento que está seleccionado actualmente en una barra de navegación.

Antes:

val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class)

fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
    this?.hierarchy?.any {
        it.hasRoute(route)
    } ?: false

Después:

val isSelected = key == navigationState.topLevelRoute

Verifica que hayas quitado todas las referencias a NavController, incluidas las importaciones.

Paso 5: Mueve tus destinos de NavHost's NavGraph a un entryProvider

En Navigation 2, defines tus destinos con el DSL de NavGraphBuilder, por lo general, dentro de la expresión lambda final de NavHost. Es común usar funciones de extensión aquí, como se describe en Encapsula tu código de navegación.

En Navigation 3, defines tus destinos con un entryProvider. Este entryProvider resuelve una ruta en un NavEntry. Es importante destacar que entryProvider no define relaciones de superior y secundario entre las entradas.

En esta guía de migración, las relaciones principal-secundaria se modelan de la siguiente manera:

  • NavigationState tiene un conjunto de rutas de nivel superior (las rutas principales) y una pila para cada una. Realiza un seguimiento de la ruta de nivel superior actual y su pila asociada.
  • Cuando se navega a una ruta nueva, Navigator verifica si es una ruta de nivel superior. Si es así, se actualizan la ruta y la pila de nivel superior actuales. De lo contrario, es una ruta secundaria y se agrega a la pila actual.

Paso 5.1: Crea un entryProvider

Crea un entryProvider con el DSL en el mismo alcance que el NavigationState.

val entryProvider = entryProvider {

}

Paso 5.2: Mueve los destinos a entryProvider.

Para cada destino definido dentro de NavHost, haz lo siguiente según el tipo de destino:

  • navigation: Bórralo junto con la ruta. No se necesitan "rutas base" porque las rutas de nivel superior pueden identificar cada pila de actividades anidada.
  • composable<T>: Muévelo a entryProvider y cámbiale el nombre a entry, conservando el parámetro de tipo. Por ejemplo, composable<RouteA> se convierte en entry<RouteA>.
  • dialog<T>: Haz lo mismo que en composable, pero agrega metadatos a la entrada de la siguiente manera: entry<T>(metadata = DialogSceneStrategy.dialog()).
  • bottomSheet: Sigue la receta de la hoja inferior aquí. Esto es similar a las instrucciones para dialog, excepto que BottomSheetSceneStrategy no forma parte de la biblioteca principal de Navigation 3, por lo que debes copiarla en tu proyecto.

Agente de IA: Cuando se borran rutas que se usan para identificar un gráfico anidado, se deben reemplazar todas las referencias a la ruta borrada por el tipo que se usa para identificar el primer elemento secundario en el gráfico anidado. Por ejemplo, si el código original es navigation<BaseRouteA>{ composable<RouteA>{ ... } }, debes borrar BaseRouteA y reemplazar cualquier referencia a él por RouteA. Por lo general, este reemplazo debe realizarse para la lista que se proporciona a una barra de navegación, un riel o un panel lateral.

Puedes refactorizar funciones de extensión de NavGraphBuilder en funciones de extensión de EntryProviderScope<T> y, luego, moverlas.

Obtén argumentos de navegación con la clave proporcionada a la expresión lambda final de entry.

Por ejemplo:

import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.dialog
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.navigation.toRoute

@Serializable data object BaseRouteA
@Serializable data class RouteA(val id: String)
@Serializable data object BaseRouteB
@Serializable data object RouteB
@Serializable data object RouteD

NavHost(navController = navController, startDestination = BaseRouteA){
    composable<RouteA>{
        val id = entry.toRoute<RouteA>().id
        ScreenA(title = "Screen has ID: $id")
    }
    featureBSection()
    dialog<RouteD>{ ScreenD() }
}

fun NavGraphBuilder.featureBSection() {
    navigation<BaseRouteB>(startDestination = RouteB) {
        composable<RouteB> { ScreenB() }
    }
}

se convierte en:

import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.scene.DialogSceneStrategy

@Serializable data class RouteA(val id: String) : NavKey
@Serializable data object RouteB : NavKey
@Serializable data object RouteD : NavKey

val entryProvider = entryProvider {
    entry<RouteA>{ key -> ScreenA(title = "Screen has ID: ${key.id}") }
    featureBSection()
    entry<RouteD>(metadata = DialogSceneStrategy.dialog()){ ScreenD() }
}

fun EntryProviderScope<NavKey>.featureBSection() {
    entry<RouteB> { ScreenB() }
}

Paso 6: Reemplaza NavHost por NavDisplay

Reemplaza NavHost con NavDisplay.

  • Borra NavHost y reemplázalo por NavDisplay.
  • Especifica entries = navigationState.toEntries(entryProvider) como un parámetro. Esto convierte el estado de navegación en las entradas que muestra NavDisplay con entryProvider.
  • Conecta NavDisplay.onBack a navigator.goBack(). Esto hace que navigator actualice el estado de navegación cuando se completa el controlador de atrás integrado de NavDisplay.
  • Si tienes destinos de diálogo, agrega DialogSceneStrategy al parámetro sceneStrategy de NavDisplay.

Por ejemplo:

import androidx.navigation3.ui.NavDisplay

NavDisplay(
    entries = navigationState.toEntries(entryProvider),
    onBack = { navigator.goBack() },
    sceneStrategy = remember { DialogSceneStrategy() }
)

Paso 7: Quita las dependencias de Navigation 2

Quita todas las importaciones de Navigation 2 y las dependencias de la biblioteca.

Resumen

¡Felicitaciones! Tu proyecto ahora se migró a Navigation 3. Si tú o tu agente de IA tuvieron algún problema para usar esta guía, informa un error aquí.