Cómo navegar con Compose

El componente Navigation proporciona compatibilidad con aplicaciones de Jetpack Compose. Puedes navegar entre los elementos que admiten composición y aprovechar la infraestructura y las funciones del componente Navigation.

Configuración

Para admitir Compose, usa la siguiente dependencia en el archivo build.gradle del módulo de tu app:

Groovy

dependencies {
    def nav_version = "2.5.3"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

Kotlin

dependencies {
    def nav_version = "2.5.3"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

Cómo comenzar

NavController es la API central del componente Navigation. Tiene estado y realiza un seguimiento de la pila de actividades de elementos que admiten composición que conforman las pantallas de tu app y el estado de cada pantalla.

Puedes crear un objeto NavController con el método rememberNavController() en el elemento componible:

val navController = rememberNavController()

Debes crear el NavController en el lugar de la jerarquía correspondiente que sea accesible para todos los elementos componibles y necesiten hacer referencia a él. Eso sigue los principios de la elevación de estado y te permite usar el NavController y el estado que proporciona a través de currentBackStackEntryAsState() como fuente de confianza para actualizar elementos componibles fuera de tus pantallas. Consulta Integración con la barra de navegación inferior para ver un ejemplo de esta funcionalidad.

Cómo crear un NavHost

Cada NavController debe estar asociado con un único elemento NavHost componible. El NavHost vincula el NavController con un gráfico de navegación que especifica los destinos componibles entre los cuales deberías poder navegar. A medida que navegas por los elementos componibles, el contenido de NavHost se reescribe automáticamente. Cada destino componible de tu gráfico de navegación está asociado con una ruta.

Para crear el NavHost, se requiere el objeto NavController que se creó antes a través de rememberNavController() y la ruta del destino inicial de tu gráfico. La creación de NavHost usa la sintaxis lambda del DSL de Kotlin de Navigation para construir el gráfico de navegación. Puedes agregar el elemento a tu estructura de navegación con el método composable() que requiere que proporciones una ruta y el elemento componible que debe vincularse al destino:

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

Para navegar a un destino componible en el gráfico de navegación, debes usar el método navigate. navigate toma un solo parámetro String que representa la ruta del destino. Para navegar desde un elemento componible dentro del gráfico de navegación, llama a navigate:

navController.navigate("friendslist")

De forma predeterminada, navigate agrega el nuevo destino a la pila de actividades. Puedes modificar el comportamiento de navigate si adjuntas opciones de navegación adicionales a nuestra llamada a navigate() de la siguiente manera:

// Pop everything up to the "home" destination off the back stack before
// navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home")
}

// Pop everything up to and including the "home" destination off
// the back stack before navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home") { inclusive = true }
}

// Navigate to the "search” destination only if we’re not already on
// the "search" destination, avoiding multiple copies on the top of the
// back stack
navController.navigate("search") {
    launchSingleTop = true
}

Consulta la guía de popUpTo para ver más casos de uso.

La función navigate de NavController modifica el estado interno de NavController. Para cumplir con el principio de fuente de confianza única tanto como sea posible, solo la función de componibilidad o el contenedor de estado que eleva la instancia de NavController y las funciones que toman el elemento NavController como parámetro deben realizar llamadas de navegación. Los eventos de navegación activados desde otras funciones de componibilidad en la jerarquía de la IU deben exponer esos eventos al emisor de forma adecuada mediante las funciones.

En el siguiente ejemplo, se muestra la función de componibilidad MyAppNavHost como la única fuente de confianza para la instancia NavController. ProfileScreen expone un evento como una función a la que se llama cuando el usuario presiona un botón. MyAppNavHost, que posee la navegación a las diferentes pantallas de la app, realiza la llamada de navegación al destino correcto cuando se llama a ProfileScreen.

@Composable
fun MyAppNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
    startDestination: String = "profile"
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = startDestination
    ) {
        composable("profile") {
            ProfileScreen(
                onNavigateToFriends = { navController.navigate("friendsList") },
                /*...*/
            )
        }
        composable("friendslist") { FriendsListScreen(/*...*/) }
    }
}

