Navegación para IUs responsivas

La navegación es el proceso de interactuar con la IU de una aplicación para acceder a los destinos de contenido de la app. Los principios de navegación de Android proporcionan lineamientos que te ayudan a crear una navegación intuitiva y uniforme en las apps.

Las IU responsivas brindan destinos de contenido responsivos y, a menudo, incluyen diferentes tipos de elementos de navegación en respuesta a los cambios de tamaño de la pantalla, por ejemplo, una barra de navegación en la parte inferior en pantallas pequeñas y un riel de navegación en pantallas de tamaño mediano o un panel lateral de navegación persistente en pantallas grandes, pero las IU responsivas deben cumplir con los principios de navegación.

El componente de Navigation de Jetpack implementa los principios de navegación y se puede usar para facilitar el desarrollo de apps con IUs responsivas.

Figura 1: Pantallas expandidas, medianas y compactas con panel lateral de navegación, riel y barra inferior.

Navegación responsiva de la IU

El tamaño de la ventana de visualización que ocupa una app afecta la ergonomía y la usabilidad. Las clases de tamaño de ventanas te permiten determinar los elementos de navegación adecuados (como barras de navegación, rieles o paneles laterales) y colocarlos en el lugar donde el usuario pueda acceder más fácilmente. En los lineamientos de diseño de Material Design, los elementos de navegación ocupan un espacio persistente en el extremo inicial y pueden moverse al borde inferior cuando el ancho de la app es compacto. Los elementos de navegación que elijas dependen en gran medida del tamaño de la ventana de la app y de la cantidad de partes que debe contener el elemento.

Clase de tamaño de la ventana Pocos elementos Muchos elementos
ancho compacto barra de navegación inferior panel lateral de navegación (margen inicial o inferior)
ancho medio riel de navegación panel lateral de navegación (extremo inicial)
ancho expandido riel de navegación panel lateral de navegación persistente (extremo inicial)

En los diseños basados en vistas, los archivos de recursos de diseño se pueden calificar por puntos de interrupción de clase de tamaño de ventana a fin de usar elementos de navegación distintos para diferentes dimensiones de pantalla. Jetpack Compose puede usar los puntos de interrupción que proporciona la API de Window Size Class a fin de determinar de manera programática el elemento de navegación más adecuado para la ventana de la app.

Vistas

<!-- res/layout/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w600dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigationrail.NavigationRailView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w1240dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigation.NavigationView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

Compose

// This method should be run inside a Composable function.
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
// You can get the height of the current window by invoking heightSizeClass instead.

@Composable
fun MyApp(widthSizeClass: WindowWidthSizeClass) {
    // Select a navigation element based on window size.
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> { CompactScreen() }
        WindowWidthSizeClass.Medium -> { MediumScreen() }
        WindowWidthSizeClass.Expanded -> { ExpandedScreen() }
    }
}

@Composable
fun CompactScreen() {
    Scaffold(bottomBar = {
                NavigationBar {
                    icons.forEach { item ->
                        NavigationBarItem(
                            selected = isSelected,
                            onClick = { ... },
                            icon = { ... })
                    }
                }
            }
        ) {
        // Other content
    }
}

@Composable
fun MediumScreen() {
    Row(modifier = Modifier.fillMaxSize()) {
        NavigationRail {
            icons.forEach { item ->
                NavigationRailItem(
                    selected = isSelected,
                    onClick = { ... },
                    icon = { ... })
            }
        }
        // Other content
    }
}

@Composable
fun ExpandedScreen() {
    PermanentNavigationDrawer(
        drawerContent = {
            icons.forEach { item ->
                NavigationDrawerItem(
                    icon = { ... },
                    label = { ... },
                    selected = isSelected,
                    onClick = { ... }
                )
            }
        },
        content = {
            // Other content
        }
    )
}

Destinos de contenido responsivos

En una IU receptiva, el diseño de cada destino de contenido debe adaptarse a los cambios del tamaño de la ventana. Tu app puede ajustar el espaciado de diseño, cambiar la posición de los elementos, agregar o quitar contenido, o cambiar los elementos de la IU, incluidos los elementos de navegación. (consulta Cómo migrar tu IU a diseños responsivos y Cómo brindar compatibilidad con diferentes tamaños de pantalla).

