Cómo agregar diseños adaptables a una app para Android basada en vistas con Compose

1. Antes de comenzar

Los dispositivos Android pueden tener diferentes formas y tamaños, por lo que debes adaptar el diseño de tu app a los diferentes tamaños de pantalla a fin de que esté disponible para diferentes usuarios y dispositivos con un solo paquete de Android (APK) o Android App Bundle (AAB). Para ello, debes definir tu app con un diseño responsivo y adaptable, en lugar de hacerlo con dimensiones estáticas para un tamaño de pantalla y una relación de aspecto determinados. Los diseños adaptables cambian en función del espacio de pantalla disponible.

En este codelab, aprenderás los conceptos básicos para compilar IU adaptables y modificar una app que muestre una lista de deportes y detalles de cada deporte para admitir dispositivos de pantalla grande. La app de Deportes consta de tres pantallas: principal, favoritos y configuración. En la pantalla principal, se mostrará una lista de deportes y un marcador de posición de noticias cuando seleccionas un deporte de la lista. Las pantallas de configuración y de favoritos también muestran texto de marcadores de posición. Selecciona el elemento asociado en el menú de navegación inferior para cambiar de pantalla.

La app comienza con estos problemas de diseño en pantallas grandes:

  • No puedes usarla en orientación vertical.
  • Se muestran muchos espacios en blanco en pantallas grandes.
  • Siempre se muestra el menú de navegación inferior en pantallas grandes.

Debes hacer que tu app sea adaptable para que suceda lo siguiente:

  • Admita las orientaciones horizontal y vertical.
  • Muestre la lista de deportes y noticias sobre cada deporte al lado cuando hay suficiente espacio horizontal para hacerlo.
  • Muestre el componente de navegación según los lineamientos de Material Design.

La app es una app de una sola actividad con varios fragmentos. Tú trabajas con los siguientes archivos:

  • El archivo AndroidManifest.xml, que proporciona los metadatos sobre la app de Deportes.
  • El archivo MainActivity.kt, que contiene un código generado con el archivo activity_main.xml, la anotación override, una clase enum que representa los tamaños de clase del ancho de la ventana y una definición del método para recuperar la clase de tamaño del ancho para la ventana de la app. El menú de navegación inferior se inicializa cuando se crea la actividad.
  • El archivo activity_main.xml, que define el diseño predeterminado de la actividad Main.
  • El archivo layout-sw600dp/activity_main.xml, que define un diseño alternativo para la actividad Main. El diseño alternativo es eficaz cuando el ancho de la ventana de la app es mayor o igual que el valor 600dp. El contenido es el mismo que el diseño predeterminado del punto de partida.
  • El archivo SportsListFragment.kt, que contiene la implementación de la lista de deportes y la navegación hacia atrás personalizada.
  • El archivo fragment_sports_list.xml, que define un diseño de la lista de deportes.
  • El archivo navigation_menu.xml, que define los elementos del menú de navegación inferior.

La app de Deportes muestra una lista de deportes en una ventana compacta con una barra de navegación como componente principal de navegación. La app de Deportes muestra una lista de deportes y de noticias deportivas una al lado de la otra en una ventana mediana. Un riel de navegación se muestra como un componente de navegación superior. La app de Deportes muestra un panel lateral de navegación, una lista de deportes y las noticias en una ventana expandida.

Figura 1: La app de Deportes admite diferentes tamaños de ventana con un solo APK o AAB

Requisitos previos

Qué aprenderás

  • Cómo admitir cambios de configuración
  • Cómo agregar diseños alternativos con menos modificaciones de código
  • Cómo implementar una IU de detalles de lista que se comporte de manera diferente en distintos tamaños de ventana

Qué compilarás

Una app para Android que admita lo siguiente:

  • Orientación horizontal del dispositivo
  • Tablets, computadoras de escritorio y dispositivos móviles
  • Comportamiento de los detalles de lista para diferentes tamaños de pantalla

Requisitos

2. Prepárate

Descarga el código de este codelab y configura el proyecto:

  1. Desde tu línea de comandos, clona el código de este repositorio de GitHub:
$ git clone https://github.com/android/add-adaptive-layouts
  1. En Android Studio, abre el proyecto AddingAdaptiveLayout. El proyecto se compila en varias ramas de git:
  • La rama main contiene el código de partida para este proyecto. Realiza cambios en esta rama para completar el codelab.
  • La rama end contiene la solución para este codelab.