@Composable
fun ProfileScreen(
    onNavigateToFriends: () -> Unit,
    /*...*/
) {
    /*...*/
    Button(onClick = onNavigateToFriends) {
        Text(text = "See friends list")
    }
}

Solo debes llamar a navigate() como parte de una devolución de llamada y no como parte de tu elemento componible, para evitar llamar a navigate() en cada recomposición.

Exponer eventos de funciones de componibilidad a emisores que saben cómo controlar una lógica en particular en la app es una práctica recomendada en Compose cuando se eleva el estado.

Si bien exponer eventos como parámetros lambda individuales podría sobrecargar la firma de la función, maximiza la visibilidad de cuáles son las responsabilidades de la función de componibilidad. Puedes dar un vistazo para ver lo que hace.

Otras alternativas que podrían reducir la cantidad de parámetros en la declaración de funciones al principio podrían ser más cómodas para escribir, pero podrían ocultar algunas desventajas a largo plazo. Por ejemplo, crear una clase wrapper como ProfileScreenEvents que centralice todos los eventos en un solo lugar. De esta manera, se reduce la visibilidad de lo que hace el elemento componible cuando se analiza su definición de funciones, se agregan otra clase y métodos al recuento de proyectos, y debes crear y recordar instancias de esa clase cada vez que llamas a esa función de componibilidad. Además, para reutilizar esa clase de wrapper tanto como sea posible, este patrón incentiva el paso de una instancia de esa clase a la jerarquía de la IU, en lugar de la práctica recomendada de pasar a elementos componibles justo lo que necesitan.

Navigation Compose también es compatible con el paso de argumentos entre destinos que admiten composición. Para ello, debes agregar marcadores de posición de argumento a la ruta, de manera similar al proceso de agregar argumentos a un vínculo directo cuando usas la biblioteca de navegación base:

NavHost(startDestination = "profile/{userId}") {
    ...
    composable("profile/{userId}") {...}
}

De forma predeterminada, todos los argumentos se analizan como strings. El parámetro arguments de composable() acepta una lista de NamedNavArgument. Puedes crear un NamedNavArgument con rapidez mediante el método navArgument y, luego, especificar su type exacto:

NavHost(startDestination = "profile/{userId}") {
    ...
    composable(
        "profile/{userId}",
        arguments = listOf(navArgument("userId") { type = NavType.StringType })
    ) {...}
}

Debes extraer los argumentos de NavBackStackEntry que está disponible en la expresión lambda de la función composable().

composable("profile/{userId}") { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

Para pasar el argumento al destino, debes agregarlo a la ruta cuando realices la llamada a navigate:

navController.navigate("profile/user1234")

Para obtener una lista de tipos admitidos, consulta Cómo pasar datos entre destinos.

Cómo recuperar datos complejos durante la navegación

Se recomienda no pasar objetos de datos complejos cuando navegas, sino pasar la información mínima necesaria, como un identificador único o alguna otra forma de ID, como argumentos, cuando se realizan acciones de navegación:

// Pass only the user ID when navigating to a new destination as argument
navController.navigate("profile/user1234")

Los objetos complejos se deben almacenar como datos en una sola fuente de información, como la capa de datos. Una vez que llegues a tu destino después de navegar, podrás cargar la información requerida desde la única fuente de confianza mediante el ID pasado. Para recuperar los argumentos de tu ViewModel que son responsables de acceder a la capa de datos, puedes usar ViewModel’s SavedStateHandle:

class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["userId"])

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(userId)

// …

}

Este enfoque ayuda a evitar la pérdida de datos durante los cambios de configuración y cualquier incoherencia cuando el objeto en cuestión se actualiza o muta.

Para obtener una explicación más detallada sobre por qué deberías evitar pasar datos complejos como argumentos, así como una lista de los tipos de argumentos admitidos, consulta Cómo pasar datos entre destinos.

Cómo agregar argumentos opcionales

