Navigation de Jetpack Compose

1. Introducción

Última actualización: 25/07/2022

Requisitos

Navigation es una biblioteca de Jetpack que permite navegar de un destino a otro dentro de tu app. La biblioteca de Navigation también proporciona un artefacto específico para permitir la navegación idiomática y coherente con Jetpack Compose. Este artefacto (navigation-compose) es el punto central de este codelab.

Actividades

Usarás el estudio de Material de Rally como base para este codelab a fin de implementar el componente de Navigation de Jetpack y habilitar la navegación entre pantallas de Rally que admiten composición.

Qué aprenderás

  • Aspectos básicos sobre el uso de Navigation de Jetpack con Jetpack Compose
  • Cómo navegar entre funciones de componibilidad
  • Cómo integrar una barra de pestañas personalizada que admite composición en tu jerarquía de navegación
  • Cómo navegar con argumentos
  • Cómo navegar con vínculos directos
  • Cómo probar Navigation

2. Configuración

Para continuar, clona el punto de partida (rama main) del codelab.

$ git clone https://github.com/googlecodelabs/android-compose-codelabs.git

Como alternativa, puedes descargar dos archivos ZIP:

Ahora que descargaste el código, abre la carpeta del proyecto NavigationCodelab en Android Studio. Ya estás listo para comenzar.

3. Descripción general de la app de Rally

Como primer paso, debes familiarizarte con la app de Rally y su base de código. Ejecuta la app y explórala.

Rally tiene tres pantallas principales como funciones de componibilidad:

  1. OverviewScreen: Descripción general de todas las alertas y transacciones financieras
  2. AccountsScreen: Estadísticas sobre las cuentas existentes
  3. BillsScreen: Gastos programados

Captura de pantalla de la pantalla Overview que contiene información sobre Alerts, Accounts y Bills. Captura de pantalla de la pantalla Accounts, que contiene información sobre varias cuentas. Captura de pantalla de la pantalla Bills, en la que se incluye información sobre varias facturas salientes.

En la parte superior de la pantalla, Rally usa una barra de pestañas personalizada que admite composición (RallyTabRow) para navegar entre estas tres pantallas. Si presionas cada ícono, deberías poder expandir la selección actual y acceder a la pantalla correspondiente:

336ba66858ae3728.png e26281a555c5820d.png

Cuando navegas a estas pantallas que admiten composición, también puedes considerarlas como destinos de navegación, ya que queremos llegar a cada una en un punto específico. Estos destinos están predefinidos en el archivo RallyDestinations.kt.

En él, encontrarás los tres destinos principales definidos como objetos (Overview, Accounts y Bills), así como un SingleAccount, que se agregará a la app más adelante. Cada objeto se extiende desde la interfaz de RallyDestination y contiene la información necesaria en cada destino para fines de navegación:

  1. Un icon para la barra superior
  2. Una string route, necesaria para la navegación de Compose como una ruta que conduce a ese destino
  3. Una screen, que representa todo el elemento que admite composición para este destino

Cuando ejecutes la app, notarás que puedes navegar entre los destinos que actualmente usan la barra superior. Sin embargo, la app en realidad no usa la navegación de Compose, sino que su mecanismo de navegación actual se basa en un cambio manual de elementos que admiten composición y activa la recomposición a fin de mostrar el contenido nuevo. Por lo tanto, el objetivo de este codelab es migrar e implementar correctamente la navegación de Compose.

4. Cómo migrar a la navegación de Compose

Para una migración básica a Jetpack Compose, haz lo siguiente:

  1. Agrega la dependencia Navigation de Compose más reciente.
  2. Configura el componente NavController.
  3. Agrega un elemento NavHost y crea el gráfico de navegación.
  4. Prepara rutas a los efectos de navegar entre diferentes destinos de la app.
  5. Reemplaza el mecanismo de navegación actual con la navegación de Compose.

Analicemos estos pasos uno por uno con más detalle.

Cómo agregar la dependencia Navigation

Abre el archivo de compilación de la app, que se encuentra en app/build.gradle. En la sección de dependencias, agrega la dependencia navigation-compose.

dependencies {
  implementation "androidx.navigation:navigation-compose:{latest_version}"
  // ...
}

Puedes encontrar la versión más reciente de navigation-compose aquí.

Ahora, sincroniza el proyecto, y estará todo listo para comenzar a usar Navigation en Compose.

Cómo configurar el componente NavController

NavController es el componente central cuando se usa Navigation en Compose. Este permite realizar un seguimiento de las entradas que admiten composición de la pila de actividades, adelantar la pila, habilitar la manipulación de la pila de actividades y navegar entre los estados de destino. Como NavController es fundamental para la navegación, crearlo debe ser el primer paso en la configuración de la navegación de Compose.

Se obtiene un NavController cuando se llama a la función rememberNavController(). Esto crea y recuerda un NavController que sobrevive a los cambios de configuración (con rememberSaveable).

Siempre debes crear y colocar el elemento NavController en el nivel superior de tu jerarquía de elementos componibles, por lo general, dentro de tu elemento componible App. Luego, todas las funciones de componibilidad que hacen referencia a NavController tendrán acceso a él. Esto sigue los principios de la elevación de estado y garantiza que NavController sea la fuente de confianza principal para navegar entre pantallas que admiten composición y mantener la pila de actividades.