3. Cómo migrar el componente de navegación superior a Compose

La app de Deportes usa un menú de navegación inferior como componente principal de navegación. Este componente de navegación se implementa con la clase BottomNavigationView. En esta sección, migrarás el componente de navegación superior a Compose.

Representa el contenido del recurso de menú asociado como una clase sellada

  1. Crea una clase MenuItem sellada para representar el contenido del archivo navigation_menu.xml y, luego, pásale un parámetro iconId, un parámetro labelId y un parámetro destinationId.
  2. Agrega un objeto Home, un objeto Favorite y un objeto Settings como subclases que correspondan a tus destinos.
sealed class MenuItem(
    // Resource ID of the icon for the menu item
    @DrawableRes val iconId: Int,
    // Resource ID of the label text for the menu item
    @StringRes val labelId: Int,
    // ID of a destination to navigate users
    @IdRes val destinationId: Int
) {

    object Home: MenuItem(
        R.drawable.ic_baseline_home_24,
        R.string.home,
        R.id.SportsListFragment
    )

    object Favorites: MenuItem(
        R.drawable.ic_baseline_favorite_24,
        R.string.favorites,
        R.id.FavoritesFragment
    )

    object Settings: MenuItem(
        R.drawable.ic_baseline_settings_24,
        R.string.settings,
        R.id.SettingsFragment
    )
}

Implementa el menú de navegación inferior como una función de componibilidad

  1. Define una función de componibilidad BottomNavigationBar que tome estos tres parámetros: un objeto menuItems configurado en un valor List<MenuItem>, un objeto modifier configurado en un valor Modifier = Modifier y un objeto onMenuSelected configurado en una función lambda (MenuItem) -> Unit = {}.
  2. En el cuerpo de la función de componibilidad BottomNavigationBar, llama a una función NavigationBar() que toma un parámetro modifier.
  3. En la función lambda pasada a la función NavigationBar(), llama al método forEach() en el parámetro menuItems y, luego, llama a una función NavigationBarItem() en la función lambda configurada en la llamada de método foreach()
  4. Pasa la función NavigationBarItem() a un parámetro selected configurado en un valor false; un parámetro onClick configurado en una función lambda que contiene una función onMenuSelected con un parámetro MenuItem; un parámetro icon configurado en una función lambda que contiene una función Icon que toma un parámetro painter = painterResource(id = menuItem.iconId) y un parámetro contentDescription = null; y un parámetro label configurado en una función lambda que contiene una función Text que toma un parámetro (text = stringResource(id = menuItem.labelId).
@Composable
fun BottomNavigationBar(
    menuItems: List<MenuItem>,
    modifier: Modifier = Modifier,
    onMenuSelected: (MenuItem) -> Unit = {}
) {

    NavigationBar(modifier = modifier) {
        menuItems.forEach { menuItem ->
            NavigationBarItem(
                selected = false,
                onClick = { onMenuSelected(menuItem) },
                icon = {
                    Icon(
                        painter = painterResource(id = menuItem.iconId),
                        contentDescription = null)
                },
                label = { Text(text = stringResource(id = menuItem.labelId))}
            )
        }
    }
}

Reemplaza el elemento BottomNavigationView por el elemento ComposeView en el archivo de recursos de diseño

  • En el archivo activity_main.xml, reemplaza el elemento BottomNavigationView por el elemento ComposeView. De esta manera, se incorpora el componente de navegación inferior en un diseño de IU basado en vistas con Compose.

activity_main.xml

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment_content_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toTopOf="@id/top_navigation"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/nav_graph" />

        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/navigation"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:menu="@menu/top_navigation" />
    </androidx.constraintlayout.widget.ConstraintLayout>

Integra el menú de navegación inferior basado en Compose con un diseño de IU basado en vistas

  1. En el archivo MainActivity.kt, define una variable navigationMenuItems configurada en una lista de objetos MenuItem. Los objetos MenuItems aparecen en el menú de navegación inferior en el orden de la lista.
  2. Llama a la función BottomNavigationBar() para incorporar el menú de navegación inferior en el objeto ComposeView.
  3. Navega al destino asociado con el elemento seleccionado por los usuarios en la función de devolución de llamada que se pasa a la función BottomNavigationBar.

MainActivity.kt

val navigationMenuItems = listOf(
    MenuItem.Home,
    MenuItem.Favorites,
    MenuItem.Settings
)

binding.navigation.setContent {
    MaterialTheme {
        BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
            navController.navigate(screen.destinationId)
        }
    }
}

