Cómo navegar entre pantallas con Compose

1. Antes de comenzar

Hasta este momento, las apps en las que trabajaste tenían una sola pantalla. Sin embargo, es probable que muchas de las apps que uses tengan varias pantallas por las que puedas navegar. Por ejemplo, la app de Configuración tiene muchas páginas de contenido distribuidas en diferentes pantallas.

En Modern Android Development, las apps multipantalla se crean con el componente Navigation de Jetpack. Este componente de Navigation Compose te permite compilar con facilidad apps multipantalla en Compose a través de un enfoque declarativo, tal como se compilan las interfaces de usuario. En este codelab, se presentan los aspectos básicos del componente Navigation de Compose, así como la forma de lograr que la AppBar sea responsiva y cómo enviar datos de tu app a otra con intents, además de demostrar las prácticas recomendadas en una app cada vez más compleja.

Requisitos previos

  • Conocimientos del lenguaje Kotlin, incluidos los tipos de funciones, las lambdas y las funciones de alcance
  • Conocimientos de diseños básicos de Row y Column en Compose

Qué aprenderás

  • Crear un NavHost componible para definir rutas y pantallas en tu app
  • Navegar entre pantallas utilizando un NavHostController
  • Manipular la pila de actividades para navegar a pantallas anteriores
  • Usar intents para compartir datos con otra app
  • Personalizar la AppBar, incluidos el título y el botón Atrás

Qué compilarás

  • Implementarás la navegación en una app multipantalla.

Requisitos

  • La versión más reciente de Android Studio
  • Conexión a Internet para descargar el código de partida

2. Descarga el código de partida

Para comenzar, descarga el código de partida:

Descargar ZIP

Como alternativa, puedes clonar el repositorio de GitHub para el código:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout starter

Si deseas ver el código de partida para este codelab, míralo en GitHub.

3. Explicación de la app

La app de Cupcake es un poco diferente de las apps con las que trabajaste hasta ahora. En lugar de que todo el contenido se muestre en una sola pantalla, la app tiene cuatro pantallas distintas, y el usuario puede navegar por cada una de ellas mientras pide magdalenas. Si ejecutas la app, no podrás ver nada ni podrás navegar entre estas pantallas, ya que el componente de navegación aún no está agregado al código de la app. No obstante, puedes verificar las previsualizaciones de los componibles de cada pantalla y compararlas con las pantallas finales de la aplicación que aparecen a continuación.

Pantalla de inicio del pedido

La primera pantalla presenta al usuario tres botones que corresponden a la cantidad de magdalenas que pedirá.

En el código, esto se representa a través del elemento componible StartOrderScreen en StartOrderScreen.kt.

La pantalla consta de una sola columna, con una imagen y texto, junto con tres botones personalizados para pedir diferentes cantidades de magdalenas. Los botones personalizados se implementan a través del elemento componible SelectQuantityButton, que también está en StartOrderScreen.kt.

Pantalla de selección de sabores

Después de seleccionar la cantidad, la app le pedirá al usuario que seleccione un sabor para la magdalena. La app usa lo que se conoce como botones de selección a fin de mostrar diferentes opciones. Los usuarios pueden seleccionar un sabor entre diversas opciones.

La lista de posibles sabores se almacena como una lista de IDs de recursos de cadenas en data.DataSource.kt.

Selecciona la pantalla de fecha de retiro

Después de elegir un sabor, la app presenta al usuario otra serie de botones de selección para que elija una fecha de retiro. Las opciones de retiro provienen de una lista que muestra la función pickupOptions() en OrderViewModel.

Las pantallas Choose Flavor y Choose Pickup Date se representan con el mismo elemento componible, SelectOptionScreen en SelectOptionScreen.kt. ¿Por qué usar el mismo elemento? Porque el diseño de estas pantallas es exactamente el mismo. La única diferencia son los datos, pero puedes usar el mismo elemento componible para mostrar las pantallas de sabores y fecha de retiro.

Pantalla de resumen del pedido

Después de seleccionar la fecha de retiro, la app muestra la pantalla Order Summary, en la que el usuario puede revisar y completar el pedido.

Esta pantalla se implementa a través del elemento componible OrderSummaryScreen en SummaryScreen.kt.

El diseño consiste en un Column que contiene toda la información sobre el pedido, un elemento componible Text para el subtotal y botones para enviar el pedido a otra app o cancelarlo y volver a la primera pantalla.

Si los usuarios deciden enviar el pedido a otra app, la app de Cupcake muestra una Android ShareSheet con diferentes opciones para compartir.

13bde33712e135a4.png