La composición de Navigation también admite argumentos de navegación opcionales. Los argumentos opcionales se diferencian de los obligatorios de dos maneras:

  • Se deben incluir con la sintaxis del parámetro de búsqueda ("?argName={argName}").
  • Deben tener un valor defaultValue establecido o nullability = true (que configura implícitamente el valor predeterminado en null).

Eso significa que todos los argumentos opcionales se deben agregar de forma explícita a la función composable() de una lista:

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

Ahora, incluso si no se pasa ningún argumento al destino, se usa defaultValue, "user1234".

La estructura de controlar los argumentos a través de las rutas significa que los elementos componibles siguen siendo completamente independientes de Navigation y son mucho más fáciles de probar.

Navigation Compose admite vínculos directos implícitos que también se pueden definir como parte de la función composable(). Su parámetro deepLinks acepta una lista de NavDeepLink que se pueden crear con rapidez mediante el método navDeepLink:

val uri = "https://www.example.com"

composable(
    "profile?id={id}",
    deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("id"))
}

Esos vínculos directos te permiten asociar una URL, acción o tipo de MIME específico con un elemento componible. De forma predeterminada, esos vínculos directos no se exponen a aplicaciones externas. Para que los vínculos directos estén disponibles externamente, debes agregar los elementos <intent-filter> apropiados al archivo manifest.xml de tu app. Para habilitar el vínculo directo anterior, debes agregar lo siguiente dentro del elemento <activity> del manifiesto:

<activity …>
  <intent-filter>
    ...
    <data android:scheme="https" android:host="www.example.com" />
  </intent-filter>
</activity>

La navegación incluye vínculos directos automáticamente a ese elemento componible cuando otra app activa el vínculo directo.

Esos vínculos directos también se pueden usar para compilar un PendingIntent con el vínculo apropiado de un elemento componible:

val id = "exampleId"
val context = LocalContext.current
val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "https://www.example.com/$id".toUri(),
    context,
    MyActivity::class.java
)

val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}

Luego, puedes usar este deepLinkPendingIntent como cualquier otro PendingIntent para abrir tu app en el destino del vínculo directo.

Navegación anidada

Los destinos se pueden agrupar en un gráfico anidado para modularizar un flujo en particular de la IU de tu app. Un ejemplo de esto podría ser un flujo de acceso independiente.

El gráfico anidado encapsula sus destinos. Al igual que sucede con el gráfico raíz, un gráfico anidado debe tener un destino que su ruta identifique como el destino de inicio. Este es el destino cuando navegas a la ruta asociada con el gráfico anidado.

Para agregar un gráfico anidado a tu NavHost, puedes usar la función de extensión navigation como se muestra a continuación:

NavHost(navController, startDestination = "home") {
    ...
    // Navigating to the graph via its route ('login') automatically
    // navigates to the graph's start destination - 'username'
    // therefore encapsulating the graph's internal routing logic
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
    ...
}

Te recomendamos que dividas el gráfico de navegación en varios métodos a medida que aumente su tamaño. Esto también permite que varios módulos contribuyan en sus propios gráficos de navegación.

fun NavGraphBuilder.loginGraph(navController: NavController) {
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
}

Si haces que el método sea un método de extensión en NavGraphBuilder, puedes usarlo junto con los métodos de extensión precompilados navigation, composable y dialog:

NavHost(navController, startDestination = "home") {
    ...
    loginGraph(navController)
    ...
}

Integración con la barra de navegación inferior

Si defines NavController en un nivel superior en tu jerarquía que admite composición, puedes conectar Navigation con otros componentes, como el componente de navegación inferior. Eso te permite navegar si seleccionas los íconos de la barra inferior.

Si quieres usar los componentes BottomNavigation y BottomNavigationItem, agrega la dependencia androidx.compose.material a tu aplicación para Android.

Groovy

