Compila una app con un diseño adaptable

1. Introducción

En el codelab anterior, comenzaste a transformar la app de Reply de modo que resulte adaptable mediante el uso de clases de tamaño de ventana y la implementación de la navegación dinámica. Estas funciones son una base importante y el primer paso a la hora de crear apps para todos los tamaños de pantalla. Si no pudiste realizar el codelab Cómo compilar una app adaptable con navegación dinámica, te recomendamos que regreses y comiences allí.

En este codelab, compilarás un concepto que aprendiste a fin de implementar aún más el diseño adaptable en tu app. Este diseño es parte de los diseños canónicos, un conjunto de patrones que se usan comúnmente para las pantallas grandes. También aprenderás más herramientas y técnicas de prueba que te ayudarán a compilar rápidamente apps sólidas.

Requisitos previos

  • Haber completado el codelab Cómo compilar una app adaptable con navegación dinámica
  • Conocer la programación de Kotlin, incluidas las clases, las funciones y los condicionales
  • Conocer las clases ViewModel
  • Conocer las funciones Composable
  • Experiencia en la compilación de diseños con Jetpack Compose
  • Tener experiencia en la ejecución de apps en un dispositivo o emulador
  • Tener experiencia en el uso de la API de WindowSizeClass

Qué aprenderás

  • Cómo crear un diseño adaptable de patrón de vista de lista con Jetpack Compose
  • Cómo crear vistas previas para diferentes tamaños de pantalla
  • Cómo probar el código para varios tamaños de pantalla

Qué compilarás

  • Seguirás actualizando la app de Reply para adaptarla a todos los tamaños de pantalla.

La app terminada se verá de la siguiente manera:

Requisitos

  • Una computadora con acceso a Internet, un navegador web y Android Studio
  • Acceso a GitHub

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-reply-app.git
$ cd basic-android-kotlin-compose-training-reply-app
$ git checkout nav-update

Puedes explorar el código en el repositorio Reply de GitHub.

2. Vistas previas para diferentes tamaños de pantalla

Cómo crear vistas previas para diferentes tamaños de pantalla

En el codelab Cómo compilar una app adaptable con navegación dinámica, aprendiste a usar vistas previas componibles para tu proceso de desarrollo. En el caso de una app adaptable, te recomendamos que crees varias vistas previas para mostrar la app en diferentes tamaños de pantalla. Con las diferentes vistas previas, podrás ver los cambios en todos los tamaños de pantallas a la vez. Además, las vistas previas también sirven como documentación para otros desarrolladores que revisen tu código a fin de asegurarse de que tu app sea compatible con diferentes tamaños de pantalla.

Anteriormente, solo había una vista previa compatible con la pantalla compacta. A continuación, agregarás más.

Para agregar vistas previas de las pantallas medianas y expandidas, sigue estos pasos:

  1. Agrega una vista previa para pantallas medianas configurando un valor de widthDp mediano en el parámetro de anotación Preview y especificando el valor WindowWidthSizeClass.Medium como parámetro para el elemento ReplyApp componible.

MainActivity.kt

...
@Preview(showBackground = true, widthDp = 700)
@Composable
fun ReplyAppMediumPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(windowSize = WindowWidthSizeClass.Medium)
        }
    }
}
... 
  1. Agrega otra vista previa para pantallas expandidas configurando un valor de widthDp grande en el parámetro de anotación Preview y especificando el valor WindowWidthSizeClass.Expanded como parámetro del elemento componible ReplyApp.

MainActivity.kt

...
@Preview(showBackground = true, widthDp = 1000)
@Composable
fun ReplyAppExpandedPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(windowSize = WindowWidthSizeClass.Expanded)
        }
    }
}
... 
  1. Compila la vista previa para ver lo siguiente:

5577b1d0fe306e33.png

f624e771b76bbc2.png

3. Cómo implementar el diseño de contenido adaptable

Introducción a la vista de lista-detalles

Notarás que, en las pantallas expandidas, el contenido se ve estirado y no aprovecha bien el espacio disponible en pantalla.