El estado actual de la app se almacena en data.OrderUiState.kt. La clase de datos OrderUiState contiene propiedades para almacenar las selecciones que realiza el usuario en cada pantalla.

Las pantallas de la app se presentarán en el elemento componible CupcakeApp. Sin embargo, en el proyecto inicial, la app simplemente muestra la primera pantalla. Por el momento, no es posible navegar por todas las pantallas de la app, pero no te preocupes, ya que para eso estás haciendo este codelab. Aprenderás a definir rutas de navegación, configurar un elemento componible NavHost para navegar entre pantallas (también conocido como destino), realizar intents de integración con componentes de la IU del sistema (como la pantalla para compartir) y hacer que la AppBar responda a los cambios de navegación.

Elementos componibles reutilizables

Las apps de ejemplo de este curso están diseñadas para implementar prácticas recomendadas cuando corresponda. La app de Cupcake no es la excepción. En el paquete ui.components, verás un archivo llamado CommonUi.kt que contiene un elemento componible FormattedPriceLabel. Varias pantallas de la app usan este elemento para dar formato al precio del pedido de manera coherente. En lugar de duplicar el elemento Text con el mismo formato y los mismos modificadores, puedes definir FormattedPriceLabel una vez y volver a usarlo tantas veces como sea necesario para otras pantallas.

Las pantallas de sabores y fecha de retiro usan el elemento componible SelectOptionScreen que también se puede reutilizar. Este elemento toma un parámetro llamado options del tipo List<String> que representa las opciones que se mostrarán. Las opciones aparecen en una Row, que consta de un elemento componible RadioButton y un elemento Text que contiene cada cadena. Una Column rodea todo el diseño y también contiene un elemento Text que admite composición para mostrar el precio con formato, los botones Cancel y Next.

4. Cómo definir rutas y crear un NavHostController

Partes del componente Navigation

El componente Navigation tiene tres partes principales:

  • NavController: Es responsable de navegar entre los destinos, es decir, las pantallas en tu app.
  • NavGraph: Realiza la asignación de los destinos componibles a los que se navegará.
  • NavHost: Es el elemento componible que funciona como contenedor para mostrar el destino actual del NavGraph.

En este codelab, te enfocarás en el NavController y el NavHost. Dentro del NavHost, definirás los destinos para el NavGraph de la app de Cupcake.

Cómo definir las rutas para los destinos en tu app

Uno de los conceptos fundamentales de la navegación en una app de Compose es la ruta. Una ruta es una string que se corresponde con un destino. Esta idea es similar al concepto de una URL. Así como una URL diferente se asigna a una página diferente en un sitio web, una ruta es una string que se asigna a un destino y sirve como su identificador único. Por lo general, un destino es un único elemento componible (o un grupo de ellos) que corresponde a lo que ve el usuario. La app de Cupcake necesita destinos para la pantalla de inicio del pedido, la pantalla de sabores, la pantalla de fecha de retiro y la pantalla de resumen del pedido.

Hay una cantidad limitada de pantallas en una app, por lo que también hay una cantidad limitada de rutas. Puedes definir las rutas de una app mediante una clase de tipo enum. En Kotlin, estas clases tienen una propiedad de nombre que muestra una string con el nombre de la propiedad.

Comenzarás por definir las cuatro rutas de la app de Cupcake.

  • Start: Selecciona la cantidad de magdalenas optando por uno de los tres botones.
  • Flavor: Selecciona el sabor a partir de una lista de opciones.
  • Pickup: Selecciona la fecha de retiro a partir de una lista de opciones.
  • Summary: Revisa las selecciones y envía o cancela el pedido.

Agrega una clase de tipo enum para definir las rutas.

  1. En CupcakeScreen.kt, encima del elemento componible CupcakeAppBar, agrega una clase de tipo enum llamada CupcakeScreen.
enum class CupcakeScreen() {

}
  1. Agrega cuatro casos a la clase enum: Start, Flavor, Pickup y Summary.
enum class CupcakeScreen() {
    Start,
    Flavor,
    Pickup,
    Summary
}

Cómo agregar un NavHost a tu app

Un NavHost es un elemento componible que muestra otros destinos, según una ruta determinada. Por ejemplo, si la ruta es Flavor, NavHost mostrará la pantalla para elegir el sabor de la magdalena. Si la ruta es Summary, la app mostrará la pantalla de resumen.

La sintaxis de NavHost es como cualquier otro elemento componible.

fae7688d6dd53de9.png