dependencies {
    implementation "androidx.compose.material:material:1.4.3"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.4.7"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

dependencies {
    implementation("androidx.compose.material:material:1.4.3")
}

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.4.7"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Para vincular los elementos de una barra de navegación inferior a las rutas del gráfico de navegación, se recomienda definir una clase sellada, como Screen, que se muestra aquí, que contenga la ruta y el ID de recurso de strings de los destinos.

sealed class Screen(val route: String, @StringRes val resourceId: Int) {
    object Profile : Screen("profile", R.string.profile)
    object FriendsList : Screen("friendslist", R.string.friends_list)
}

Luego, coloca esos elementos en una lista que pueda usar el BottomNavigationItem:

val items = listOf(
   Screen.Profile,
   Screen.FriendsList,
)

En el elemento BottomNavigation componible, obtén el NavBackStackEntry actual con la función currentBackStackEntryAsState(). Esta entrada te da acceso al NavDestination actual. El estado seleccionado de cada BottomNavigationItem se puede determinar si comparas la ruta del elemento con la del destino actual y los destinos superiores (para controlar casos en los que se usa la navegación anidada) mediante la jerarquía NavDestination.

La ruta del elemento también se usa para conectar el valor lambda onClick a una llamada a navigate, de modo que cuando se presione en ese elemento se navegue a él. Cuando usas las marcas saveState y restoreState, el estado y la pila de actividades de ese elemento se guardan y restablecen de forma correcta a medida que alternas entre los elementos de navegación inferiores.

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      items.forEach { screen ->
        BottomNavigationItem(
          icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
          label = { Text(stringResource(screen.resourceId)) },
          selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
          onClick = {
            navController.navigate(screen.route) {
              // Pop up to the start destination of the graph to
              // avoid building up a large stack of destinations
              // on the back stack as users select items
              popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
              }
              // Avoid multiple copies of the same destination when
              // reselecting the same item
              launchSingleTop = true
              // Restore state when reselecting a previously selected item
              restoreState = true
            }
          }
        )
      }
    }
  }
) { innerPadding ->
  NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
    composable(Screen.Profile.route) { Profile(navController) }
    composable(Screen.FriendsList.route) { FriendsList(navController) }
  }
}

Aquí, aprovechas el método NavController.currentBackStackEntryAsState() para elevar el estado navController fuera de la función NavHost y compartirlo con el componente BottomNavigation. Eso significa que la BottomNavigation tiene el estado más actualizado automáticamente.

Seguridad de tipos en Navigation Compose

El código de esta página no tiene seguridad de tipos. Puedes llamar a la función navigate() con rutas inexistentes o argumentos incorrectos. Sin embargo, puedes estructurar tu código de Navigation de modo que tenga seguridad de tipos en el tiempo de ejecución. De esta manera, puedes evitar fallas y asegurarte de lo siguiente:

  • Los argumentos que proporcionas cuando navegas a un destino o gráfico de navegación son del tipo correcto y todos los argumentos necesarios están presentes.
  • Los argumentos que recuperas de SavedStateHandle son del tipo correcto.

Para obtener más información, consulta la documentación sobre seguridad de tipos en Navigation.

Interoperabilidad

Si quieres usar el componente Navigation con Compose, tienes dos opciones:

  • Define un gráfico de navegación con el componente Navigation para fragmentos.
  • Usa destinos de Compose para definir un gráfico de navegación con un NavHost en Compose. Esto será posible solo si todas las pantallas del gráfico de navegación son elementos componibles.

Por lo tanto, para apps híbridas de Compose y Views, se recomienda utilizar el componente Navigation basado en fragmentos. Los fragmentos conservarán las pantallas basadas en Views, pantallas de Compose y pantallas que usan tanto Views como Compose. Una vez que el contenido de cada fragmento esté en Compose, el siguiente paso es vincular todas esas pantallas con Navigation Compose y quitar todos los fragmentos.

A fin de cambiar los destinos dentro del código de Compose, debes exponer eventos que se puedan pasar a cualquier elemento componible de la jerarquía y que este último sea capaz de activarlos:

@Composable
fun MyScreen(onNavigate: (Int) -> ()) {
    Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}

En tu fragmento, para trazar el puente entre Compose y el componente Navigation basado en fragmentos, busca NavController y navega al destino:

override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
    }
}

De manera alternativa, puedes pasar el NavController en tu jerarquía de Compose. Sin embargo, exponer funciones simples te dará una posibilidad mucho mayor de probar y volver a usar este método.