Abre RallyActivity.kt. Obtén el NavController con rememberNavController() dentro de RallyApp, ya que es el elemento raíz que admite composición y es el punto de entrada para toda la aplicación:

import androidx.navigation.compose.rememberNavController
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            // ...
        ) {
            // ...
       }
}

Rutas en la navegación de Compose

Como se mencionó anteriormente, la app de Rally tiene tres destinos principales y uno adicional que agregaremos más adelante (SingleAccount). Estos se definen en RallyDestinations.kt. También mencionamos que cada destino tiene definidos un icon, una route y una screen:

Captura de pantalla de la pantalla Overview que contiene información sobre Alerts, Accounts y Bills. Captura de pantalla de la pantalla Accounts, que contiene información sobre varias cuentas. Captura de pantalla de la pantalla Bills, en la que se incluye información sobre varias facturas salientes.

El siguiente paso es agregar estos destinos a tu gráfico de navegación, con Overview como el destino de inicio cuando se inicia la app.

Cuando usas Navigation dentro de Compose, cada destino componible en tu gráfico de navegación está asociado con una ruta. Las rutas se representan como strings que definen el acceso al elemento componible y guían tu navController para que llegue al lugar correcto. Puedes considerarla como un vínculo directo implícito que dirige a un destino específico. Cada destino debe tener una ruta única.

A fin de lograr esto, usaremos la propiedad route de cada objeto RallyDestination. Por ejemplo, Overview.route es la ruta que te llevará a la función de componibilidad de la pantalla Overview.

Cómo llamar al elemento NavHost componible con el gráfico de navegación

El siguiente paso es agregar un NavHost y crear tu gráfico de navegación.

Las 3 partes principales de Navigation son NavController, NavGraph y NavHost. El componente NavController siempre está asociado con un único elemento NavHost componible. NavHost actúa como un contenedor y es responsable de mostrar el destino actual del gráfico. A medida que navegas por las funciones de componibilidad, el contenido del NavHost se vuelve a componer automáticamente. También se vincula el NavController con un gráfico de navegación (NavGraph) que asigna los destinos de las funciones de componibilidad entre los que se navegará. Básicamente, es una colección de destinos recuperables.

Vuelve al elemento RallyApp componible en RallyActivity.kt. Reemplaza el elemento Box componible dentro de Scaffold, que incluye el contenido de la pantalla actual para el cambio manual de pantallas, por un NavHost nuevo que puedas crear siguiendo el ejemplo de código que aparece a continuación.

Pasa el navController que creamos en el paso anterior a fin de conectarlo a este NavHost. Como se mencionó anteriormente, cada NavController debe estar asociado con un solo NavHost.

NavHost también necesita una ruta startDestination para saber el destino que debe mostrar cuando se inicia la app, así que establece esto en Overview.route. Además, pasa un Modifier para aceptar el padding externo de Scaffold y aplicarlo al NavHost.

El parámetro final builder: NavGraphBuilder.() -> Unit es responsable de definir y compilar el gráfico de navegación. Utiliza la sintaxis lambda del DSL de Kotlin de Navigation, de modo que puede pasarse como una lambda final dentro del cuerpo de la función y extraerse de los paréntesis:

import androidx.navigation.compose.NavHost
...

Scaffold(...) { innerPadding ->
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = Modifier.padding(innerPadding)
    ) {
       // builder parameter will be defined here as the graph
    }
}

Cómo agregar destinos a NavGraph

Ahora, puedes definir tu gráfico de navegación y los destinos a los que puede navegar NavController. Como se mencionó antes, el parámetro builder espera una función, por lo que la navegación de Compose proporciona la función de extensión NavGraphBuilder.composable para agregar con facilidad destinos individuales que admiten composición al gráfico de navegación y definir la información de navegación necesaria.

El primer destino será Overview, por lo que debes agregarlo a través de la función de extensión composable y establecer su string route única. Esto simplemente agrega el destino a tu gráfico de navegación, por lo que también debes definir la IU real que se mostrará cuando navegues a este destino. Esto también se realizará con una lambda al final del cuerpo de la función composable, un patrón que se usa con frecuencia en Compose:

import androidx.navigation.compose.composable
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
}

A continuación de este patrón, agregaremos como tres destinos los tres elementos de la pantalla principal que admiten composición:

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    composable(route = Accounts.route) {
        Accounts.screen()
    }
    composable(route = Bills.route) {
        Bills.screen()
    }
}

Ahora, ejecuta la app. Verás la Overview como el destino de inicio y su IU correspondiente.

Más arriba mencionamos una barra de pestañas superior personalizada, un elemento RallyTabRow componible, que antes manejaba la navegación manual entre las pantallas. En este punto, todavía no está conectado con la nueva navegación, por lo que puedes verificar que hacer clic en las pestañas no cambiará el destino del elemento componible de pantalla que se muestra. A continuación, mostraremos la solución.

5. Cómo integrar RallyTabRow con la navegación

En este paso, conectarás el elemento RallyTabRow con el navController y el gráfico de navegación a fin de permitir que navegue a los destinos correctos.