Se destacan dos parámetros.

  • navController: Es una instancia de la clase NavHostController. Puedes usar este objeto a fin de navegar entre pantallas, por ejemplo, si llamas al método navigate() para navegar a otro destino. Puedes obtener el NavHostController si llamas a rememberNavController() desde una función de componibilidad.
  • startDestination: Es una ruta de cadenas que define el destino que se muestra de forma predeterminada cuando la app muestra el NavHost por primera vez. En el caso de la app de Cupcake, esta debería ser la ruta Start.

Al igual que otros elementos componibles, NavHost también toma un parámetro modifier.

Agregarás un NavHost al elemento componible CupcakeApp en CupcakeScreen.kt. Primero, necesitas una referencia al controlador de navegación. Puedes usar este controlador tanto en el NavHost que estás agregando ahora como en el AppBar que agregarás en un paso posterior. Por lo tanto, debes declarar la variable en el elemento CupcakeApp().

  1. Abre CupcakeScreen.kt.
  2. Dentro de Scaffold, debajo de la variable uiState, agrega un elemento NavHost.
import androidx.navigation.compose.NavHost

Scaffold(
    ...
) { innerPadding ->
    val uiState by viewModel.uiState.collectAsState()

    NavHost()
}
  1. Pasa la variable navController para el parámetro navController y CupcakeScreen.Start.name para el parámetro startDestination. Pasa el modificador que se pasó a CupcakeApp() para el parámetro del modificador. Pasa una lambda final vacía para el parámetro final.
import androidx.compose.foundation.layout.padding

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {

}

Cómo administrar las rutas en tu NavHost

Al igual que otros elementos que admiten composición, NavHost toma un tipo de función para su contenido.

f67974b7fb3f0377.png

Dentro de la función de contenido de un NavHost, debes llamar a la función composable(). La función composable() tiene dos parámetros obligatorios.

  • route: Es una string que corresponde al nombre de una ruta. Puede ser cualquier string única. Usarás la propiedad de nombre de las constantes de la clase enum CupcakeScreen.
  • content: Aquí puedes llamar a un elemento que deseas mostrar para la ruta determinada.

Llamarás a la función composable() una vez para cada una de las cuatro rutas.

  1. Llama a la función composable() y pasa CupcakeScreen.Start.name para la route.
import androidx.navigation.compose.composable

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {

    }
}
  1. Dentro de la expresión lambda final, llama al elemento StartOrderScreen y pasa quantityOptions para la propiedad quantityOptions. Para el modifier, pasa Modifier.fillMaxSize().padding(dimensionResource(R.dimen.padding_medium)).
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.res.dimensionResource
import com.example.cupcake.ui.StartOrderScreen
import com.example.cupcake.data.DataSource

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}
  1. Debajo de la primera llamada a composable(), vuelve a llamar a composable() y pasa CupcakeScreen.Flavor.name para la route.
composable(route = CupcakeScreen.Flavor.name) {

}
  1. Dentro de la expresión lambda final, obtén una referencia a LocalContext.current y almacénala en una variable llamada context. Context es una clase abstracta que se debe implementar por medio del sistema Android. Permite acceder a recursos y clases específicos de la aplicación, así como a llamadas ascendentes para operaciones a nivel de la aplicación, como el lanzamiento de actividades, etc. Puedes utilizar esta variable para obtener las cadenas de la lista de ID de recursos en el modelo de vista para mostrar la lista de sabores.
import androidx.compose.ui.platform.LocalContext

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
}
  1. Llama al elemento componible SelectOptionScreen.
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(

    )
}
  1. La pantalla de sabores debe mostrar y actualizar el subtotal cuando el usuario selecciona un sabor. Pasa uiState.price para el parámetro subtotal.
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price
    )
}
  1. La pantalla de sabores obtiene la lista de opciones de los recursos de cadenas de la app. Puedes transformar la lista de ID de recursos en una lista de cadenas usando la función map() y llamando a context.resources.getString(id) para cada sabor.
import com.example.cupcake.ui.SelectOptionScreen

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) }
    )
}
  1. Para el parámetro onSelectionChanged, pasa una expresión lambda que llame a setFlavor() en el modelo de vista y pasa it (el argumento que se pasa a onSelectionChanged()). Para el parámetro modifier, pasa Modifier.fillMaxHeight()..
import androidx.compose.foundation.layout.fillMaxHeight
import com.example.cupcake.data.DataSource.flavors

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}

La pantalla de fecha de retiro es similar a la de sabores. La única diferencia son los datos que se pasan al elemento componible SelectOptionScreen.

  1. Vuelve a llamar a la función composable() y pasa CupcakeScreen.Pickup.name para el parámetro route.