56cfa13ef31d0b59.png

Puedes mejorar este diseño aplicando uno de los diseños canónicos. Estos diseños son composiciones de pantalla grande que funcionan como puntos de partida para el diseño y la implementación. Puedes usar los tres diseños disponibles a fin de guiarte en la organización de los elementos comunes en una app, la vista de lista, el panel de asistencia y el feed. Cada diseño considera casos de uso y componentes comunes para abordar las expectativas y las necesidades de los usuarios sobre la forma en que las apps se adaptan a diferentes tamaños de pantalla y puntos de interrupción.

En la app de Reply, implementemos la vista de lista y detalles, ya que es la mejor opción a la hora de explorar contenido y ver detalles rápidamente. Con un diseño de vista de lista-detalles, crearás otro panel junto a la pantalla de la lista de correos electrónicos para mostrar los detalles. Este diseño te permite usar la pantalla disponible a fin de mostrarle más información al usuario y hacer que tu app sea más productiva.

Cómo implementar una vista de lista-detalles

Para implementar una vista de lista-detalles para pantallas expandidas, completa los siguientes pasos:

  1. Para representar diferentes tipos de diseño de contenido, en WindowStateUtils.kt, crea una nueva clase Enum para diferentes tipos de contenido. Usa el valor LIST_AND_DETAIL para cuando la pantalla expandida esté en uso y LIST_ONLY en caso contrario.

WindowStateUtils.kt

...
enum class ReplyContentType {
    LIST_ONLY, LIST_AND_DETAIL
}
... 
  1. Declara la variable contentType en ReplyApp.kt y asigna el contentType apropiado para varios tamaños de ventana con el objetivo de determinar la selección del tipo de contenido adecuado, según el tamaño de la pantalla.

ReplyApp.kt

...
import com.example.reply.ui.utils.ReplyContentType
...

    val navigationType: ReplyNavigationType
    val contentType: ReplyContentType

    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
        WindowWidthSizeClass.Medium -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
        WindowWidthSizeClass.Expanded -> {
            ...
            contentType = ReplyContentType.LIST_AND_DETAIL
        }
        else -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
    }
... 

Luego, puedes usar el valor contentType para crear diferentes ramas para diseños en el elemento ReplyAppContent componible.

  1. En ReplyHomeScreen.kt, agrega contentType como parámetro al elemento ReplyHomeScreen componible.

ReplyHomeScreen.kt

...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    contentType: ReplyContentType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit,
    onEmailCardPressed: (Email) -> Unit,
    onDetailScreenBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {
...
  1. Pasa el valor contentType al elemento ReplyHomeScreen componible.

ReplyApp.kt

...
    ReplyHomeScreen(
        navigationType = navigationType,
        contentType = contentType,
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )

... 
  1. Agrega contentType como parámetro para el elemento ReplyAppContent componible.

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    contentType: ReplyContentType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit),
    onEmailCardPressed: (Email) -> Unit,
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
... 
  1. Pasa el valor contentType a los dos elementos ReplyAppContent componible.

ReplyHomeScreen.kt

...
            ReplyAppContent(
                navigationType = navigationType,
                contentType = contentType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                navigationType = navigationType,
                contentType = contentType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                isFullScreen = true,
                onBackButtonClicked = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
    }
... 

Mostremos la pantalla completa de lista y detalles cuando contentType sea LIST_AND_DETAIL o solo el contenido de lista de correo electrónico cuando contentType sea LIST_ONLY.

  1. En ReplyHomeScreen.kt, agrega una sentencia if/else en el elemento ReplyAppContent componible para mostrar el elemento ReplyListAndDetailContent cuando el valor contentType sea LIST_AND_DETAIL y mostrar el elemento ReplyListOnlyContent en la rama else.

ReplyHomeScreen.kt