Cuando cada destino individual controla correctamente los eventos de cambio de tamaño, los cambios se aíslan de la IU. El resto del estado de la app, incluida la navegación, no se ve afectado.

La navegación no debería ocurrir como un efecto secundario de los cambios de tamaño de ventana. No crees destinos de contenido solo para que se adapten a diferentes tamaños de ventanas. Por ejemplo, no crees diferentes destinos de contenido para las diferentes pantallas de un dispositivo plegable.

La navegación como efecto secundario de los cambios de tamaño de ventana tiene los siguientes problemas:

  • El destino anterior (para el tamaño de ventana anterior) podría estar visible temporalmente antes de navegar al nuevo destino.
  • Para mantener la reversibilidad (por ejemplo, cuando se pliega y se despliega un dispositivo), se requiere la navegación para cada tamaño de ventana.
  • Mantener el estado de la aplicación entre destinos puede ser difícil, ya que la navegación puede destruir el estado cuando se abre la pila de actividades.

Además, es posible que tu app ni siquiera se encuentre en primer plano mientras se producen los cambios de tamaño de las ventanas. El diseño de tu app podría requerir más espacio que la app en primer plano y, cuando el usuario regrese a ella, es posible que todo el tamaño de la orientación y la ventana haya cambiado.

Si tu app requiere destinos de contenido únicos según el tamaño de la ventana, considera combinar los destinos relevantes en un solo destino que incluya diseños alternativos.

Destinos de contenido con diseños alternativos

Como parte de un diseño responsivo, un destino de navegación único puede tener diseños alternativos según el tamaño de la ventana de la app. Cada diseño ocupa toda la ventana, pero se presentan diseños diferentes para distintos tamaños de ventana.

Un ejemplo de versión canónica es la vista de detalles de lista. En el caso de los tamaños de ventana pequeños, tu app muestra un diseño de contenido para la lista y otro para los detalles. Si se navega al destino de la vista de lista-detalles, primero se muestra solo el diseño de la lista. Cuando se selecciona un elemento de la lista, tu app muestra el diseño detallado y reemplaza la lista. Cuando se selecciona el control de retroceso, se muestra el diseño de la lista y reemplaza el detalle. Sin embargo, en el caso de los tamaños de ventana expandidos, la lista y los diseños detallados se muestran lado a lado.

Vistas

SlidingPaneLayout te permite crear un solo destino de navegación que muestra dos paneles de contenido, uno al lado del otro, en pantallas grandes, pero solo un panel a la vez en dispositivos de pantalla pequeña, como los teléfonos.

<!-- Single destination for list and detail. -->

<navigation ...>

    <!-- Fragment that implements SlidingPaneLayout. -->
    <fragment
        android:id="@+id/article_two_pane"
        android:name="com.example.app.ListDetailTwoPaneFragment" />

    <!-- Other destinations... -->
</navigation>

Consulta Cómo crear un diseño de doble panel para obtener información detallada para implementar un diseño de detalles de lista mediante SlidingPaneLayout.

Compose

En Compose, se puede implementar una vista de detalles de lista combinando elementos de componibilidad alternativos en una sola ruta que use clases de tamaño de ventana para emitir el elemento de ese tipo apropiado para cada clase de tamaño.

La ruta de navegación hacia un destino de contenido, generalmente, es un único elemento de componibilidad, pero también puede consistir en elementos de este tipo alternativos. La lógica empresarial determina cuál de los elementos componibles alternativos se muestra. El elemento componible ocupa toda la ventana de la app, independientemente de la alternativa que se muestre.

La vista de lista-detalles consta de tres elementos de componibilidad, por ejemplo:

/* Displays a list of items. */
@Composable
fun ListOfItems(
    onItemSelected: (String) -> Unit,
) { /*...*/ }

/* Displays the detail for an item. */
@Composable
fun ItemDetail(
    selectedItemId: String? = null,
) { /*...*/ }

/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
    selectedItemId: String? = null,
    onItemSelected: (String) -> Unit,
) {
  Row {
    ListOfItems(onItemSelected = onItemSelected)
    ItemDetail(selectedItemId = selectedItemId)
  }
}

Una sola ruta de navegación proporciona acceso a la vista de lista-detalles:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      /*...*/
    )
  } else {
    // If the display size cannot accommodate both the list and the item detail,
    // show one of them based on the user's focus.
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(/*...*/)
    }
  }
}