composable(route = CupcakeScreen.Pickup.name) {

}
  1. En la expresión lambda final, llama al elemento SelectOptionScreen y pasa uiState.price para el subtotal, como antes. Pasa uiState.pickupOptions para el parámetro options y una expresión lambda que llame a setDate() en el viewModel para el parámetro onSelectionChanged. Para el parámetro modifier, pasa Modifier.fillMaxHeight()..
SelectOptionScreen(
    subtotal = uiState.price,
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. Llama a composable() una vez más y pasa CupcakeScreen.Summary.name para la route.
composable(route = CupcakeScreen.Summary.name) {

}
  1. Dentro de la expresión lambda final, llama al elemento componible OrderSummaryScreen() y pasa la variable uiState para el parámetro orderUiState. Para el parámetro modifier, pasa Modifier.fillMaxHeight()..
import com.example.cupcake.ui.OrderSummaryScreen

composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        modifier = Modifier.fillMaxHeight()
    )
}

Eso es todo lo que se necesita para configurar el NavHost. En la siguiente sección, harás que tu app cambie de ruta y navegue entre pantallas cuando el usuario presione cada uno de los botones.

5. Cómo navegar entre rutas

Ahora que definiste las rutas y las asignaste a elementos componibles en un NavHost, es hora de navegar entre pantallas. El NavHostController (la propiedad del navController que surge de llamar a rememberNavController()) es el responsable de navegar entre rutas. Sin embargo, ten en cuenta que esta propiedad se define en el elemento CupcakeApp. Necesitas una forma de acceder a él desde las diferentes pantallas de tu app.

Fácil, ¿verdad? Solo pasa navController como parámetro a cada uno de los elementos componibles.

Si bien este enfoque funciona, no es una forma ideal de diseñar tu app. Un beneficio de usar un NavHost para manejar la navegación de tu app es que la lógica de navegación se mantiene independiente de la IU individual. Esta opción evita algunas de las principales desventajas de pasar navController como parámetro.

  • La lógica de navegación se guarda en un solo lugar, lo que puede facilitar el mantenimiento de tu código y evitar errores, ya que no da vía libre de forma accidental a las pantallas individuales para la navegación en tu app.
  • En las apps que necesitan trabajar con diferentes factores de forma (como un teléfono en modo Retrato, un teléfono plegable o una tablet con pantalla grande), es posible que un botón active la navegación, según el diseño de la app. Las pantallas individuales deben ser independientes, y no es necesario que tengan en cuenta otras pantallas de la app.

En cambio, nuestro enfoque consiste en pasar un tipo de función a cada elemento componible para lo que debe suceder cuando un usuario hace clic en el botón. De esa manera, el elemento y cualquiera de sus elementos secundarios deciden cuándo llamar a la función. Sin embargo, la lógica de navegación no está expuesta a las pantallas individuales de tu app. Todo el comportamiento de navegación se controla en el NavHost.

Cómo agregar controladores de botones a StartOrderScreen

Comenzarás por agregar un parámetro de tipo de función al que se llama cuando se presiona uno de los botones de cantidad en la primera pantalla. Esta función se pasa al elemento componible StartOrderScreen y es responsable de actualizar el viewmodel y navegar a la siguiente pantalla.

  1. Abre StartOrderScreen.kt.
  2. Debajo del parámetro quantityOptions y antes del parámetro modificador, agrega un parámetro llamado onNextButtonClicked de tipo () -> Unit.
@Composable
fun StartOrderScreen(
    quantityOptions: List<Pair<Int, Int>>,
    onNextButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. Ahora que el elemento componible StartOrderScreen espera un valor de onNextButtonClicked, encuentra el StartOrderPreview y pasa un cuerpo lambda vacío al parámetro onNextButtonClicked.
@Preview
@Composable
fun StartOrderPreview() {
    CupcakeTheme {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            onNextButtonClicked = {},
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}

Cada botón corresponde a una cantidad diferente de magdalenas. Necesitarás esta información de modo que la función pasada de onNextButtonClicked pueda actualizar el viewmodel según corresponda.

  1. Modifica el tipo del parámetro onNextButtonClicked para que tome un parámetro Int.
onNextButtonClicked: (Int) -> Unit,

Para obtener el Int que se pasará cuando se llame a onNextButtonClicked(), observa el tipo de parámetro quantityOptions.

El tipo es List<Pair<Int, Int>> o una lista de Pair<Int, Int>. Es posible que no conozcas el tipo Pair, pero, tal como sugiere su nombre, consiste en un par de valores. Pair toma dos parámetros de tipo genérico. En este caso, ambos son del tipo Int.

8326701a77706258.png

Se puede acceder a cada elemento de un par a través de la primera o la segunda propiedad. En el caso del parámetro quantityOptions del elemento componible StartOrderScreen, el primer Int es un ID de recurso para la cadena que se mostrará en cada botón. El segundo Int es la cantidad real de magdalenas.

Pasaremos la segunda propiedad del par seleccionado cuando llames a la función onNextButtonClicked().

  1. Encuentra la expresión lambda vacía para el parámetro onClick de SelectQuantityButton.
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = {}
    )
}
  1. Dentro de la expresión lambda, llama a onNextButtonClicked y pasa item.second, la cantidad de magdalenas.
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = { onNextButtonClicked(item.second) }
    )
}