Para ello, debes usar tu nuevo navController a los efectos de definir la acción de navegación correcta que corresponde a la devolución de llamada onTabSelected de RallyTabRow. Esta devolución de llamada define lo que debe ocurrir cuando se selecciona un ícono de pestaña específico y realiza la acción de navegación con navController.navigate(route).

Sigue esta guía en RallyActivity para encontrar el elemento RallyTabRow que admite composición y su parámetro de devolución de llamada onTabSelected.

Como queremos que la pestaña navegue a un destino específico cuando se la presione, también debes saber qué ícono de pestaña exacto se seleccionó. Por suerte, el parámetro onTabSelected: (RallyDestination) -> Unit ya ofrece esto. Usarás esa información y la ruta RallyDestination a fin de guiar tu navController y llamar a navController.navigate(newScreen.route) cuando se seleccione una pestaña:

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    // Pass the callback like this,
                    // defining the navigation action when a tab is selected:
                    onTabSelected = { newScreen ->
                        navController.navigate(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

Si ejecutas la app ahora, puedes verificar que, al presionar pestañas individuales en RallyTabRow, efectivamente se navega al destino correcto que admite composición. Sin embargo, es posible que hayas notado dos problemas:

  1. Cuando se presiona la misma pestaña en una fila, se inician varias copias del mismo destino.
  2. La IU de la pestaña no coincide con el destino correcto que se muestra, lo que significa que la expansión y contracción de las pestañas seleccionadas no funciona según lo previsto:

336ba66858ae3728.png e26281a555c5820d.png

Corrijamos ambas cosas.

Cómo lanzar una sola copia de un destino

Para solucionar el primer problema y asegurarte de que haya, como máximo, una copia de un destino determinado en la parte superior de la pila de actividades, la API de Compose Navigation proporciona una marca launchSingleTop que puedes pasar a tu navController.navigate(). Por ejemplo:

navController.navigate(route) { launchSingleTop = true }

Como deseas este comportamiento en la app, para cada destino, en lugar de copiar esta marca y pegarla en todas las llamadas .navigate(...), puedes extraerla en una extensión auxiliar en la parte inferior de tu RallyActivity:

import androidx.navigation.NavHostController
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

Ahora puedes reemplazar la llamada a navController.navigate(newScreen.route) por .navigateSingleTopTo(...). Vuelve a ejecutar la app y verifica que solo recibas una copia de un único destino cuando hagas clic varias veces en su ícono en la barra superior:

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    onTabSelected = { newScreen ->
                        navController
                            .navigateSingleTopTo(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

Cómo controlar las opciones de navegación y estado de la pila de actividades

Además de launchSingleTop, también hay otras marcas que puedes usar en NavOptionsBuilder para controlar y personalizar aún más tu comportamiento de navegación. Dado que nuestro RallyTabRow actúa de manera similar a BottomNavigation, también debes pensar si deseas guardar y restablecer un estado de destino cuando navegas hacia y desde él. Por ejemplo, si te desplazas hasta la parte inferior de Overview, navega a Accounts y regresa, ¿deseas mantener la posición de desplazamiento? ¿Quieres volver a presionar el mismo destino de RallyTabRow para volver a cargar el estado de la pantalla o no? Todas son preguntas válidas, y los requisitos del diseño de tu app deben determinar las respuestas.

Explicaremos algunas opciones adicionales que puedes usar dentro de la misma función de extensión navigateSingleTopTo:

  • launchSingleTop = true: Como se mencionó, esto garantiza que haya, como máximo, una copia de un destino determinado en la parte superior de la pila de actividades.
  • En la app de Rally, esto significa que, si presionas la misma pestaña varias veces, no se inician varias copias del mismo destino.
  • popUpTo(startDestination) { saveState = true }: Aparece en el destino de inicio del gráfico para evitar crear una gran pila de destinos en la pila de actividades a medida que seleccionas las pestañas.
  • En Rally, esto significa que, cuando se presiona la flecha hacia atrás desde cualquier destino, se lleva toda la pila de actividades a Overview.
  • restoreState = true: Determina si esta acción de navegación debe restablecer cualquier estado que haya guardado antes PopUpToBuilder.saveState o el atributo popUpToSaveState. Ten en cuenta que, si anteriormente no se guardó ningún estado con el ID de destino al que se navega, esto no tiene efecto.
  • En Rally, esto significa que, cuando se vuelve a presionar la misma pestaña, se conservan los datos y el estado del usuario anterior en la pantalla sin volver a cargarlos.

Puedes agregar todas estas opciones una por una al código, ejecutar la app después de cada una y verificar el comportamiento exacto luego de agregar cada marca. De esta manera, podrás ver en la práctica el modo en que cada marca cambia el estado de la navegación y de la pila de actividades:

import androidx.navigation.NavHostController
import androidx.navigation.NavGraph.Companion.findStartDestination
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) {
        popUpTo(
            this@navigateSingleTopTo.graph.findStartDestination().id
        ) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
}

Cómo corregir la IU de las pestañas

Al comienzo del codelab, cuando se usaba el mecanismo de navegación manual, RallyTabRow usaba la variable currentScreen para determinar si expandir o contraer cada pestaña.

Sin embargo, después de los cambios que hiciste, currentScreen ya no se actualizará. Es por eso que ya no funciona expandir ni contraer las pestañas seleccionadas dentro de RallyTabRow.

Si deseas volver a habilitar este comportamiento con la navegación de Compose, debes saber en cada punto cuál es el destino actual que se muestra o, en términos de navegación, cuál es la parte superior de tu entrada de pila de actividades actual y, luego, actualizar tu RallyTabRow cada vez que esto cambie.

Para recibir actualizaciones en tiempo real sobre tu destino actual desde la pila de actividades con forma de State, puedes usar navController.currentBackStackEntryAsState() y luego tomar su destination: actual.

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        // Fetch your currentDestination:
        val currentDestination = currentBackStack?.destination
        // ...
    }
}

currentBackStack?.destination muestra NavDestination. Para actualizar currentScreen de forma correcta, debes buscar una forma de hacer coincidir el NavDestination que se muestra con uno de los tres elementos de la pantalla principal que admiten composición. Debes determinar cuál se muestra en este momento a fin de poder pasar esta información al elemento RallyTabRow.. Como se mencionó antes, cada destino tiene una ruta única, de modo que podemos usar esta ruta de string como ID para hacer una comparación verificada y encontrar una coincidencia única.

Si deseas actualizar currentScreen, debes iterar a través de la lista rallyTabRowScreens a fin de encontrar una ruta coincidente y, luego, mostrar el RallyDestination correspondiente. Kotlin proporciona una función .find() útil para eso:

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        val currentDestination = currentBackStack?.destination

        // Change the variable to this and use Overview as a backup screen if this returns null
        val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: Overview
        // ...
    }
}