Prueba

Te recomendamos que separes el código de Navigation de los destinos componibles para habilitar la prueba de cada elemento componible de forma independiente del elemento componible NavHost.

Esto significa que no debes pasar el elemento navController directamente a ningún elemento componible y, en su lugar, pasar las devoluciones de llamada de navegación como parámetros. De esta manera, todos tus elementos componibles se pueden probar de forma individual, ya que no requieren una instancia de navController en las pruebas.

El nivel de indirección proporcionado por la expresión lambda composable es lo que te permite separar el código de Navigation del mismo elemento componible. Eso funciona de dos formas:

  • Pasa argumentos analizados al elemento que admite composición.
  • Pasa expresiones lambdas que deban activarse con el elemento que admita composición para navegar, en lugar del NavController.

Por ejemplo, un elemento Profile componible que recibe un userId como entrada y permite a los usuarios navegar a la página de perfil de un amigo podría tener la firma de:

@Composable
fun Profile(
    userId: String,
    navigateToFriendProfile: (friendUserId: String) -> Unit
) {
 …
}

De esta manera, el elemento Profile componible funciona independientemente de Navigation, lo que permite que se pruebe de forma independiente. La expresión lambda composable encapsula la lógica mínima necesaria para cerrar la brecha entre las APIs de Navigation y el elemento componible:

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(backStackEntry.arguments?.getString("userId")) { friendUserId ->
        navController.navigate("profile?userId=$friendUserId")
    }
}

Se recomienda escribir pruebas que cubran los requisitos de navegación de la app con pruebas en NavHost, acciones de navegación que se pasan a los elementos componibles, así como a los elementos individuales de la pantalla.

Prueba el elemento NavHost

Para comenzar a probar tu NavHost, agrega la siguiente dependencia de navegación-prueba:

dependencies {
// ...
  androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
  // ...
}

Puedes configurar el sujeto de prueba NavHost y pasarle una instancia de la instancia navController. Para ello, el artefacto de prueba de Navigation proporciona un TestNavHostController. Una prueba de la IU que verifique el destino de inicio de tu app y NavHost tendría el siguiente aspecto:

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    lateinit var navController: TestNavHostController

    @Before
    fun setupAppNavHost() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }

    // Unit test
    @Test
    fun appNavHost_verifyStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Start Screen")
            .assertIsDisplayed()
    }
}

Cómo probar las acciones de navegación

Puedes probar tu implementación de navegación de varias maneras. Para ello, haz clic en los elementos de la IU y, luego, verifica el destino que se muestra o compara la ruta esperada con la ruta real.

Dado que deseas probar la implementación concreta de tu app, es preferible que hagas clic en la IU. Para aprender a probar esto junto con las funciones individuales de componibilidad por separado, asegúrate de consultar el codelab Pruebas en Jetpack Compose.

También puedes usar navController para verificar tus aserciones comparando la ruta de string actual con la esperada, mediante el currentBackStackEntry de navController:

@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
    composeTestRule.onNodeWithContentDescription("All Profiles")
        .performScrollTo()
        .performClick()

    val route = navController.currentBackStackEntry?.destination?.route
    assertEquals(route, "profiles")
}

Para obtener más información sobre los conceptos básicos de las pruebas de Compose, consulta la documentación de pruebas de Compose y el codelab sobre pruebas en Jetpack Compose. Si deseas obtener más información sobre las pruebas avanzadas del código de navegación, consulta la guía para Probar Navigation.

Más información

Para obtener más información sobre Jetpack Navigation, consulta Cómo comenzar con el componente de Navigation o el codelab de Navigation de Jetpack Compose.

Si deseas obtener información para diseñar la navegación de tu app, de modo que se adapte a diferentes tamaños de pantalla, orientaciones y factores de forma, consulta el artículo sobre Navigation para IU responsivas.

Para obtener información sobre una implementación más avanzada de Navigation Compose en una app modularizada, incluidos conceptos como gráficos anidados y la integración de la barra de navegación inferior, consulta el repositorio Now in Android.

Ejemplos