Cómo agregar controladores de botones a SelectOptionScreen

  1. Debajo del parámetro onSelectionChanged del elemento componible SelectOptionScreen en SelectOptionScreen.kt, agrega un parámetro llamado onCancelButtonClicked de tipo () -> Unit con un valor predeterminado de {}.
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. Debajo del parámetro onCancelButtonClicked, agrega otro parámetro de tipo () -> Unit llamado onNextButtonClicked con un valor predeterminado de {}
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    onNextButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. Pasa onCancelButtonClicked para el parámetro onClick del botón Cancel.
OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
  1. Pasa onNextButtonClicked para el parámetro onClick del botón Next.
Button(
    modifier = Modifier.weight(1f),
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

Cómo agregar controladores de botones a SummaryScreen

Por último, agrega las funciones del controlador de botones para los botones Cancel y Send en la pantalla de resumen.

  1. En el elemento componible OrderSummaryScreen, en SummaryScreen.kt, agrega un parámetro llamado onCancelButtonClicked de tipo () -> Unit.
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. Agrega otro parámetro de tipo (String, String) -> Unit y asígnale el nombre onSendButtonClicked.
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    onSendButtonClicked: (String, String) -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. El elemento componible OrderSummaryScreen ahora espera valores para onSendButtonClicked y onCancelButtonClicked. Encuentra el OrderSummaryPreview, pasa un cuerpo lambda vacío con dos parámetros String a onSendButtonClicked y un cuerpo lambda vacío a los parámetros onCancelButtonClicked.
@Preview
@Composable
fun OrderSummaryPreview() {
   CupcakeTheme {
       OrderSummaryScreen(
           orderUiState = OrderUiState(0, "Test", "Test", "$300.00"),
           onSendButtonClicked = { subject: String, summary: String -> },
           onCancelButtonClicked = {},
           modifier = Modifier.fillMaxHeight()
       )
   }
}
  1. Pasa onSendButtonClicked para el parámetro onClick del botón Send. Pasa newOrder y orderSummary, las dos variables definidas antes en OrderSummaryScreen. Estas cadenas consisten en los datos reales que el usuario puede compartir con otra app.
Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
    Text(stringResource(R.string.send))
}
  1. Pasa onCancelButtonClicked para el parámetro onClick del botón Cancel.
OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}

Para navegar a otra ruta, simplemente llama al método navigate() en tu instancia de NavHostController.

fc8aae3911a6a25d.png

El método de navegación toma un solo parámetro: una String que corresponde a una ruta definida en tu NavHost. Si la ruta coincide con una de las llamadas a composable() en el NavHost, la app navega a esa pantalla.

Pasarás funciones que llamen a navigate() cuando el usuario presione botones en las pantallas Start, Flavor y Pickup.

  1. En CupcakeScreen.kt, busca la llamada a composable() para la pantalla de inicio. Para el parámetro onNextButtonClicked, pasa una expresión lambda.
StartOrderScreen(
    quantityOptions = DataSource.quantityOptions,
    onNextButtonClicked = {
    }
)

¿Recuerdas la propiedad Int que se pasó a esta función para la cantidad de magdalenas? Antes de navegar a la pantalla siguiente, debes actualizar el modelo de vistas de modo que la app muestre el subtotal correcto.

  1. Llama a setQuantity en el viewModel y pasa it.
onNextButtonClicked = {
    viewModel.setQuantity(it)
}
  1. Llama a navigate() en el navController y pasa CupcakeScreen.Flavor.name para el route.