Como currentScreen ya se pasa a RallyTabRow, puedes ejecutar la app y verificar que la IU de la barra de pestañas se esté actualizando según corresponda.

6. Cómo extraer de RallyDestinations elementos de pantalla componibles

Hasta ahora, por cuestiones de simplicidad, usábamos la propiedad screen de la interfaz RallyDestination y los objetos de pantalla que se extendían desde allí para agregar la IU que admite composición en NavHost (RallyActivity.kt):

import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    // ...
}

Sin embargo, los siguientes pasos de este codelab (como los eventos de clic) requieren que se pase información adicional a las pantallas que admiten composición de forma directa. En un entorno de producción, seguramente se necesitarán más datos.

La forma correcta y más limpia de lograr esto sería agregar los elementos que admiten composición directamente en el gráfico de navegación NavHost y extraerlos de RallyDestination. Después de eso, RallyDestination y los objetos de pantalla solo contendrán información específica de la navegación, como icon y route, y se desvincularán de cualquier elemento relacionado con la IU de Compose.

Abre RallyDestinations.kt. Extrae el elemento componible de cada pantalla del parámetro screen de objetos RallyDestination y en las funciones composable correspondientes de tu NavHost, y reemplaza la llamada .screen() anterior de esta forma:

import com.example.compose.rally.ui.accounts.AccountsScreen
import com.example.compose.rally.ui.bills.BillsScreen
import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        OverviewScreen()
    }
    composable(route = Accounts.route) {
        AccountsScreen()
    }
    composable(route = Bills.route) {
        BillsScreen()
    }
}

En este punto, puedes quitar de forma segura el parámetro screen de RallyDestination y sus objetos:

interface RallyDestination {
    val icon: ImageVector
    val route: String
}

/**
 * Rally app navigation destinations
 */
object Overview : RallyDestination {
    override val icon = Icons.Filled.PieChart
    override val route = "overview"
}
// ...

Vuelve a ejecutar la app y verifica que todo funcione como antes. Ahora que completaste este paso, podrás configurar eventos de clic en tus pantallas que admiten composición.

Cómo habilitar clics en OverviewScreen

Actualmente, se ignoran los eventos de clic en tu OverviewScreen. Esto significa que los usuarios pueden hacer clic en los botones "SEE ALL" de las subsecciones Accounts y Bills, pero no los llevarán a ningún lado. El objetivo de este paso es habilitar la navegación para estos eventos de clic.

Grabación de pantalla de la pantalla Overview, desplazamiento a destinos de clic futuros e intento de clic. Los clics no funcionan porque todavía no se implementaron.

El elemento OverviewScreen que admite composición puede aceptar varias funciones como devoluciones de llamada para establecerlas como eventos de clic, que, en este caso, deben ser acciones de navegación que te dirijan a AccountsScreen o BillsScreen. Pasemos estas devoluciones de llamada de navegación a onClickSeeAllAccounts y onClickSeeAllBills a fin de navegar a destinos relevantes.

Abre RallyActivity.kt, busca OverviewScreen en NavHost y pasa navController.navigateSingleTopTo(...) a ambas devoluciones de llamada de navegación con las rutas correspondientes:

OverviewScreen(
    onClickSeeAllAccounts = {
        navController.navigateSingleTopTo(Accounts.route)
    },
    onClickSeeAllBills = {
        navController.navigateSingleTopTo(Bills.route)
    }
)

Ahora navController tendrá suficiente información, como la ruta del destino exacto, para navegar al destino correcto con un clic en el botón. Si observas la implementación de OverviewScreen, verás que estas devoluciones de llamada ya se establecieron en los parámetros onClick correspondientes:

