Cómo implementar la navegación para IU adaptables

La navegación se refiere a las interacciones que permiten a los usuarios navegar a través, dentro y fuera de las diferentes piezas de contenido de tu app. Las IU adaptables en Compose no cambian de forma sustancial el proceso de navegación, por esa razón deberás seguir cumpliendo con todos sus principios. El componente de Navigation facilita la implementación de patrones recomendados y puedes seguir usándolo en apps con diseños altamente adaptables.

Además de los principios anteriores, hay otras consideraciones para mejorar la experiencia del usuario en apps mediante diseños adaptables. Como se describe en la guía sobre compilación de diseños adaptables, la estructura de la IU podría depender del espacio disponible para tu app. Todos estos principios de navegación adicionales tienen en cuenta lo que sucede cuando cambia el espacio de pantalla disponible para tu app.

IU de navegación responsiva

A fin de brindar la mejor experiencia de navegación posible a los usuarios, deberías proporcionar una IU de navegación que se adapte al espacio disponible para tu app. Te recomendamos que uses una barra de la aplicación inferior, un panel lateral de navegación que esté siempre presente o que se pueda contraer, un riel, o tal vez un elemento completamente nuevo en función del espacio de pantalla disponible y el estilo único de tu app.

Debido a que estos componentes ocupan todo el ancho o el alto de la pantalla, cuando se decide cuál se debe usar se debe tener en cuenta el diseño a nivel de la pantalla. Por ese motivo, recomendamos que uses las clases de tamaño de ventana a fin de determinar el tipo de IU de navegación que se mostrará. Las clases de tamaño de ventana son puntos de interrupción diseñadas para equilibrar la simplicidad y la flexibilidad a fin de optimizar la app en la mayoría de los casos únicos.

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the nav rail
    val showNavRail = windowSizeClass != WindowSizeClass.Compact
    MyScreen(
        showNavRail = showNavRail,
        /* ... */
    )
}

Destinos totalmente responsivos

El modo multiventana, los dispositivos plegables y las ventanas de formato libre de Chrome OS pueden hacer que el espacio disponible para tu app cambie más que nunca.

Si quieres brindar una experiencia sin interrupciones al usuario, en tu host de navegación, usa un gráfico de navegación único en el que cada destino sea responsivo. Este enfoque refuerza los principios fundamentales de la IU responsiva: la flexibilidad y la continuidad. Si cada destino individual controla de forma correcta los eventos de cambio de tamaño, los cambios se aíslan solo en la IU y se conserva el resto del estado de la app (incluida la navegación), lo que ayudará a la continuidad.

Con los gráficos de navegación paralelos, cada vez que la app transicione a otra clase de tamaño, tendrás que determinar el destino actual del usuario en el otro gráfico, reconstruir una pila de actividades y conciliar otra información de estado que difiera entre los gráficos. Este enfoque es complicado y propenso a errores.

En un destino específico, tienes muchas opciones para crear un diseño responsivo. Puedes ajustar el espaciado, usar diseños alternativos, agregar columnas adicionales de información de modo que se use más espacio o mostrar detalles adicionales que no se ajustan a poco espacio. Si quieres obtener más información sobre las herramientas disponibles para implementar estos cambios, consulta el artículo de compilación de diseños adaptables.

Para obtener una experiencia del usuario aun mejor, puedes agregar más contenido a un destino específico mediante un diseño canónico de pantalla grande, como una vista de lista y detalles. A continuación, se exploran las consideraciones de navegación.

Cómo distinguir entre rutas y pantallas

El componente de navegación permite definir rutas, cada una de las cuales corresponde a algún destino. La navegación genera cambios en el destino que se muestra actualmente, junto con el seguimiento de una pila de actividades, la lista de destinos en los que se encontraba el usuario.

En un destino específico, puedes mostrar el contenido que desees. Para un objeto NavHost que controla la navegación principal de tu app, por lo general, debes mostrar una pantalla diferente en cada destino, lo que ocupará todo el espacio disponible para tu app.