onNextButtonClicked = {
    viewModel.setQuantity(it)
    navController.navigate(CupcakeScreen.Flavor.name)
}
  1. Para el parámetro onNextButtonClicked en la pantalla de sabores, simplemente pasa una lambda que llame a navigate() y pasa CupcakeScreen.Pickup.name para la route.
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. Pasa una lambda vacía para onCancelButtonClicked, que implementarás a continuación.
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
    onCancelButtonClicked = {},
    options = DataSource.flavors.map { id -> context.resources.getString(id) },
    onSelectionChanged = { viewModel.setFlavor(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. Para el parámetro onNextButtonClicked en la pantalla de retiro, pasa una lambda que llame a navigate() y pasa CupcakeScreen.Summary.name para la route.
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. Una vez más, pasa una lambda vacía para onCancelButtonClicked().
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
    onCancelButtonClicked = {},
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. Para OrderSummaryScreen, pasa lambdas vacías para onCancelButtonClicked y onSendButtonClicked. Agrega los parámetros para el subject y el summary que se pasan a onSendButtonClicked, que implementarás pronto.
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {},
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
    )
}

Ahora deberías poder navegar por cada pantalla de tu app. Ten en cuenta que, si llamas a navigate(), no solo cambiará la pantalla, sino que se colocará encima de la pila de actividades. Además, cuando presionas el botón del sistema para ir hacia atrás, podrás volver a la pantalla anterior.

La app apila cada pantalla en la parte superior de la anterior, y el botón para ir hacia atrás ( bade5f3ecb71e4a2.png) puede quitarlas. El historial de pantallas desde el elemento startDestination en la parte inferior hasta la parte superior que se acaba de mostrar se conoce como la pila de actividades.

Cómo ir a la pantalla de inicio

A diferencia del botón para ir hacia atrás del sistema, el botón Cancel no vuelve a la pantalla anterior. En cambio, debe quitar todas las pantallas de la pila de actividades y volver a la pantalla de inicio.

Puedes hacer esto llamando al método popBackStack().

2f382e5eb319b4b8.png

El método popBackStack() tiene dos parámetros obligatorios.

  • route: Es la cadena que representa la ruta del destino al que deseas volver.
  • inclusive: Es un valor booleano que, si es verdadero, también muestra (quita) la ruta especificada. Si es falso, popBackStack() quitará todos los destinos que se encuentren sobre el de inicio (pero no este último), lo que hará que sea la pantalla superior visible para el usuario.

Cuando los usuarios presionan el botón Cancel en cualquiera de las pantallas, la app restablece el estado del modelo de vistas y llama a popBackStack(). Primero, implementarás un método para hacer esto y, luego, lo pasarás en el parámetro adecuado en las tres pantallas con los botones Cancel.

  1. Después de la función CupcakeApp(), define una función privada llamada cancelOrderAndNavigateToStart().
private fun cancelOrderAndNavigateToStart() {
}
  1. Agrega dos parámetros: viewModel de tipo OrderViewModel y navController de tipo NavHostController.
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
}
  1. En el cuerpo de la función, llama a resetOrder() en el viewModel.
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
}
  1. Llama a popBackStack() en el navController, pasa CupcakeScreen.Start.name para la route y false para el parámetro inclusive.
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
  1. En el elemento componible CupcakeApp(), pasa cancelOrderAndNavigateToStart para los parámetros onCancelButtonClicked de los dos elementos SelectOptionScreen y el elemento OrderSummaryScreen.
composable(route = CupcakeScreen.Start.name) {
    StartOrderScreen(
        quantityOptions = DataSource.quantityOptions,
        onNextButtonClicked = {
            viewModel.setQuantity(it)
            navController.navigate(CupcakeScreen.Flavor.name)
        },
        modifier = Modifier
            .fillMaxSize()
            .padding(dimensionResource(R.dimen.padding_medium))
    )
}
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
   )
}
  1. Ejecuta tu app y prueba si, cuando se presiona el botón Cancel en cualquiera de las pantallas, el usuario vuelve a la primera pantalla.

6. Cómo navegar a otra app

Hasta ahora, aprendiste a navegar a una pantalla diferente en tu app y volver a la pantalla principal. Solo falta completar un paso más para implementar la navegación en la app de Cupcake. En la pantalla de resumen del pedido, el usuario puede enviar su pedido a otra app. Esta selección abre una ShareSheet (un componente de la interfaz de usuario que cubre la parte inferior de la pantalla) que muestra las opciones para compartir.

Esta parte de la IU no forma parte de la app de Cupcake. De hecho, lo proporciona el sistema operativo Android. La IU del sistema, como la pantalla para compartir, no recibe llamadas de tu navController. En su lugar, usarás algo llamado Intent.

Un intent es una solicitud para que el sistema realice alguna acción, en general, presentando una actividad nueva. Existen muchos intents diferentes, y te recomendamos que consultes la documentación con el fin de obtener una lista completa. Sin embargo, nos interesa el que se llama ACTION_SEND. Puedes enviarle algunos datos a este intent, como una string, y presentar las acciones de uso compartido adecuadas para esos datos.