@Composable
fun OverviewScreen(...) {
    // ...
    AccountsCard(
        onClickSeeAll = onClickSeeAllAccounts,
        onAccountClick = onAccountClick
    )
    // ...
    BillsCard(
        onClickSeeAll = onClickSeeAllBills
    )
}

Como se mencionó antes, mantener el elemento navController en el nivel superior de tu jerarquía de navegación y elevarlo al nivel del elemento App que admite composición (en lugar de pasarlo directamente, por ejemplo, a OverviewScreen) facilita la vista previa, la reutilización y la prueba del elemento OverviewScreen que admite composición de forma aislada, sin tener que depender de instancias navController reales o simuladas. Pasar devoluciones de llamada también permite hacer cambios rápidos en tus eventos de clic.

7. Cómo navegar a SingleAccountScreen con argumentos

Agreguemos algunas funciones nuevas a nuestras pantallas Accounts y Overview. Actualmente, estas pantallas muestran una lista de varios tipos de cuentas diferentes: "Checking" (cuenta corriente), "Home Savings" (ahorros del hogar), etcétera.

2f335ceab09e449a.png 2e78a5e090e3fccb.png

Sin embargo, hacer clic en estos tipos de cuenta no realiza ninguna acción (todavía). Solucionemos esto. Cuando presionamos cada tipo de cuenta, queremos mostrar una pantalla nueva con los detalles completos de la cuenta. Para ello, debemos proporcionar información adicional a nuestro navController sobre el tipo de cuenta exacto en el que estamos haciendo clic. Esto se puede hacer con argumentos.

Los argumentos son una herramienta muy potente que hace que el enrutamiento de navegación sea dinámico, ya que pasa uno o más argumentos a una ruta. Además, permite mostrar información diferente según los distintos argumentos brindados.

En RallyApp, agrega una nueva SingleAccountScreen de destino, que se encargará de mostrar estas cuentas individuales en el gráfico. Para ello, agrega una nueva función composable a la NavHost: existente.

import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    ...
    composable(route = SingleAccount.route) {
        SingleAccountScreen()
    }
}

Cómo configurar el destino final de SingleAccountScreen

Cuando llegues a SingleAccountScreen, este destino requerirá información adicional a los efectos de saber el tipo de cuenta que debe mostrar cuando se abra. Podemos usar argumentos para pasar este tipo de información. Debes especificar que su ruta también requiera un argumento {account_type}. Si observas el RallyDestination y su objeto SingleAccount, notarás que este argumento ya está definido para que lo uses, como una string accountTypeArg.

Si deseas pasar el argumento a tu ruta cuando navegas, debes adjuntarlos y seguir un patrón: "route/{argument}". En tu caso, tendría el siguiente aspecto: "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}". Recuerda que el signo $ se usa para escapar variables:

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) {
    SingleAccountScreen()
}

Esto garantizará que, cuando se active una acción para navegar a SingleAccountScreen, también se deba pasar un argumento accountTypeArg. De lo contrario, la navegación no se realizará correctamente. Considéralo como una firma o un contrato que deben seguir otros destinos que quieren navegar a SingleAccountScreen.

El segundo paso es hacer que composable reconozca que debe aceptar argumentos. Para ello, define su parámetro arguments. Puedes definir tantos argumentos como necesites, ya que la función composable acepta una lista de argumentos de forma predeterminada. En tu caso, solo debes agregar uno solo, llamado accountTypeArg, y agregar un poco de seguridad adicional especificando su tipo como String. Si no estableces un tipo de forma explícita, se inferirá del valor predeterminado de este argumento:

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments = listOf(
        navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
    )
) {
    SingleAccountScreen()
}

Esto funcionaría a la perfección y podrías conservar el código de esta forma. Sin embargo, como toda la información específica de destino se encuentra en RallyDestinations.kt y sus objetos, sigamos usando el mismo enfoque (como lo hicimos antes para Overview, Accounts, y Bills) y movamos esta lista de argumentos a SingleAccount:

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

Ahora, reemplaza los argumentos anteriores por SingleAccount.arguments en el composable correspondiente de NavHost. Esto también garantiza que NavHost sea lo más claro y legible posible:

composable(
    route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments =  SingleAccount.arguments
) {
    SingleAccountScreen()
}

Ahora que definiste la ruta completa con argumentos para SingleAccountScreen, el siguiente paso es asegurarte de que este accountTypeArg se pase al elemento SingleAccountScreen que admite composición, de modo que sepa qué tipo de cuenta mostrar correctamente. Si observas la implementación de la SingleAccountScreen, verás que ya está configurada y lista para aceptar un parámetro accountType:

fun SingleAccountScreen(
    accountType: String? = UserData.accounts.first().name
) {
   // ...
}

En resumen, hasta el momento, hiciste lo siguiente:

  • Te aseguraste de definir la ruta para solicitar argumentos, como una señal para sus destinos anteriores.
  • Te aseguraste de que el elemento composable sepa que debe aceptar argumentos.

El paso final es recuperar el valor del argumento que se pasó de alguna manera.