4. Admite la orientación horizontal

Si tu app admite un dispositivo de pantalla grande, se espera que admita la orientación horizontal y la vertical. En este momento, tu app solo tiene una actividad: la actividad MainActivity.

La orientación de la pantalla de la actividad en el dispositivo se establece en el archivo AndroidManifest.xml con el atributo android:screenOrientation, que está configurado en un valor portrait.

Haz que la app admita la orientación horizontal de la siguiente manera:

  1. Configura el atributo android:screenOrientation en un valor fullUser. Con esta configuración, los usuarios pueden bloquear la orientación de su pantalla. El sensor de orientación del dispositivo determina cualquiera de las cuatro orientaciones posibles.

AndroidManifest.xml

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:screenOrientation="fullUser">

La app de Deportes admite orientación horizontal mediante la configuración del valor fullUser en el atributo android:screenOrientation de un elemento de actividad en el archivo AndroidManifest.xml.

Figura 2: La app se ejecuta en orientación horizontal después de actualizar el archivo AndroidManifest.xml

5. Clases de tamaño de ventana

Estos son valores de punto de interrupción que ayudan a clasificar el tamaño de la ventana en clases de tamaño predefinidas (compactas, medianas y expandidas) con el tamaño sin procesar de la ventana disponible para tu app. Usarás estas clases de tamaño cuando diseñes, desarrolles y pruebes tu diseño adaptable.

El ancho y las alturas disponibles se particionan de forma independiente para que tu app siempre esté asociada con dos clases de tamaño de ventana: una clase de ancho y otra de alto.

Hay dos puntos de interrupción entre tres clases de ancho de ventana. Un valor de 600 dp es el punto de interrupción entre los tamaños de ventana compacta y mediana, y uno de 840 dp es el que se encuentra entre las clases de tamaño de ventana mediana y expandida.

Figura 3: Clases de ancho de ventana y puntos de interrupción asociados

Hay dos puntos de interrupción para tres clases de tamaño de altura de ventana. Un valor de 480 dp es el que se encuentra entre las clases de tamaño de ventana compacta y mediana, y un valor de 900 dp es el que se encuentra entre las clases de tamaño de ventana mediana y expandida.

Figura 4: Las clases de tamaño de ventana de alto y sus puntos de interrupción asociados

Las clases de tamaño de ventana representan el tamaño actual de la ventana de tu app. En otras palabras, no puedes determinar la clase de tamaño de ventana según el tamaño del dispositivo físico. Incluso si tu app se ejecuta en el mismo dispositivo, la clase de tamaño de ventana asociada cambia según la configuración, por ejemplo, cuando ejecutas tu app en el modo de pantalla dividida. Esto tiene dos consecuencias importantes:

  • Los dispositivos físicos no garantizan una clase específica del tamaño de la ventana.
  • La clase de tamaño de ventana puede cambiar durante el ciclo de vida de la app.

Después de agregar diseños adaptables a tu app, debes probarla en todos los tamaños de ventanas, en especial, en clases de tamaño de ventana compacta, mediana y expandida. Las pruebas de cada clase de tamaño de ventana son necesarias, pero en muchos casos no son suficientes. Es importante que pruebes tu app en una variedad de tamaños de ventana para asegurarte de que la IU se ajuste correctamente. Para obtener más información, consulta Diseños de pantalla grande y Calidad de las apps en pantallas grandes.

6. Coloca los paneles de lista y de detalles uno al lado de otro en pantallas grandes

Es posible que una IU de detalles de lista deba comportarse de manera diferente según la clase de tamaño de ventana actual del ancho. Cuando la clase de tamaño de ventana mediana o expandida se asocia con tu app, significa que esta puede tener suficiente espacio para mostrar el panel de lista y el panel de detalles uno al lado de otro, de modo que los usuarios puedan ver la lista de elementos y los detalles del elemento seleccionado sin transición de pantalla. Sin embargo, esto puede verse muy ajustado en pantallas más pequeñas, en las que puede ser mejor mostrar un panel a la vez, con el panel de lista en primer lugar. En el panel de detalles, se muestran los detalles del elemento seleccionado cuando los usuarios presionan un elemento de la lista. La clase SlidingPaneLayout administra la lógica para determinar cuál de estas dos experiencias del usuario es apropiada para el tamaño actual de la ventana.