El proceso básico para configurar un intent es el siguiente:

  1. Crea un objeto de intent y especifica el intent, como ACTION_SEND.
  2. Especifica el tipo de datos adicionales que se envían con el intent. Para un texto simple, puedes usar "text/plain", aunque hay otros tipos disponibles, como "image/*" o "video/*".
  3. Pasa cualquier dato adicional al intent, como el texto o la imagen que se compartirá, llamando al método putExtra(). Este intent tendrá dos extras: EXTRA_SUBJECT y EXTRA_TEXT.
  4. Llama al método startActivity() de contexto y pasa una actividad creada a partir del intent.

Te explicaremos cómo crear el intent de acción de uso compartido, pero el proceso es el mismo para otros tipos de intents. En proyectos futuros, te recomendamos que consultes la documentación según sea necesario para el tipo específico de datos y los extras necesarios.

Completa los siguientes pasos con el fin de crear un intent de modo que se envíe el pedido de magdalenas a otra app:

  1. En CupcakeScreen.kt, debajo del elemento componible CupcakeApp, crea una función privada llamada shareOrder().
private fun shareOrder()
  1. Agrega un parámetro llamado context de tipo Context.
import android.content.Context

private fun shareOrder(context: Context) {
}
  1. Agrega dos parámetros de tipo String: subject y summary. Estas cadenas se mostrarán en la hoja de acciones para compartir.
private fun shareOrder(context: Context, subject: String, summary: String) {
}
  1. Dentro del cuerpo de la función, crea un intent llamado intent y pasa Intent.ACTION_SEND como argumento.
import android.content.Intent

val intent = Intent(Intent.ACTION_SEND)

Dado que solo necesitas configurar este objeto Intent una vez, puedes hacer que las siguientes líneas de código resulten más concisas mediante la función apply(), que aprendiste en un codelab anterior.

  1. Llama a apply() en el intent recién creado y pasa una expresión lambda.
val intent = Intent(Intent.ACTION_SEND).apply {

}
  1. En el cuerpo de la lambda, establece el tipo en "text/plain". Debido a que estás haciendo esto en una función pasada a apply(), no necesitas hacer referencia al identificador del objeto, intent.
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
}
  1. Llama a putExtra() y pasa el asunto de EXTRA_SUBJECT.
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
}
  1. Llama a putExtra() y pasa el resumen de EXTRA_TEXT.
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
    putExtra(Intent.EXTRA_TEXT, summary)
}
  1. Llama al método startActivity() de contexto.
context.startActivity(

)
  1. Dentro de la expresión lambda pasada a startActivity(), crea una actividad desde el intent llamando al método de clase createChooser(). Pasa el intent del primer argumento y el recurso de cadenas new_cupcake_order.
context.startActivity(
    Intent.createChooser(
        intent,
        context.getString(R.string.new_cupcake_order)
    )
)
  1. En el elemento componible CupcakeApp, en la llamada a composable() para CucpakeScreen.Summary.name, obtén una referencia al objeto de contexto para que puedas pasarlo a la función shareOrder().
composable(route = CupcakeScreen.Summary.name) {
    val context = LocalContext.current

    ...
}
  1. En el cuerpo de la lambda de onSendButtonClicked(), llama a shareOrder() y pasa context, subject y summary como argumentos.
onSendButtonClicked = { subject: String, summary: String ->
    shareOrder(context, subject = subject, summary = summary)
}
  1. Ejecuta tu app y navega por las pantallas.

Cuando hagas clic en Send Order to Another App, deberías ver las acciones de uso compartido, como Messaging y Bluetooth, en la hoja inferior, junto con el asunto y el resumen que proporcionaste como extras.

13bde33712e135a4.png

7. Cómo hacer que la AppBar responda a la navegación

Si bien tu app funciona y puede navegar desde cada pantalla y hacia ellas, aún falta algo en las capturas de pantalla que vimos al comienzo de este codelab. La app bar no responde automáticamente a la navegación. El título no se actualiza cuando la app navega a una ruta nueva ni muestra el botón Up antes del título cuando corresponde.

El código de inicio incluye un elemento componible llamado CupcakeAppBar y sirve para administrar la AppBar. Ahora que implementaste la navegación en la app, puedes usar la información de la pila de actividades para mostrar el título correcto y el botón Up si corresponde. El elemento componible CupcakeAppBar debe reconocer la pantalla actual de manera tal que el título se actualice correctamente.

  1. En la enum CupcakeScreen en CupcakeScreen.kt, agrega un parámetro de tipo Int llamado title usando la anotación @StringRes.