En la navegación de Compose, cada función de componibilidad NavHost tiene acceso a la NavBackStackEntry actual, una clase que contiene la información de la ruta actual y los argumentos que se pasaron de una entrada en la pila de actividades. Puedes usar esto a fin de obtener la lista de arguments obligatoria de navBackStackEntry y, luego, buscar y recuperar el argumento exacto que necesitas para pasarlo a la pantalla que admite composición.

En este caso, solicitarás accountTypeArg a navBackStackEntry. Luego, debes pasarlo al parámetro accountType de SingleAccountScreen'.

También puedes brindar un valor predeterminado para el argumento, como marcador de posición, si no se proporcionó y hacer que tu código sea más seguro, dado que cubrirá este caso límite.

Tu código debería verse de la siguiente manera:

NavHost(...) {
    // ...
    composable(
        route =
          "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
        arguments = SingleAccount.arguments
    ) { navBackStackEntry ->
        // Retrieve the passed argument
        val accountType =
            navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)

        // Pass accountType to SingleAccountScreen
        SingleAccountScreen(accountType)
    }
}

Ahora la SingleAccountScreen tiene la información necesaria para mostrar el tipo de cuenta correcto cuando navegas hacia ella. Si observas la implementación de SingleAccountScreen,, verás que ya hace coincidir el accountType que se pasó con la fuente de UserData a fin de recuperar los detalles correspondientes de la cuenta.

Volvamos a realizar una pequeña tarea de optimización y movamos la ruta "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}" a RallyDestinations.kt y su objeto SingleAccount:

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val routeWithArgs = "${route}/{${accountTypeArg}}"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

Una vez más, reemplázalo en el NavHost composable: correspondiente

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments
) {...}

Cómo configurar los destinos iniciales de Accounts y Overview

Ahora que definiste la ruta SingleAccountScreen y el argumento que requiere y acepta para navegar con éxito a SingleAccountScreen, debes asegurarte de que se pase el mismo argumento accountTypeArg del destino anterior (es decir, independientemente del destino del que se provenga).

Como puedes ver, esto tiene dos facetas: el destino de inicio que proporciona y pasa un argumento, y el destino final que lo acepta y lo usa a fin de mostrar la información correcta. Ambos se deben definir de forma explícita.

Por ejemplo, cuando estás en el destino Accounts y presionas el tipo de cuenta "Checking", el destino Accounts debe pasar una string "Checking" como argumento, adjunta a la ruta de string "single_account", para abrir con éxito la SingleAccountScreen correspondiente. Su ruta de string se vería así: "single_account/Checking".

Usa la misma ruta con el argumento pasado cuando uses navController.navigateSingleTopTo(...), de la siguiente manera:

navController.navigateSingleTopTo("${SingleAccount.route}/$accountType").

Pasa esta devolución de llamada de acción de navegación al parámetro onAccountClick de OverviewScreen y AccountsScreen. Ten en cuenta que estos parámetros están predefinidos: onAccountClick: (String) -> Unit, donde String es la entrada. Esto significa que, cuando el usuario presione un tipo de cuenta específico en Overview y Account, esa string de tipo de cuenta ya estará disponible y se podrá pasar con facilidad como un argumento de navegación:

OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)
// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)

Para facilitar la lectura, puedes extraer esta acción de navegación en una función auxiliar privada de extensión:

import androidx.navigation.NavHostController
// ...
OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

En este punto, cuando ejecutes la app, podrás hacer clic en cada tipo de cuenta, y te dirigirá a su SingleAccountScreen correspondiente, en la que se mostrarán los datos de la cuenta determinada.

Grabación de pantalla de la pantalla Overview, desplazamiento a destinos de clic futuros e intento de clic. Los clics ahora dirigen a los destinos.

8. Cómo habilitar la compatibilidad con vínculos directos

Además de argumentos, también puedes agregar vínculos directos a fin de asociar una URL, una acción o un tipo de MIME específicos con un elemento que admita composición. En Android, un vínculo directo es aquel que te lleva directamente a un destino específico dentro de una app. La navegación de Compose admite los vínculos directos implícitos. Cuando se invoca un vínculo directo implícito (por ejemplo, cuando un usuario hace clic en un vínculo), Android puede abrir tu app en el destino correspondiente.

En esta sección, agregarás un vínculo directo nuevo para navegar al elemento SingleAccountScreen componible con un tipo de cuenta correspondiente y también permitirás que este vínculo directo se exponga a apps externas. A modo de repaso, la ruta para este elemento que admite composición era "single_account/{account_type}", que también usarás para el vínculo directo, con algunos cambios menores relacionados.

Dado que la exposición de vínculos directos a apps externas no está habilitada de forma predeterminada, también debes agregar elementos <intent-filter> al archivo manifest.xml de tu app, de modo que este será tu primer paso.

Para comenzar, agrega el vínculo directo al archivo AndroidManifest.xml de tu app. Debes crear un filtro de intents nuevo desde <intent-filter> dentro de <activity>, con la acción VIEW y las categorías BROWSABLE y DEFAULT.

Luego, dentro del filtro, necesitas la etiqueta data a fin de agregar scheme (rally es el nombre de tu app) y host (single_account es la ruta al elemento que admite composición) para definir tu vínculo directo preciso. Se mostrará rally://single_account como URL del vínculo directo.