...
        Column(
            modifier = modifier
                .fillMaxSize()
                .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            if (contentType == ReplyContentType.LIST_AND_DETAIL) {
                ReplyListAndDetailContent(
                    replyUiState = replyUiState,
                    onEmailCardPressed = onEmailCardPressed,
                    modifier = Modifier.weight(1f)
                )
            } else {
                ReplyListOnlyContent(
                    replyUiState = replyUiState,
                    onEmailCardPressed = onEmailCardPressed,
                    modifier = Modifier.weight(1f)
                        .padding(
                            horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
                        )
                )
            }
            AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
                ReplyBottomNavigationBar(
                    currentTab = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        }
... 
  1. Quita la condición replyUiState.isShowingHomepage para mostrar un panel lateral de navegación permanente, ya que el usuario no necesita navegar a la vista de detalles si usa la vista expandida.

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER) {
        PermanentNavigationDrawer(
            drawerContent = {
                PermanentDrawerSheet(Modifier.width(dimensionResource(R.dimen.drawer_width))) {
                    NavigationDrawerContent(
                        selectedDestination = replyUiState.currentMailbox,
                        onTabPressed = onTabPressed,
                        navigationItemContentList = navigationItemContentList,
                        modifier = Modifier
                            .wrapContentWidth()
                            .fillMaxHeight()
                            .background(MaterialTheme.colorScheme.inverseOnSurface)
                            .padding(dimensionResource(R.dimen.drawer_padding_content))
                    )
                }
            }
        ) {

... 
  1. Ejecuta la app en el modo tablet para ver la siguiente pantalla:

fe811a212feefea5.png

Cómo mejorar los elementos de la IU para la vista de lista-detalles

Actualmente, tu app muestra un panel de detalles en la pantalla principal de las pantallas expandidas.

e7c540e41fe1c3d.png

Sin embargo, la pantalla contiene elementos extraños, como el botón para ir hacia atrás, el encabezado del asunto y paddings adicionales, ya que se diseñaron para una pantalla de detalles independiente. A continuación, podrás mejorar esto mediante un simple ajuste.

A fin de mejorar la pantalla de detalles de la vista expandida, completa los siguientes pasos:

  1. En ReplyDetailsScreen.kt, agrega una variable isFullScreen como parámetro Boolean al elemento componible ReplyDetailsScreen.

Este agregado te permite diferenciar el elemento componible cuando lo usas de forma independiente y cuando lo usas dentro de la pantalla principal.

ReplyDetailsScreen.kt

...
@Composable
fun ReplyDetailsScreen(
    replyUiState: ReplyUiState,
    onBackPressed: () -> Unit,
    modifier: Modifier = Modifier,
    isFullScreen: Boolean = false
) {
... 
  1. Dentro del elemento ReplyDetailsScreen componible, une el elemento ReplyDetailsScreenTopBar con una sentencia if para que solo se muestre cuando la app esté en pantalla completa.

ReplyDetailsScreen.kt

...
    LazyColumn(
        modifier = modifier
            .fillMaxSize()
            .background(color = MaterialTheme.colorScheme.inverseOnSurface)
            .padding(top = dimensionResource(R.dimen.detail_card_list_padding_top))
    ) {
        item {
            if (isFullScreen) {
                ReplyDetailsScreenTopBar(
                    onBackPressed,
                    replyUiState,
                    Modifier
                        .fillMaxWidth()
                        .padding(bottom = dimensionResource(R.dimen.detail_topbar_padding_bottom))
                    )
                )
            }

... 

Ahora puedes agregar padding. El padding requerido para el elemento ReplyEmailDetailsCard componible difiere en función de si lo usas o no en pantalla completa. Cuando usas ReplyEmailDetailsCard con otros elementos componibles en la pantalla expandida, hay padding adicional de otros elementos.

  1. Pasa el valor isFullScreen al elemento ReplyEmailDetailsCard componible. Pasa un modificador con un padding horizontal de R.dimen.detail_card_outer_padding_horizontal si la pantalla está en modo de pantalla completa y, de lo contrario, pasa un modificador con un padding final de R.dimen.detail_card_outer_padding_horizontal.

ReplyDetailsScreen.kt

...
        item {
            if (isFullScreen) {
                ReplyDetailsScreenTopBar(
                    onBackPressed,
                    replyUiState,
                    Modifier
                        .fillMaxWidth()
                        .padding(bottom = dimensionResource(R.dimen.detail_topbar_padding_bottom))
                    )
                )
            }
            ReplyEmailDetailsCard(
                email = replyUiState.currentSelectedEmail,
                mailboxType = replyUiState.currentMailbox,
                isFullScreen = isFullScreen,
                modifier = if (isFullScreen) {
                    Modifier.padding(horizontal = dimensionResource(R.dimen.detail_card_outer_padding_horizontal))
                } else {
                    Modifier.padding(end = dimensionResource(R.dimen.detail_card_outer_padding_horizontal))
                }
            )
        }
... 
  1. Agrega un valor isFullScreen como parámetro al elemento ReplyEmailDetailsCard componible.

ReplyDetailsScreen.kt

...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReplyEmailDetailsCard(
    email: Email,
    mailboxType: MailboxType,
    modifier: Modifier = Modifier,
    isFullScreen: Boolean = false
) {
... 
  1. Dentro del elemento ReplyEmailDetailsCard componible, solo muestra el texto del asunto del correo electrónico cuando la app no esté en pantalla completa, dado que este diseño ya muestra el asunto del correo electrónico como encabezado. Si está en pantalla completa, agrega un separador con una altura de R.dimen.detail_content_padding_top.

ReplyDetailsScreen.kt

...
Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(dimensionResource(R.dimen.detail_card_inner_padding))
) {
    DetailsScreenHeader(
        email,
        Modifier.fillMaxWidth()
    )
    if (isFullScreen) {
        Spacer(modifier = Modifier.height(dimensionResource(R.dimen.detail_content_padding_top)))
    } else {
        Text(
            text = stringResource(email.subject),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.outline,
            modifier = Modifier.padding(
                top = dimensionResource(R.dimen.detail_content_padding_top),
                bottom = dimensionResource(R.dimen.detail_expanded_subject_body_spacing)
            ),
        )
    }
    Text(
        text = stringResource(email.body),
        style = MaterialTheme.typography.bodyLarge,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
    )
    DetailsScreenButtonBar(mailboxType, displayToast)
}

... 
  1. En ReplyHomeScreen.kt, dentro del elemento ReplyHomeScreen componible, pasa un valor true para el parámetro isFullScreen cuando crees el elemento ReplyDetailsScreen como independiente.

ReplyHomeScreen.kt

...
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                isFullScreen = true,
                onBackPressed = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
... 
  1. Ejecuta la app en el modo tablet y observa el siguiente diseño:

833b3986a71a0b67.png

Cómo ajustar el control de retroceso para la vista de lista-detalles

Con las pantallas expandidas, no necesitas navegar a ReplyDetailsScreen. En cambio, quieres que la app se cierre cuando el usuario seleccione el botón para ir hacia atrás. Por lo tanto, debemos ajustar el controlador de retroceso.

Para modificar el controlador de actividades anteriores, pasa la función activity.finish() como el parámetro onBackPressed del elemento ReplyDetailsScreen componible dentro del elemento ReplyListAndDetailContent.

ReplyHomeContent.kt

...
import android.app.Activity
import androidx.compose.ui.platform.LocalContext
...
        val activity = LocalContext.current as Activity
        ReplyDetailsScreen(
            replyUiState = replyUiState,
            modifier = Modifier.weight(1f),
            onBackPressed = { activity.finish() }
        )
... 

4. Cómo verificar el diseño para diferentes tamaños de pantalla

Lineamientos de calidad de las apps en pantallas grandes

A fin de crear una experiencia excelente y coherente para los usuarios de Android, es importante que compiles y pruebes tu app teniendo en cuenta la calidad. Puedes consultar los lineamientos de calidad de la app principal a fin de determinar cómo mejorarla.

Si quieres crear una app de excelente calidad para todos los factores de forma, consulta los lineamientos de calidad de las apps para pantallas grandes. La app también debe cumplir con los requisitos de Nivel 3 de compatibilidad con pantallas grandes.

Cómo probar de forma manual la preparación de tu app para pantallas grandes

Los lineamientos de calidad de las apps brindan recomendaciones y procedimientos para los dispositivos de prueba a fin de verificar su calidad. Veamos un ejemplo de prueba relevante para la app de Reply.

Descripción de la calidad de la app de pantalla grande sobre la configuración y continuidad.

El lineamiento de calidad de apps anterior requiere que la app conserve o restablezca su estado después de los cambios de configuración. También se proporcionan instrucciones para probar apps, como se muestra en la siguiente figura:

Los pasos de prueba de calidad de apps de pantalla grande sobre la configuración y continuidad.

A fin de probar la continuidad de la configuración de la app de Reply de forma manual, completa los siguientes pasos:

  1. Ejecuta la app de Reply en un dispositivo mediano o, si usas el emulador de tamaño variable, en modo plegable desplegado.
  2. Asegúrate de que la opción Giro automático del emulador esté activada.

5a1c3a4cb4fc0192.png

  1. Desplázate hacia abajo en la lista de direcciones de correo electrónico.

7ce0887b5b38a1f0.png

  1. Haz clic en una tarjeta de correo electrónico. Por ejemplo, abre el correo electrónico de Ali.

16d7ca9c17206bf8.png

  1. Rota el dispositivo para comprobar que el correo electrónico seleccionado siga siendo coherente con el que seleccionaste en orientación vertical. En este ejemplo, todavía se muestra un correo electrónico de Ali.

d078601f2cc50341.png

  1. Vuelve a la orientación vertical para comprobar que la app siga mostrando el mismo correo electrónico.

16d7ca9c17206bf8.png

5. Cómo agregar pruebas automatizadas para apps adaptables

Cómo configurar la prueba para el tamaño de pantalla compacta

En el codelab Cómo probar la app de Cupcake, aprendiste a crear pruebas de IU. Ahora, veamos cómo crear pruebas específicas para diferentes tamaños de pantalla.

En la app de Reply, usas diferentes elementos de navegación para diferentes tamaños de pantalla. Por ejemplo, esperas ver un panel lateral de navegación permanente cuando el usuario ve la pantalla expandida. Resulta útil crear pruebas para comprobar la existencia de varios elementos de navegación, como la barra de navegación inferior, el riel de navegación y el panel lateral de navegación para diferentes tamaños de pantalla.

Completa los siguientes pasos a los efectos de crear una prueba que verifique la existencia de un elemento de navegación inferior en una pantalla compacta:

  1. En el directorio de prueba, crea una nueva clase de Kotlin llamada ReplyAppTest.kt.
  2. En la clase ReplyAppTest, crea una regla de prueba con createAndroidComposeRule y pasa ComponentActivity como el parámetro de tipo. Se usa ComponentActivity para acceder a una actividad vacía en lugar de a MainActivity.

ReplyAppTest.kt

...
class ReplyAppTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()
...

Para diferenciar los elementos de navegación de las pantallas, agrega un elemento testTag en el elemento ReplyBottomNavigationBar componible.

  1. Define un recurso de cadenas para la navegación inferior.

strings.xml

...
<resources>
...
    <string name="navigation_bottom">Navigation Bottom</string>
...
</resources>
  1. Agrega el nombre de string como el argumento de testTag para el método testTag de Modifier en el elemento ReplyBottomNavigationBar componible.

ReplyHomeScreen.kt

...
val bottomNavigationContentDescription = stringResource(R.string.navigation_bottom)
ReplyBottomNavigationBar(
    ...
    modifier = Modifier
        .fillMaxWidth()
        .testTag(bottomNavigationContentDescription)
)
...
  1. En la clase ReplyAppTest, crea una función de prueba para probar una pantalla de tamaño compacto. Configura el contenido de composeTestRule con el elemento componible ReplyApp y pasa WindowWidthSizeClass.Compact como el argumento windowSize.

ReplyAppTest.kt

...
    @Test
    fun compactDevice_verifyUsingBottomNavigation() {
        // Set up compact window
        composeTestRule.setContent {
            ReplyApp(
                windowSize = WindowWidthSizeClass.Compact
            )
        }
    }
  1. Confirma que el elemento de navegación inferior existe con la etiqueta de prueba. Llama a la función de extensión onNodeWithTagForStringId en composeTestRule, pasa la string de navegación inferior y llama al método assertExists().

ReplyAppTest.kt

...
    @Test
    fun compactDevice_verifyUsingBottomNavigation() {
        // Set up compact window
        composeTestRule.setContent {
            ReplyApp(
                windowSize = WindowWidthSizeClass.Compact
            )
        }
        // Bottom navigation is displayed
        composeTestRule.onNodeWithTagForStringId(
            R.string.navigation_bottom
        ).assertExists()
    }
  1. Ejecuta la prueba y verifica que sea exitosa.

Cómo configurar la prueba para los tamaños de pantalla mediana y expandida

Ahora que creaste correctamente una prueba para la pantalla compacta, crearemos las pruebas correspondientes para las pantallas medianas y expandidas.

Completa los siguientes pasos a los efectos de crear pruebas que verifiquen la existencia de un riel de navegación y un panel lateral de navegación permanente para pantallas medianas y expandidas:

  1. Define un recurso de cadenas para el riel de navegación que se usará como etiqueta de prueba más adelante.

strings.xml

...
<resources>
...
    <string name="navigation_rail">Navigation Rail</string>
...
</resources>
  1. Pasa la cadena como la etiqueta de prueba a través del Modifier en el elemento PermanentNavigationDrawer componible.

ReplyHomeScreen.kt

...
    val navigationDrawerContentDescription = stringResource(R.string.navigation_drawer)
        PermanentNavigationDrawer(
...
modifier = Modifier.testTag(navigationDrawerContentDescription)
)
...
  1. Pasa la cadena como la etiqueta de prueba a través del Modifier en el elemento ReplyNavigationRail componible.

ReplyHomeScreen.kt

...
val navigationRailContentDescription = stringResource(R.string.navigation_rail)
ReplyNavigationRail(
    ...
    modifier = Modifier
        .testTag(navigationRailContentDescription)
)
...
  1. Agrega una prueba para verificar que exista un elemento de riel de navegación en las pantallas medianas.

ReplyAppTest.kt

...
@Test
fun mediumDevice_verifyUsingNavigationRail() {
    // Set up medium window
    composeTestRule.setContent {
        ReplyApp(
            windowSize = WindowWidthSizeClass.Medium
        )
    }
    // Navigation rail is displayed
    composeTestRule.onNodeWithTagForStringId(
        R.string.navigation_rail
    ).assertExists()
}
  1. Agrega una prueba para verificar que exista un elemento de panel lateral de navegación en las pantallas expandidas.

ReplyAppTest.kt

...
@Test
fun expandedDevice_verifyUsingNavigationDrawer() {
    // Set up expanded window
    composeTestRule.setContent {
        ReplyApp(
            windowSize = WindowWidthSizeClass.Expanded
        )
    }
    // Navigation drawer is displayed
    composeTestRule.onNodeWithTagForStringId(
        R.string.navigation_drawer
    ).assertExists()
}
  1. Usa un emulador de tablet o uno de tamaño variable en el modo Tablet a fin de ejecutar la prueba.
  2. Ejecuta todas las pruebas y verifica que resulten exitosas.

Cómo probar un cambio de configuración en una pantalla compacta

Un cambio de configuración es un caso común que ocurre en el ciclo de vida de tu app. Por ejemplo, cuando cambias la orientación de vertical a horizontal, se produce un cambio de configuración. Cuando eso sucede, es importante probar que tu app conserve su estado. A continuación, crearás pruebas que simulan un cambio de configuración a fin de probar que tu app conserva su estado en una pantalla compacta.

Para probar un cambio de configuración en la pantalla compacta, haz lo siguiente:

  1. En el directorio de prueba, crea una nueva clase de Kotlin llamada ReplyAppStateRestorationTest.kt.
  2. En la clase ReplyAppStateRestorationTest, crea una regla de prueba con createAndroidComposeRule y pasa ComponentActivity como el parámetro de tipo.

ReplyAppStateRestorationTest.kt

...
class ReplyAppStateRestorationTest {

    /**
     * Note: To access to an empty activity, the code uses ComponentActivity instead of
     * MainActivity.
     */
    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()
}
...
  1. Crea una función de prueba que verifique que un correo electrónico aún aparezca seleccionado en la pantalla compacta después de un cambio de configuración.

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    
}
...

Para probar un cambio de configuración, debes usar StateRestorationTester.

  1. Configura stateRestorationTester pasando composeTestRule como argumento a StateRestorationTester.
  2. Usa setContent() con el elemento componible ReplyApp y pasa el elemento WindowWidthSizeClass.Compact como el argumento windowSize.

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

}
...
  1. Verifica que se muestre un tercer correo electrónico en la app. Usa el método assertIsDisplayed() en composeTestRule, que busca el texto del tercer correo electrónico.

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()
}
...
  1. Haz clic en el asunto del correo electrónico para navegar a su pantalla de detalles. Usa el método performClick() para la navegación.

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Open detailed page
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()
}
...
  1. Verifica que el tercer correo electrónico aparezca en la pantalla de detalles. Comprueba la existencia del botón para ir hacia atrás con el objetivo de confirmar que la app se encuentra en la pantalla de detalles y verifica que se muestre el texto del tercer correo electrónico.

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Open detailed page
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that it shows the detailed screen for the correct email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
}
...
  1. Simula un cambio de configuración con stateRestorationTester.emulateSavedInstanceStateRestore().

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Verify that it shows the detailed screen for the correct email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertExists()

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()
}
...
  1. Verifica una vez más que el tercer correo electrónico aparezca en la pantalla de detalles. Comprueba la existencia del botón para ir hacia atrás con el objetivo de confirmar que la app se encuentra en la pantalla de detalles y verifica que se muestre el texto del tercer correo electrónico.

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Open detailed page
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that it shows the detailed screen for the correct email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertExists()

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()

    // Verify that it still shows the detailed screen for the same email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertExists()
}