ListDetailRoute (el destino de navegación) determina cuál de los tres elementos componibles se emite: ListAndDetail para el tamaño de ventana expandido ListOfItems o ItemDetail para el tamaño compacto, según si se seleccionó un elemento de la lista.

La ruta se incluye en un NavHost, por ejemplo:

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  /*...*/
}

Para proporcionar el argumento isExpandedWindowSize, examina las WindowMetrics de tu app.

Un objeto ViewModel puede proporcionar el argumento selectedItemId que mantiene el estado en todos los tamaños de ventana. Cuando el usuario selecciona un elemento de la lista, se actualiza la variable de estado selectedItemId:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

La ruta también incluye un BackHandler personalizado cuando el elemento de detalles del elemento componible ocupa toda la ventana de la app:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }

  fun onItemBackPress() {
    viewModelState.update {
      it.copy(selectedItemId = null)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
    onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
      BackHandler {
        onItemBackPress()
      }
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

La combinación de estados de apps desde un objeto ViewModel con información de clase de tamaño de ventana hace que elegir el elemento componible apropiado sea una cuestión de lógica simple. Si mantienes un flujo de datos unidireccional, tu app puede usar todo el espacio de visualización disponible y, al mismo tiempo, preservar el estado de la app.

Para ver una implementación completa de la vista de lista-detalles en Compose, consulta el ejemplo de JetNews en GitHub.

Un gráfico de navegación

Para brindar una experiencia de usuario uniforme en cualquier dispositivo, o bien en cualquier tamaño de ventana, usa un solo gráfico de navegación en el que el diseño de cada destino de contenido sea responsivo.

Si usas un gráfico de navegación diferente para cada clase de tamaño de ventana, cada vez que la app pase de una clase de tamaño a otra, debes determinar el destino actual del usuario en los otros gráficos, construir una pila de actividades y conciliar la información de estado que difiera entre los gráficos.

Host de navegación anidada

Tu app podría incluir un destino de contenido que tenga destinos de contenido propios. Por ejemplo, en una vista de lista-detalles, el panel de detalles del elemento podría incluir elementos de la IU que navegan al contenido que reemplaza el detalle del elemento.

Para implementar este tipo de subnavegación, el panel de detalles puede ser un host de navegación anidada con su propio gráfico de navegación que especifica los destinos a los que se accede desde el panel de detalles:

Vistas

<!-- layout/two_pane_fragment.xml -->

<androidx.slidingpanelayout.widget.SlidingPaneLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sliding_pane_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_pane"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_gravity="start"/>

    <!-- Detail pane is a nested navigation host. Its graph is not connected
         to the main graph that contains the two_pane_fragment destination. -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/detail_pane"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/detail_pane_nav_graph" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

Compose

@Composable
fun ItemDetail(selectedItemId: String? = null) {
    val navController = rememberNavController()
    NavHost(navController, "itemSubdetail1") {
        composable("itemSubdetail1") { ItemSubdetail1(...) }
        composable("itemSubdetail2") { ItemSubdetail2(...) }
        composable("itemSubdetail3") { ItemSubdetail3(...) }
    }
}

Esto es diferente de un gráfico de navegación anidada porque el gráfico de navegación del elemento NavHost anidado no está conectado al gráfico de navegación principal. Es decir, no puedes navegar directamente desde los destinos en un gráfico hasta los destinos en el otro.

Para obtener más información, consulta Gráficos de navegación anidada y Cómo navegar con Compose.

Estado preservado

Para proporcionar destinos de contenido responsivos, tu app debe preservar su estado cuando se rota o se pliega el dispositivo, o bien se cambia el tamaño de la ventana de la app. De forma predeterminada, los cambios de configuración como estos recrean las actividades, los fragmentos, la jerarquía de vistas y los elementos componibles de la app. La forma recomendada de guardar el estado de la IU es con un ViewModel o rememberSaveable, que persiste tras los cambios de configuración (consulta Cómo guardar estados de IU y Estado y Jetpack Compose).

Los cambios de tamaño deberían ser reversibles (por ejemplo, cuando el usuario rota el dispositivo y, luego, vuelve a rotarlo).

Los diseños responsivos pueden mostrar distintos elementos de contenido en diferentes tamaños de ventana. Suelen guardar estados adicionales relacionados con el contenido, incluso si el estado no corresponde al tamaño de ventana actual. Por ejemplo, supongamos que un diseño tiene espacio para mostrar un widget adicional de desplazamiento solo en anchos más grandes de ventana. Si un cambio de tamaño hace que el ancho de la ventana se vuelva demasiado pequeño, el widget se oculta. Cuando la app cambia de tamaño a sus dimensiones anteriores, el widget de desplazamiento vuelve a estar visible y se debe restablecer la posición de desplazamiento original.

Alcances de ViewModel

La guía para desarrolladores Cómo migrar al componente Navigation recomienda una arquitectura de actividad única en la que los destinos se implementan como fragmentos y sus modelos de datos se implementan mediante ViewModel.

Un ViewModel siempre tiene alcance en un ciclo de vida y, una vez que el ciclo de vida termina de forma permanente, el ViewModel se borra y se puede descartar. El ciclo de vida en el que se define el alcance de ViewModel (y, por lo tanto, cuánto se puede compartir el objeto ViewModel) depende del delegado de propiedad que se use para obtener el ViewModel.

En el caso más simple, cada destino de navegación es un fragmento único con un estado de IU completamente aislado. Por lo tanto, cada fragmento puede usar el delegado de la propiedad viewModels() para obtener un ViewModel con alcance para ese fragmento.

A fin de compartir el estado de la IU entre fragmentos, define el alcance del elemento ViewModel en la actividad llamando a activityViewModels() en los fragmentos (el equivalente a la actividad es solo viewModels()). De esta manera, la actividad y los fragmentos que se adjuntan a ella comparten la instancia ViewModel. Sin embargo, en una arquitectura de una sola actividad, este alcance ViewModel perdura de manera efectiva mientras dure la app, por lo que ViewModel permanece en la memoria incluso si no hay fragmentos que lo usen.

Supongamos que tu gráfico de navegación tiene una secuencia de destinos de fragmentos que representa un flujo de confirmación de la compra y el estado actual de toda la experiencia de confirmación de la compra se encuentra en un objeto ViewModel compartido entre los fragmentos. Determinar el alcance de ViewModel según la actividad no solo es demasiado amplio, sino que expone otro problema: si el usuario pasa por el flujo de confirmación de la compra de un pedido y, luego, lo vuelve a realiza en un segundo pedido, los dos pedidos usan la misma instancia de confirmación de la compra ViewModel. Deberás borrar manualmente los datos del primer pedido antes de la segunda confirmación de la compra. Cualquier error podría resultar costoso para el usuario.

En cambio, puedes definir el alcance de ViewModel en un gráfico de navegación en el NavController actual. Crea un gráfico de navegación anidada para encapsular los destinos que forman parte del flujo de confirmación de la compra. Luego, en cada uno de esos destinos de fragmentos, usa el delegado de la propiedad navGraphViewModels() y pasa el ID del gráfico de navegación a fin de obtener el ViewModel compartido. Esto garantiza que, una vez que el usuario salga del flujo de confirmación de la compra y el gráfico de navegación anidado esté fuera del alcance, la instancia correspondiente de ViewModel se descartará y no se usará en la siguiente confirmación de la compra.

Ámbito Delegado de la propiedad Puede compartir ViewModel con
Fragmento Fragment.viewModels() Solo el fragmento actual
Actividad Activity.viewModels()

Fragment.activityViewModels()

La actividad y todos los fragmentos asociados a ella
Gráfico de navegación Fragment.navGraphViewModels() Todos los fragmentos en el mismo gráfico de navegación

Ten en cuenta que, si usas un host de navegación anidada (consulta la sección anterior), los destinos de ese host no pueden compartir ViewModel con destinos fuera del host cuando se usa navGraphViewModels(), ya que los gráficos no están conectados. En este caso, puedes usar el alcance de la actividad.

Estado elevado

En Compose, puedes conservar el estado durante los cambios de tamaño de ventana mediante la elevación de estado. Si elevas el estado de los elementos componibles a una posición más arriba en el árbol de composición, el estado se puede conservar incluso cuando los elementos componibles ya no son visibles.

En la sección Compose de Destinos de contenido con diseños alternativos de más arriba, elevamos el estado de los elementos componibles de la vista de lista-detalles para que ListDetailRoute se conserve, sin importar qué elemento componible se muestra:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) { /*...*/ }

Recursos adicionales