Configura el diseño del panel de lista

La clase SlidingPaneLayout es un componente de la IU basado en vistas. Debes modificar el archivo de recursos de diseño para el panel de lista, que es el archivo fragment_sports_list.xml de este codelab.

La clase SlidingPaneLayout toma dos elementos secundarios. Los atributos de ancho y ponderación de cada elemento secundario son los factores clave de la clase SlidingPaneLayout para determinar si la ventana es lo suficientemente grande para mostrar ambas vistas una al lado de la otra. De lo contrario, se reemplazará la lista de pantalla completa por la IU de detalles de pantalla completa. Los valores de ponderación se refieren al tamaño de los dos paneles de forma proporcional cuando el tamaño de la ventana es mayor que el requisito mínimo para mostrar los paneles uno al lado del otro.

Se aplicó la clase SlidingPaneLayout al archivo fragment_sports_list.xml. El panel de lista está configurado para tener un ancho de 1280dp. Este es el motivo por el que el panel de lista y el de detalles no aparecen uno al lado del otro.

Haz que los paneles se muestren uno al lado del otro cuando la pantalla tenga un ancho mayor que 580dp:

  • Configura el RecyclerView en un ancho de 280dp.

fragment_sports_list.xml

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

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:clipToPadding="false"
        android:padding="8dp"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

    <androidx.fragment.app.FragmentContainerView
        android:layout_height="match_parent"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:id="@+id/detail_container"
        android:name="com.example.android.sports.NewsDetailsFragment"/>
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

Después de actualizar el archivo sports_list_fragment.xml, la app mostrará la lista de deportes y las noticias del deporte seleccionado en paralelo.

Figura 5: Los paneles de lista y de detalles aparecen uno al lado del otro después de actualizar el archivo de recursos de diseño

Intercambia el panel de detalles

Ahora, los paneles de lista y de detalles aparecen uno al lado del otro cuando la clase de tamaño de ventana de ancho medio o expandido está asociada con tu app. Sin embargo, la pantalla pasa por completo al panel de detalles cuando los usuarios seleccionan un elemento en el panel de lista.

La app realiza una transición de pantalla y muestra solo las noticias sobre deportes, aunque se espera que siga mostrando la lista de deportes y las noticias en paralelo.

Figura 6: La pantalla pasa al panel de detalles después de seleccionar un deporte de la lista

Este problema se debe a que la navegación se activa cuando los usuarios seleccionan un elemento del panel de lista. El código relevante está disponible en el archivo SportsListFragment.kt.

SportsListFragment.kt

val adapter = SportsAdapter {
    sportsViewModel.updateCurrentSport(it)
    // Navigate to the details pane.
    val action =
       SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
    this.findNavController().navigate(action)
}

Asegúrate de que la pantalla solo complete la transición al panel de detalles cuando no haya suficiente espacio para mostrar los paneles de lista y de detalles uno al lado del otro:

  • En la variable de función adapter, agrega una sentencia if que verifique si el atributo isSlidable de la clase SlidingPaneLayout es verdadero y si el atributo isOpen de la clase SlidingPaneLayout es falso.

SportsListFragment.kt

val adapter = SportsAdapter {
    sportsViewModel.updateCurrentSport(it)
    if(slidingPaneLayout.isSlidable && !slidingPaneLayout.isOpen){
        // Navigate to the details pane.
        val action =
           SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
        this.findNavController().navigate(action)
    }
}

7. Elige el componente de navegación correcto por clase de tamaño de ancho de ventana

Material Design espera que tu app elija los componentes de manera adaptable. En esta sección, elegirás un componente de navegación para la barra de navegación superior según la clase de ancho de ventana actual. En esta tabla, se describe el componente de navegación esperado para cada clase de tamaño de ventana:

Clase tamaño de ancho de ventana

Componente de navegación

Compacta

Navegación inferior

Media

Riel de navegación

Expandida

Panel lateral de navegación permanente