...
  1. Ejecuta la prueba con un emulador de teléfono o uno de tamaño variable en el modo de Teléfono.
  2. Verifica que la prueba resulte exitosa.

Cómo probar un cambio de configuración en la pantalla expandida

Prueba un cambio de configuración en la pantalla expandida simulando un cambio de configuración y pasando la WindowWidthSizeClass apropiada. Para ello, completa los siguientes pasos:

  1. Crea una función de prueba que verifique que un correo electrónico aún aparezca seleccionado en la pantalla de detalles después de un cambio de configuración.

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {

}
...

Para probar un cambio de configuración, debes usar StateRestorationTester.

  1. Configura stateRestorationTester pasando composeTestRule como argumento a StateRestorationTester.
  2. Usa setContent() con el elemento componible ReplyApp y pasa el elemento WindowWidthSizeClass.Expanded como el argumento windowSize.

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }
}
...
  1. Verifica que se muestre un tercer correo electrónico en la app. Usa el método assertIsDisplayed() en composeTestRule, que busca el texto del tercer correo electrónico.

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()
}
...
  1. Selecciona el tercer correo electrónico en la pantalla de detalles. Usa el método performClick() para seleccionar el correo electrónico.

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Select third email
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()
    ...
}

...
  1. Verifica que la pantalla de detalles muestre el tercer correo electrónico usando testTag en la pantalla de detalles y buscando texto en sus elementos secundarios. Este enfoque garantiza que el texto se encuentre en la sección de detalles y no en la lista de direcciones de correo electrónico.