Ten en cuenta que no necesitas declarar el argumento account_type en el AndroidManifest. Esto se agregará más adelante dentro de la función NavHost de componibilidad.

<activity
    android:name=".RallyActivity"
    android:windowSoftInputMode="adjustResize"
    android:label="@string/app_name"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="rally" android:host="single_account" />
    </intent-filter>
</activity>

Ahora, puedes reaccionar a los intents entrantes desde el objeto RallyActivity.

El elemento SingleAccountScreen que admite composición ya acepta argumentos, pero ahora también debe aceptar el vínculo directo recién creado para iniciar este destino cuando se active dicho vínculo.

Dentro de la función de componibilidad de SingleAccountScreen, agrega un parámetro más deepLinks. Al igual que con arguments,, también acepta una lista de navDeepLink, ya que puedes definir varios vínculos directos que lleven al mismo destino. Pasa el uriPattern, que coincide con el definido en intent-filter en tu manifiesto (rally://singleaccount), pero esta vez también agregarás su argumento accountTypeArg:

import androidx.navigation.navDeepLink
// ...

composable(
    route = SingleAccount.routeWithArgs,
    // ...
    deepLinks = listOf(navDeepLink {
        uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
    })
)

Ya sabes lo que sigue, ¿no? Mueve esta lista a RallyDestinations SingleAccount:

object SingleAccount : RallyDestination {
    // ...
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
    val deepLinks = listOf(
       navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}"}
    )
}

Una vez más, reemplázala en el elemento NavHost componible correspondiente:

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments,
    deepLinks = SingleAccount.deepLinks
) {...}

Ahora tu app y SingleAccountScreen están listos para admitir vínculos directos. A fin de probar que se comporte de manera correcta, instala Rally de nuevo en un emulador o dispositivo conectado, abre una línea de comandos y ejecuta el siguiente comando para simular el lanzamiento de un vínculo directo:

adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW

Esto te llevará directamente a la cuenta "Checking", pero también puedes comprobar que funcione bien para todos los demás tipos de cuentas.

9. Cómo extraer el NavHost en RallyNavHost

Ahora, el elemento NavHost está completo. Sin embargo, a fin de que se pueda probar y para mantener tu RallyActivity más limpio, puedes extraer el NavHost actual y sus funciones auxiliares, como navigateToSingleAccount, del elemento RallyApp que admite composición a su propia función de componibilidad y asignarle el nombre RallyNavHost.

RallyApp es la única función de componibilidad que debería funcionar de forma directa con navController. Como se mencionó antes, todas las demás pantallas anidadas que admiten composición solo deben obtener devoluciones de llamada de navegación, no el objeto navController en sí.

Por lo tanto, el nuevo RallyNavHost aceptará el navController y el modifier como parámetros de RallyApp:

@Composable
fun RallyNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = modifier
    ) {
        composable(route = Overview.route) {
            OverviewScreen(
                onClickSeeAllAccounts = {
                    navController.navigateSingleTopTo(Accounts.route)
                },
                onClickSeeAllBills = {
                    navController.navigateSingleTopTo(Bills.route)
                },
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Accounts.route) {
            AccountsScreen(
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Bills.route) {
            BillsScreen()
        }
        composable(
            route = SingleAccount.routeWithArgs,
            arguments = SingleAccount.arguments,
            deepLinks = SingleAccount.deepLinks
        ) { navBackStackEntry ->
            val accountType =
              navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
            SingleAccountScreen(accountType)
        }
    }
}

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

Ahora agrega el nuevo RallyNavHost a tu RallyApp y vuelve a ejecutar la app para verificar que todo funcione como antes:

fun RallyApp() {
    RallyTheme {
    ...
        Scaffold(
        ...
        ) { innerPadding ->
            RallyNavHost(
                navController = navController,
                modifier = Modifier.padding(innerPadding)
            )
        }
     }
}

10. Cómo probar la navegación de Compose

Desde el principio de este codelab, te aseguraste de no pasar el componente navController directamente a ningún elemento que admita composición (a excepción de la app de alto nivel) y, en su lugar, pasaste las devoluciones de llamada de navegación como parámetros. De esa manera, todos tus elementos que admiten composición se pueden probar de forma individual, ya que no requieren una instancia de navController en las pruebas.

Siempre debes probar que todo el mecanismo de navegación de Compose funcione según lo previsto en tu app. Para ello, prueba RallyNavHost y las acciones de navegación que se pasan a tus elementos que admiten composición. Estos serán los objetivos principales de esta sección. Si deseas probar las funciones de componibilidad individuales por separado, asegúrate de consultar el codelab Pruebas en Jetpack Compose.

A fin de comenzar con la prueba, primero debemos agregar las dependencias de prueba necesarias, así que regresa al archivo de compilación de tu app, que se encuentra en app/build.gradle. En la sección de dependencias, agrega la dependencia navigation-testing:

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

Cómo preparar la clase NavigationTest

Se puede probar tu RallyNavHost de forma independiente de la Activity en sí.

Como esta prueba se ejecutará en un dispositivo Android, deberás crear el directorio de prueba /app/src/androidTest/java/com/example/compose/rally y, luego, crear una nueva clase de prueba del archivo de prueba y asignarle el nombre NavigationTest.

Como primer paso, a fin de usar las APIs de prueba de Compose y probar y controlar elementos componibles y aplicaciones con Compose, agrega una regla de prueba de Compose:

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

}