Implementa el riel de navegación

  1. Crea una función de componibilidad NavRail() que tome tres parámetros: un objeto menuItems configurado en un valor List<MenuItem>, un objeto modifier configurado en un valor Modifier y una función lambda onMenuSelected.
  2. En el cuerpo de la función, llama a una función NavigationRail() que tome el objeto modifier como parámetro.
  3. Llama a una función NavigationRailItem() para cada objeto MenuItem en el objeto menuItems como lo hiciste con la función NavigationBarItem en la función BottomNavigationBar().

Implementa el panel lateral de navegación permanente

  1. Crea una función de componibilidad NavigationDrawer() que tome estos tres parámetros: un objeto menuItems configurado en un valor List<MenuItem>, un objeto modifier configurado en un valor Modifier y una función lambda onMenuSelected.
  2. En el cuerpo de la función, llama a una función Column() que tome el objeto modifier como parámetro.
  3. Llama a una función Row() para cada objeto MenuItem en el objeto menuItems.
  4. En el cuerpo de la función Row(), agrega las etiquetas icon y text como lo hiciste con la función NavigationBarItem en la función BottomNavigationBar().

Selecciona el componente de navegación correcto por clase de tamaño de ancho ventana

  1. Para recuperar la clase de ancho de ventana actual, llama a la función rememberWidthSizeClass() dentro de la función de componibilidad pasada al método setContent() del objeto ComposeView.
  2. Crea una rama condicional para elegir el componente de navegación basado en la clase de tamaño de ventana recuperada y llama a la seleccionada.
  3. Pasa un objeto Modifier a la función NavigationDrawer para especificar su ancho como un valor 256dp.

ActivityMain.kt

binding.navigation.setContent {
    MaterialTheme {
        when(rememberWidthSizeClass()){
            WidthSizeClass.COMPACT ->
                BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
                    navController.navigate(screen.destinationId)
                }
            WidthSizeClass.MEDIUM ->
                NavRail(menuItems = navigationMenuItems){ menuItem ->
                    navController.navigate(screen.destinationId)
                }
            WidthSizeClass.EXPANDED ->
                NavigationDrawer(
                    menuItems = navigationMenuItems,
                    modifier = Modifier.width(256.dp)
                ) { menuItem ->
                    navController.navigate(screen.destinationId)
                }
        }

    }
}

Coloca el componente de navegación en la posición correcta

Ahora, tu app puede elegir el componente de navegación correcto en función de la clase de ancho de ventana actual, pero los componentes seleccionados no se pueden colocar como desearías. Esto se debe a que el elemento ComposeView se coloca debajo del elemento FragmentViewContainer.

Actualiza el recurso de diseño alternativo para la clase MainActivity:

  1. Abre el archivo layout-sw600dp/activity_main.xml.
  2. Actualiza la restricción para distribuir los elementos ComposeView y FragmentContainer de forma horizontal.

layout-sw600dp/activity_main.xml

  <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment_content_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:defaultNavHost="true"
            app:layout_constraintLeft_toRightOf="@+id/top_navigation"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:navGraph="@navigation/nav_graph" />

        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/top_navigation"
            android:layout_width="wrap_content"
            android:layout_height="0dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:menu="@menu/top_navigation" />
    </androidx.constraintlayout.widget.ConstraintLayout>

Después de la modificación, la app elige el componente de navegación correcto en función de la clase de tamaño de ancho de ventana asociada. La primera captura de pantalla muestra la pantalla de la clase de tamaño de ancho de ventana media. La segunda captura de pantalla muestra la pantalla para la clase de tamaño de ancho de ventana expandida.

La app de Deportes muestra un riel de navegación, la lista de deportes y las noticias cuando se asocia con la clase de tamaño de ancho de ventana media. La app de Deportes muestra un panel lateral de navegación, la lista de deportes y las noticias en la pantalla principal cuando la app está asociada con la clase de tamaño de ancho de ventana expandida.

Figura 7: Pantallas para las clases de tamaño de ancho de ventana media y expandida

8. Felicitaciones

¡Felicitaciones! Completaste este codelab y aprendiste a agregar diseños adaptables a una app para Android basada en View con Compose. Al hacerlo, aprendiste sobre la clase SlidingPaneLayout, las clases de tamaño de ventana y la selección de componentes de navegación basada en clases de ancho de ventana.

Más información