ReplyAppStateRestorationTest.kt

...

@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Select third email
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that third email is displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )
...
}

...
  1. Simula un cambio de configuración mediante stateRestorationTester.emulateSavedInstanceStateRestore().

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Verify that third email is displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()
    ...
}
...
  1. Vuelve a verificar que la pantalla de detalles muestre el tercer correo electrónico después de un cambio de configuración.

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Select third email
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that third email is displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()

    // Verify that third email is still displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )
}
...
  1. Ejecuta la prueba con un emulador de tablet o uno de tamaño variable en el modo Tablet.
  2. Verifica que la prueba resulte exitosa.

Cómo usar las anotaciones para probar el grupo según diferentes tamaños de pantalla

A partir de las pruebas anteriores, es posible que notes que algunas pruebas fallan cuando se ejecutan en dispositivos con un tamaño de pantalla incompatible. Si bien puedes ejecutar la prueba una por una con un dispositivo adecuado, es posible que este enfoque no escale cuando tengas muchos casos de prueba.

Para resolver este problema, puedes crear anotaciones para indicar los tamaños de pantalla en los que se puede ejecutar la prueba y configurar la prueba anotada en los dispositivos adecuados.

Para ejecutar una prueba según el tamaño de la pantalla, completa los siguientes pasos:

  1. En el directorio de prueba, crea TestAnnotations.kt, que contiene tres clases de anotación: TestCompactWidth, TestMediumWidth y TestExpandedWidth.