Por lo general, cada destino es responsable de mostrar una sola pantalla y cada pantalla se muestra en un solo destino. Sin embargo, este no es un requisito obligatorio. De hecho, puede ser muy útil tener un destino y elegir entre varias pantallas para mostrar contenido según el tamaño disponible para la app.

Veamos JetNews, uno de los ejemplos oficiales de Compose. La función principal de la app consiste en mostrar artículos, que el usuario puede seleccionar de una lista. Cuando la app tenga suficiente espacio, podrá mostrar la lista y un artículo al mismo tiempo. Esta interfaz se trata de un diseño de lista y detalles, que es uno de los diseños canónicos de Material Design.

Pantallas de Lista, Detalle y Lista + Detalles en JetNews

Aunque estas son 3 pantallas visualmente distintas, la app muestra las tres en la misma ruta "home".

En el código, el destino llama a HomeRoute de la siguiente manera:

@Composable
fun JetnewsNavGraph(
    navController: NavHostController,
    isExpandedScreen: Boolean,
    // ...
) {
    // ...
    NavHost(
        navController = navController,
        startDestination = JetnewsDestinations.HomeRoute
    ) {
        composable(JetnewsDestinations.HomeRoute) {
            // ...
            HomeRoute(
                isExpandedScreen = isExpandedScreen,
                // ...
            )
        }
        // ...
    }
}

Luego, el código HomeRoute decide cuál de las tres pantallas mostrar, que son elementos componibles con el sufijo Screen. La app toma esta decisión en función de una combinación del estado de la app que se almacena en HomeViewModel, así como de la clase de tamaño de ventana que describe el espacio disponible actual.

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(/* ... */)
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

Con este enfoque, la app separa con claridad las operaciones de navegación que reemplazan toda la HomeRoute con otro destino mediante el llamado a navigate() en NavController) de las operaciones de navegación que afectan solo el contenido dentro de este destino (por ejemplo, seleccionar un artículo de la lista). Recomendamos que actualices el estado compartido que se aplica a todos los tamaños de ventana para controlar estos eventos, aunque una transición entre la pantalla de lista y la de artículo parezca ser una operación de navegación para el usuario si la app solo muestra un único panel.

Por ello, cuando presionamos un artículo de la lista, actualizamos una marca booleana isArticleOpen:

class HomeViewModel(/* ... */) {
    fun selectArticle(articleId: String) {
        viewModelState.update {
            it.copy(
                isArticleOpen = true,
                selectedArticleId = articleId
            )
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            selectedArticleId = selectedArticleId,
            onSelectArticle = onSelectArticle,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                // ...
            )
        } else {
            HomeListScreen(
                onSelectArticle = onSelectArticle,
                // ...
            )
        }
    }
}

De manera similar, instalamos un elemento BackHandler personalizado cuando solo se muestra la pantalla del artículo, lo que establece el objeto isArticleOpen nuevamente como falso.