import androidx.annotation.StringRes

enum class CupcakeScreen(@StringRes val title: Int) {
    Start,
    Flavor,
    Pickup,
    Summary
}
  1. Agrega un valor de recurso para cada caso de enum correspondiente al texto del título para cada pantalla. Usa app_name para la pantalla Start, choose_flavor para la pantalla Flavor, choose_pickup_date para la pantalla Pickup y order_summary para la pantalla Summary.
enum class CupcakeScreen(@StringRes val title: Int) {
    Start(title = R.string.app_name),
    Flavor(title = R.string.choose_flavor),
    Pickup(title = R.string.choose_pickup_date),
    Summary(title = R.string.order_summary)
}
  1. Agrega un parámetro llamado currentScreen de tipo CupcakeScreen al elemento componible CupcakeAppBar.
fun CupcakeAppBar(
    currentScreen: CupcakeScreen,
    canNavigateBack: Boolean,
    navigateUp: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. Dentro de CupcakeAppBar, reemplaza el nombre codificado de la app con el título de la pantalla actual pasando currentScreen.title a la llamada para stringResource() para el parámetro de título de TopAppBar.
TopAppBar(
    title = { Text(stringResource(currentScreen.title)) },
    modifier = modifier,
    navigationIcon = {
        if (canNavigateBack) {
            IconButton(onClick = navigateUp) {
                Icon(
                    imageVector = Icons.Filled.ArrowBack,
                    contentDescription = stringResource(R.string.back_button)
                )
            }
        }
    }
)

El botón Up solo debe mostrarse si hay un elemento componible en la pila de actividades. Si la app no tiene pantallas en la pila de actividades (es decir, si se muestra StartOrderScreen), no debería mostrarse el botón Up. Para verificar esto, necesitas una referencia a la pila de actividades.

  1. En el elemento componible CupcakeApp, debajo de la variable navController, crea una variable llamada backStackEntry y llama al método currentBackStackEntryAsState() de navController con el delegado by.
import androidx.navigation.compose.currentBackStackEntryAsState

@Composable
fun CupcakeApp(
    viewModel: OrderViewModel = viewModel(),
    navController: NavHostController = rememberNavController()
){

    val backStackEntry by navController.currentBackStackEntryAsState()

    ...
}
  1. Convierte el título actual de la pantalla en un valor de CupcakeScreen. Debajo de la variable backStackEntry crea una variable utilizando val llamada currentScreen igual al resultado de llamar la función de clase valueOf() de CupcakeScreen, y pasa la ruta del destino de backStackEntry. Usa el operador elvis para proporcionar un valor predeterminado de CupcakeScreen.Start.name.
val currentScreen = CupcakeScreen.valueOf(
    backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
  1. Pasa el valor de la variable currentScreen al parámetro del mismo nombre del elemento componible CupcakeAppBar.
CupcakeAppBar(
    currentScreen = currentScreen,
    canNavigateBack = false,
    navigateUp = {}
)

Siempre que en la pila de actividades haya una pantalla detrás de la actual, debería aparecer el botón Up. Puedes usar una expresión booleana para identificar si el botón Up debe aparecer.

  1. Para el parámetro canNavigateBack, pasa una expresión booleana que verifique si la propiedad previousBackStackEntry de navController es distinta del valor nulo.
canNavigateBack = navController.previousBackStackEntry != null,
  1. A fin de volver a la pantalla anterior, llama al método navigateUp() de navController.
navigateUp = { navController.navigateUp() }
  1. Ejecuta tu app.

Verás que el título AppBar ahora se actualiza y refleja la pantalla actual. Cuando navegues a una pantalla que no sea StartOrderScreen, debería aparecer el botón Up, que te llevará a la pantalla anterior.

3fd023516061f522.gif

8. Obtén el código de la solución

Para descargar el código del codelab terminado, puedes usar estos comandos de git:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout navigation

También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Descargar ZIP

Si deseas ver el código de la solución para este codelab, míralo en GitHub.

9. Resumen

¡Felicitaciones! Acabas de pasar de trabajar en aplicaciones simples de una pantalla a una app compleja y multipantalla con el componente Navigation de Jetpack para desplazarte por varias pantallas. Definiste rutas, las controlaste en un NavHost y usaste parámetros de tipo de función para separar la lógica de navegación de las pantallas individuales. También aprendiste a enviar datos a otra app mediante intents y a personalizar la barra de la aplicación en respuesta a la navegación. En las próximas unidades, seguirás usando estas habilidades mientras trabajas en varias apps multipantalla de mayor complejidad.

Más información