Cómo escribir tu primera prueba

Crea una función de prueba rallyNavHost pública y anótala con @Test. En esa función, primero debes configurar el contenido de Compose que deseas probar. Para ello, usa el elemento setContent de composeTestRule. Este toma un parámetro de componibilidad como cuerpo y te permite escribir código de Compose y agregar elementos que admiten composición en un entorno de prueba, como si estuvieras en una app de entorno de producción normal.

Dentro de setContent, puedes configurar el sujeto de prueba actual, RallyNavHost, y pasarle una instancia de una nueva instancia de navController. El artefacto de prueba de Navigation ofrece un práctico componente TestNavHostController para usar. Agreguemos este paso:

import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.fail
import org.junit.Test
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost() {
        composeTestRule.setContent {
            // Creates a TestNavHostController
            navController =
                TestNavHostController(LocalContext.current)
            // Sets a ComposeNavigator to the navController so it can navigate through composables
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }
        fail()
    }
}

Si copiaste el código anterior, la llamada fail() garantizará que la prueba falle hasta que se realice una aserción real. Te servirá como recordatorio para que termines de implementar la prueba.

Para verificar que se muestre el elemento de pantalla correcto que admite composición, puedes usar su contentDescription y confirmar que se muestra. En este codelab, ya se configuraron los elementos contentDescription para los destinos de Accounts y Overview, por lo que ya puedes usarlos a los efectos de realizar las verificaciones de prueba.

Como primera verificación, debes comprobar que la pantalla Overview se muestre como el primer destino cuando se inicialice RallyNavHost por primera vez. También debes cambiar el nombre de la prueba de modo que refleje esto: llámala rallyNavHost_verifyOverviewStartDestination. Para ello, reemplaza la llamada fail() por lo siguiente:

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule.setContent {
            navController =
                TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }

        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

Vuelve a ejecutar la prueba y verifica que sea exitosa.

Dado que debes configurar RallyNavHost de la misma manera para cada una de las próximas pruebas, puedes extraer su inicialización en una función @Before anotada a fin de evitar repeticiones innecesarias y hacer que tus pruebas sean más concisas:

import org.junit.Before
// ...

class NavigationTest {

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

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

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

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.

Pruebas con clics en la IU y contentDescription de la pantalla

Como deseas probar la implementación concreta de tu app, es preferible que hagas clic en la IU. A continuación, con el siguiente texto se puede verificar que, mientras estás en la pantalla Overview, cuando haces clic en el botón "SEE ALL" en la subsección Accounts, te dirigirás al destino de Accounts:

5a9e82acf7efdd5b.png

Volverás a usar el elemento contentDescription configurado en este botón específico en el elemento OverviewScreenCard componible, que simulará un clic en él con performClick() y verificará que se muestre el destino de Accounts:

import androidx.compose.ui.test.performClick
// ...

@Test
fun rallyNavHost_clickAllAccount_navigatesToAccounts() {
    composeTestRule
        .onNodeWithContentDescription("All Accounts")
        .performClick()

    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}

Puedes seguir este patrón para probar todas las acciones de navegación de clics restantes en la app.

Pruebas mediante clics en la IU y la comparación de rutas

También puedes usar navController a fin de verificar tus aserciones comparando las rutas de string actuales con la esperada. Para ello, haz clic en la IU, como en la sección anterior, y compara la ruta actual con la que esperas mediante navController.currentBackStackEntry?.destination?.route.

Un paso adicional consiste en asegurarte de que primero te desplaces a la subsección Bills en la pantalla Overview. De lo contrario, la prueba fallará, ya que no podrás encontrar un nodo con el elemento contentDescription "All Bills":

import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
// ...

@Test
fun rallyNavHost_clickAllBills_navigateToBills() {
    composeTestRule.onNodeWithContentDescription("All Bills")
        .performScrollTo()
        .performClick()

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

Si sigues estos patrones, podrás completar tu clase de prueba con las rutas de navegación, los destinos y las acciones de clic adicionales. Ahora, ejecuta todo el conjunto de pruebas para verificar que sean exitosas.

11. Felicitaciones

¡Felicitaciones! Completaste este codelab con éxito. Puedes encontrar el código de la solución aquí y compararlo con el tuyo.

Agregaste la navegación de Jetpack Compose a la app de Rally y ahora conoces sus conceptos clave. Aprendiste a configurar un gráfico de navegación de destinos que admiten composición, definir tus rutas y acciones de navegación, pasar información adicional a las rutas mediante argumentos, configurar vínculos directos y probar tu navegación.

Si deseas obtener más información y temas, como la integración de la barra de navegación inferior, la navegación en varios módulos y los gráficos anidados, puedes consultar el repositorio Now in Android Github y ver el modo en que se implementaron allí.

¿Qué sigue?

Consulta estos materiales para continuar tu ruta de aprendizaje de Jetpack Compose:

Más información sobre el componente Navigation de Jetpack:

Documentos de referencia