class HomeViewModel(/* ... */) {
    fun onArticleBackPress() {
        viewModelState.update {
            it.copy(isArticleOpen = false)
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    onArticleBackPress: () -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                onUpPressed = onArticleBackPress,
                // ...
            )
            BackHandler {
                onArticleBackPress()
            }
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

Con estas capas, se unifican muchos conceptos importantes a la hora de diseñar una app de Compose. Si haces que las pantallas sean reutilizables y permites que se eleve la importancia de su estado, podrás intercambiar pantallas completas con facilidad. Cuando se combina el estado de la app desde un ViewModel con la información de tamaño disponible, una lógica sencilla rige la pantalla en la que se mostrará contenido. Por último, cuando se conserva un flujo de datos unidireccional, tu IU adaptable siempre utilizará el espacio disponible y, al mismo tiempo, preservará el estado del usuario.

Si deseas ver la implementación completa, consulta el ejemplo de JetNews en GitHub.

Preserva el estado del usuario

La consideración más importante para las IU adaptables consiste en preservar el estado del usuario cuando se rote o pliegue el dispositivo, o cuando se cambie el tamaño de la ventana de la app. En particular, todos estos cambios de tamaño deben ser reversibles.

Por ejemplo, supongamos que el usuario ve alguna pantalla en tu app y luego rota el dispositivo. Si deshace esa rotación (es decir, rota el dispositivo de modo que quede en la posición inicial), deberá regresar a la misma pantalla en la que comenzó, con todo su estado preservado. Si se desplazó a la mitad de un contenido antes de rotar el dispositivo, deberá regresar a la misma posición de desplazamiento después de volver a rotarlo.

Recuperación de la posición de desplazamiento en el diseño de lista después de rotar

Los cambios de orientación y los de tamaño de la ventana modifican la configuración, que, de forma predeterminada, vuelven a crear tu Activity y los elementos componibles. El estado se puede guardar mediante estos cambios de configuración con rememberSaveable o ViewModel, de los que puedes obtener más información en El estado y Jetpack Compose. Si no usas herramientas como estas, se perderá el estado del usuario.

Los diseños adaptables suelen tener un estado adicional, ya que pueden mostrar distintos tipos de contenido en diferentes tamaños de pantalla. Por lo tanto, también es importante guardar el estado del usuario para esos fragmentos de contenido adicionales, incluso para los componentes que ya no resultan visibles.

Supongamos que parte del contenido de desplazamiento solo es visible en anchos más grandes. Si una rotación hace que el ancho sea demasiado pequeño para mostrar el contenido de desplazamiento, el contenido que se desplaza se ocultará. Cuando el usuario vuelve a rotar su dispositivo, el contenido de desplazamiento vuelve a ser visible y se debe restablecer la posición de desplazamiento original.

Recuperación de la posición de desplazamiento en el diseño de detalle mientras se rota

En Compose, puedes lograr esto con la elevación de estados. Si elevas el estado de los elementos componibles más arriba en el árbol de composición, su estado se podrá conservar incluso cuando ya no sean visibles.

En JetNews, elevamos el estado a HomeRoute de modo que se conserve y se vuelva a usar mientras se determina cuál pantalla será visible:

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    selectedArticleId: String,
    // ...
) {
    val homeListState = rememberHomeListState()
    val articleState = rememberSaveable(
        selectedArticleId,
        saver = ArticleState.Saver
    ) {
        ArticleState()
    }

    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            homeListState = homeListState,
            articleState = articleState,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                articleState = articleState,
                // ...
            )
        } else {
            HomeListScreen(
                homeListState = homeListState,
                // ...
            )
        }
    }
}

Evita navegar como un efecto secundario de un cambio de tamaño

Si agregas una pantalla a tu app que aprovecha el espacio adicional que brindan las pantallas más grandes, puede ser tentador agregar un destino nuevo a tu app para el diseño recién establecido.

Sin embargo, supongamos que el usuario está viendo este nuevo diseño en la pantalla interna de un dispositivo plegable. Si pliega el dispositivo, es posible que no haya suficiente espacio para mostrar el nuevo diseño en la pantalla externa. Esto introduce el requisito de navegar a otro lugar si el nuevo tamaño de pantalla es demasiado pequeño. Esto tiene los siguientes problemas:

  • La navegación como efecto secundario de la composición puede hacer que se vea momentáneamente el destino anterior, ya que debe mostrarse antes de que se produzca la navegación.
  • Para mantener la reversibilidad, también tendremos que regresar cuando se desplegó el dispositivo.
  • Es muy difícil mantener el estado del usuario a lo largo de estos cambios, ya que la navegación podría borrar el estado anterior luego de resaltar la pila de actividades.

Como consideración adicional, es posible que la app no esté en primer plano mientras se produzcan estos cambios. Quizás tu app muestre un diseño que requiera más espacio y, luego, el usuario la ubique en segundo plano. Si regresan a tu app más tarde, es posible que la orientación, el tamaño y la pantalla física hayan cambiado desde la última vez que se reanudó tu app.

Si solo deseas mostrar algunos destinos para determinados tamaños de pantalla, considera combinar los destinos relevantes en una sola ruta y mostrar diferentes pantallas en esa ruta como se analizó más arriba.