TestAnnotations.kt

...
annotation class TestCompactWidth
annotation class TestMediumWidth
annotation class TestExpandedWidth
...
  1. Usa las anotaciones en las funciones de prueba para pruebas compactas colocando la anotación TestCompactWidth después de la anotación de prueba para una prueba compacta en ReplyAppTest y ReplyAppStateRestorationTest.

ReplyAppTest.kt

...
    @Test
    @TestCompactWidth
    fun compactDevice_verifyUsingBottomNavigation() {
...

ReplyAppStateRestorationTest.kt

...
    @Test
    @TestCompactWidth
    fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {

...
  1. Usa las anotaciones en las funciones de prueba para pruebas de nivel intermedio colocando la anotación TestMediumWidth después de la anotación de prueba para una prueba de nivel intermedio en ReplyAppTest.

ReplyAppTest.kt

...
    @Test
    @TestMediumWidth
    fun mediumDevice_verifyUsingNavigationRail() {
...
  1. Usa las anotaciones en las funciones de prueba para pruebas expandidas colocando la anotación TestExpandedWidth después de la anotación de prueba para una prueba expandida en ReplyAppTest y ReplyAppStateRestorationTest.

ReplyAppTest.kt

...
    @Test
    @TestExpandedWidth
    fun expandedDevice_verifyUsingNavigationDrawer() {
...

ReplyAppStateRestorationTest.kt

...
    @Test
    @TestExpandedWidth
    fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
...

Para garantizar el éxito, configura la prueba de modo que solo se ejecuten pruebas con anotaciones TestCompactWidth.

  1. En Android Studio, selecciona Run > Edit Configurations... 7be537f5faa1a61a.png
  2. Cambia el nombre de la prueba a Pruebas compactas y ejecuta la prueba All in Package.

f70b74bc2e6674f1.png

  1. Haz clic en los tres puntos () a la derecha del campo Instrumentation arguments.
  2. Haz clic en el botón de signo más (+) y agrega los parámetros adicionales: annotation en el valor com.example.reply.test.TestCompactWidth.

cf1ef9b80a1df8aa.png

  1. Ejecuta las pruebas con un emulador compacto.
  2. Verifica que solo se ejecutaron pruebas compactas.

204ed40031f8615a.png

  1. Repite los pasos para las pantallas medianas y expandidas.

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

Para descargar el código del codelab terminado, usa el siguiente comando de Git:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-reply-app.git

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, puedes hacerlo en GitHub.

7. Conclusión

¡Felicitaciones! Implementaste un diseño adaptable para que la app de Reply se adapte a todos los tamaños de pantalla. También aprendiste a acelerar tu desarrollo usando vistas previas y manteniendo la calidad de tu app mediante varios métodos de prueba.

No olvides compartir tu trabajo en redes sociales con el hashtag #AndroidBasics.

Más información