Mejorando una app de Android responsiva

André Michel Pozos
11 min readMay 1, 2024

--

Éste artículo es la continuación de uno previo llamado “De qualifiers a Window size classes — (re) implementando una app responsiva en Android”. No es necesario leerlo para leer éste, ya que daré mencionaré desde dónde empezamos.

Si te sientes perdido en la implementación de una app responsiva en Android o te preguntas cómo pasar de qualifiers a window size classes, te recomiendo leer el artículo anterior.

En el artículo previo, reimplementamos una app responsiva originalmente construida con XML. Introdujimos Jetpack Compose junto con fragmentos, luego Jetpack Compose “puro” (sin fragmentos, todo dentro de una actividad) y finalmente concluimos con la implementación de las Window Size Classes para asegurarnos que nuestra app se adapte a distintas configuraciones de pantalla.

Y aún tras haber hecho todo esto, nuestra app puede mejorar aún más. Por lo tanto, en este artículo vamos a perfeccionar nuestra app responsiva al agregar 2 elementos que pueden mejorar la experiencia de usuario. Estos elementos son:

  1. Una mejora al diseño expandido basándonos en un diseño canónico.
  2. Diseño borde a borde (edge-to-edge).

Todo esto se hace con el objetivo de tener una app de Android responsiva que, además de funcional, tenga una apariencia atractiva ¡Comencemos!

Diseños canónicos (Canonical layouts). 🤓

Estos son diseños previamente definidos que han sido creados para pantallas grandes y probados específicamente en éstas. Han demostrado proveer tanto funcionalidad como una estética excelente. Contamos con 3 de ellos: list-detail, feed y supporting pane. De hecho el primero es el diseño que implementamos previamente para ésta app.

Como se menciona en la página de diseños canónicos de Android Developers, para las apps lista-detalle, cuando el dispositivo cambia de configuración (en este caso, cambia de tamaño), el estado de la app se debe conservar de la siguiente manera:

  1. Si pasamos de compact o medium a expanded con la lista mostrándose, la lista y un elemento placeholder se muestran juntos.
  2. Si pasamos de compact o medium a expanded con el detalle mostrándose, la lista y el detalle deberían mostrarse juntos. Y la lista indica el elemento seleccionado.
  3. Si pasamos de expanded a medium o compact con la lista y el detalle mostrándose, el detalle debería seguirse mostrando y la lista debería ocultarse.

Gracias a que usamos Jetpack Compose para hacer la UI, ahora podemos enfocarnos fácilmente en crear una adecuada user experience, en comparación al antiguo sistema de vistas.

De hecho nuestra app maneja cada uno de estos casos. Veamos la app con ayuda de un dispositivo virtual desktop emulado.

Regla 1°: De compact a expanded, con la lista mostrándose.

Demostración de la app. Primera regla en acción.
La lista se muestra junto a un placeholder.

Regla 2°: De compact a expanded, con el detalle mostrándose.

Demostración de la app. Segunda regla en acción.
El detalle se sigue mostrando.

Regla 3°: De expanded a compact, con lista y detalle mostrándose.

Demostración de la app. Tercera regla en acción.
El detalle se sigue mostrando.

Por lo que la app cumple con una excelente experiencia de usuario. ¡Implementamos las reglas del diseño lista-detalle incluso sin darnos cuenta!.

Aún así podría mejorar aún más, con la ayuda de Two Pane: un composable que forma parte de la famosa biblioteca Accompanist. Este composable nos permite tanto generar una interfaz de usuario dividida (ejemplo, nuestro lista-detalle) como hacer una app que reaccione a los pliegues de un dispositivo (fold-aware).

Basta con incluir la dependencia adaptive de Accompanist. La versión dependerá de la versión de Compose que estés utilizando:

// 0.30.1 porque usamos Compose 1.4.x
implementation("com.google.accompanist:accompanist-adaptive:0.30.1")

Si aún usas fragmentos y XML, te recomiendo revisar la biblioteca de SlidingPaneLayout. Puedes encontrar un codelab aquí o ver el mismo codelab en formato de video aquí. No hablaremos de ese componente en este artículo, debido a que nuestra app está centrada exclusivamente en Jetpack Compose. Además ese componente no es fold-aware, al menos al momento de escribir éste artículo.

Lo único que necesitamos es reemplazar el contenido del composable BeersExpandedContent. Pasamos de:

@Composable
fun BeersExpandedContent(...) {
Row(...) {
BeerListContent(..., modifier = Modifier.weight(1f))
Divider(...)
BeerDetailExpandedContent(..., modifier = Modifier.weight(3f))
}
}

A algo así:

@Composable
fun BeersExpandedContent(
displayFeatures: List<DisplayFeature>,
// ...
) {
TwoPane(
first = {
BeerListContent(...)
},
second = {
BeerDetailExpandedContent(...)
},
strategy = HorizontalTwoPaneStrategy(splitFraction = 1f / 3f),
displayFeatures = displayFeatures,
foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly,
modifier = modifier,
)
}

Todo el código está simplificado y como es habitual en Compose, se aprovecha al máximo la reutilización de composables.

TwoPane recibe los siguientes parámetros:

  1. first: el contenido de la izquierda (o derecha en right-to-left).
  2. second: el contenido de la derecha (o izquierda en right-to-left).
  3. strategy: la forma en cómo se van a mostrar los 2 contenidos anteriores. En nuestro caso, de forma horizontal y de manera que sea 1 a 3.
  4. displayFeatures: funciones que tiene la pantalla en la que se está mostrando la app. Ejemplo: los pliegues que tiene un dispositivo foldable.
  5. foldAwareConfiguration: la configuración que tendrá en cuanto a los dispositivos foldables. En nuestro caso, evitará dividir la pantalla cuando exista un pliegue vertical (foldable abierto en portrait).

Para el caso de las display features, podemos obtener estas funciones de la misma manera en la que obtenemos la window size class del dispositivo:

// Dentro de una activity o usando `LocalContext.current as Activity`
val displayFeatures = calculateDisplayFeatures(activity = this)

Y luego simplemente debemos “bajarlas” a través de la jerarquía de composables. E no vamos a trabajar con esas display features, sino será TwoPane quien lo hará. Cable aclarar que la clase DisplayFeature es la misma de la biblioteca de Window Manager, ya que es una dependencia transitiva de la biblioteca de Two Pane.

Tristemente carezco de un dispositivo foldable físico. Así que no me es posible experimentar con esta app “en vivo”. Si tu tienes uno, estaría excelente que probaras la app cambiando los parámetros displayFeatures del TwoPane.

Al implementar TwoPane nuestra app incluso luce un poco mejor. Así se ve en un foldable en modo landscape:

Izquierda: sin TwoPane. Derecha: con Two Pane. Emulador Resizable.

Y así se ve en una tablet:

Izquierda: sin TwoPane. Derecha: con Two Pane. Emulador de Pixel C.

Nota: usando el emulador Resizable en modo tablet, la app se ve mal, parece que no mantiene la proporción adecuada. Desconozco si esto se debe a Compose, a Accompanist (Two Pane) o al propio emulador (por algo es experimental aún). Lamentablemente no dispongo de una tablet real en la cuál probar la app. La app se ve de la siguiente manera:

Captura de pantalla del emulador resizable en modo tablet. La app no se ve como debería debido a un error desconocido.
Así con TwoPane en el emulador resizable en modo tablet.

Si no le damos un valor al parámetro foldAwareConfiguration, podría mostrarse el siguiente error de UI al usar la app en un foldable en modo landscape, debido a que el pliegue está pasando por en medio del dispositivo.

Captura de pantalla de la app en un foldable en modo landscape, sin establecer el parámetro foldAwareConfiguration del TwoPane.
Esta configuración es útil, pero no para nosotros que mostramos una UI lista-detalle.

Es por eso que este componente es muy útil para mostrar 2 contenidos en pantalla, no solamente para una app lista-detalle. Por ejemplo, dicho parámetro podría cambiarse a HorizontalFoldsOnly para aquellas apps que reproducen video, o para hacer una app que haga enojar a cierta compañía de videojuegos.

Diseño borde a borde (edge-to-edge) 😎

Vamos a añadir éste último elemento estético a nuestras pantallas, el cuál hará que nuestras pantallas en la app luzcan uniformes. Añadiremos esta mejora a ambas pantallas que usan Jetpack Compose. Es importante aclarar que tenemos 2 formas de hacer esto:

  1. Con dependencias actualizadas.
  2. Sin dependencias actualizadas.

Veremos ambas formas para considerar aquellos proyectos en los que actualizar las dependencias de la app es complicado.

Con dependencias actualizadas.

Únicamente requerimos actualizar las siguientes:

  • androidx.activity:activity-compose a la versión 1.8.0.
  • compileSdk a nivel 34.
  • Compose a la versión 1.6.0 (BoM 2024.01.00).

Ya que tenemos estas bibliotecas con estas versiones, haremos lo siguiente:

Después del super.onCreate del método onCreate de BeerComposeActivity, llamaremos a la función de extensión enableEdgeToEdge():

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

setContent {
/*Código Compose*/
}
}

Esta función hará que nuestras pantallas se dibujen por debajo de las barras del sistema: la barra de estado (status bar) y la barra de navegación (navigation bar), esta última aplica tanto cuando se navega con 3 botones como cuando se navega con gestos

En este momento, nuestras pantallas lucen así:

Diseño borde a borde en Material 2 sin ningún ajuste de paddings.
Todo amontonado.

Para corregir estos errores visuales, nuestras pantallas deben consumir unas cositas llamadas WindowInsets, las cuales no se consumen automáticamente. También tenemos que hacer un ajuste pequeño en la barra de estado.

Así que, para la TopAppBar añadiremos las WindowInsets de la barra de estado:

@Composable
fun BeersTopBar(
// Parámetros de nuestro Composable
) {
// ...
if (showBackIcon) {
TopAppBar(
windowInsets = WindowInsets.statusBars,
title = { Text(...) },
navigationIcon = {
// Botón de ícono
},
)
} else {
TopAppBar(
windowInsets = WindowInsets.statusBars,
title = { Text(...) },
)
}
}

Para configurar la barra de estado, debemos configurar la función enableEdgeToEdge() así:

// Para usar los colores del tema de Compose, este llamado debe hacerse
// adentro de `setContent {}`, al menos.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(
if (isSystemInDarkTheme()) {
Color.TRANSPARENT
} else {
MaterialTheme.colors.primary.toArgb()
}
)
)

Al momento de escribir este artículo, esta parece ser la mejor forma de ajustar la barra de estado con base en el tema que usamos nosotros, esto debido a que el método SystemBarStyle.auto(), el cuál sería intuitivo utilizar en esta situación, parece no funcionar adecuadamente.

Nuestras pantallas se ven ahora de la siguiente manera:

Diseño borde a borde en Material 2 con ajustes de paddings y la barra de estado.
Mucho mejor.

Sin embargo, tenemos un pequeño detalle con la Snackbar. Ésta sigue atrás de la barra de navegación, volviéndose imposible de usar con la navegación de 3 botones.

Snackbar encima de la barra de navegación. Navegación con gestos.
Snackbar detrás de la barra de navegación. Navegación con 3 botones.
Se vuelve imposible tocar la acción “Get from cache”.

Podemos solucionar este grave error visual añadimos el siguiente padding a la Snackbar:

snackbarHost = { SnackbarHost(..., Modifier.navigationBarsPadding()) }

SnackbarHost no acepta las WindowInsets. Para dichos composables se deben usar los Modifiers como el que incluimos aquí para que así se añada el padding requerido.

Con este padding la Snackbar ahora luce de la siguiente manera:

Snackbar arriba de la barra de navegación. Navegación con gestos.
Snackbar arriba de la barra de navegación. Navegación con 3 botones.
Ya es posible tocar la acción de la Snackbar. Y el resto de la UI seguirá por debajo de la barra de navegación.

También es posible hacer el diseño borde a borde en XML. Consulta éste enlace para saber cómo hacerlo. Una vez más: aquí nos enfocaremos en Jetpack Compose únicamente.

Ahora nuestra app luce de la siguiente manera:

Demo de la app en un Pixel 6 — Nivel de API 34.
Nuestra app ahora tiene un diseño de borde a borde. Pixel 6 — Nivel de API 34.

No es necesario que leas la siguiente parte si tienes ambas dependencias actualizadas. Eres libre de saltar hasta la demostración con otras configuraciones de pantalla o incluso hasta la conclusión del artículo.

Sin dependencias actualizadas.

Para implementar el diseño borde a borde, debemos añadir la siguiente línea, a falta de la función de extensión enableEdgeToEdge(). Debe añadirse en el mismo lugar donde hubiésemos añadido dicha función: en el onCreate de la actividad.

WindowCompat.setDecorFitsSystemWindows(window, false)

Así como agregar las siguientes líneas al tema de la app:

<!-- themes.xml y themes.xml (night) -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>

<!-- Solamente themes.xml -->
<item name="android:windowLightStatusBar">true</item>

Ya que no tenemos Jetpack Compose 1.6.0 no podemos usar el parámetro windowInsets en el TopAppBar. Debemos añadir el padding manualmente:

@Composable
fun BeersTopBar(
// Parámetros de nuestro Composable
) {
// ...
if (showBackIcon) {
TopAppBar(
title = { Text(...) },
navigationIcon = {
// Botón de ícono
},
modifier = Modifier.statusBarsPadding(),
)
} else {
TopAppBar(title = { /*...*/ }, modifier = Modifier.statusBarsPadding())
}
}

Recuerda agregar el padding a componente de SnackbarHost.

Dependiendo de nuestros requisitos de UI, a partir de aquí podemos implementar 3 approaches diferentes:

1.- Cambiar manualmente el color de la barra de estado para cada pantalla:

@Composable
fun BeersScreen(
isExpanded: Boolean,
displayFeatures: List<DisplayFeature>,
// Nuevo parámetro `window` proveniente de la actividad que utiliza a BeersScreen
window: Window,
viewModel: BeerListViewModel
) {
// Estados...

val controller = WindowCompat.getInsetsController(window, window.decorView)
window.statusBarColor = MaterialTheme.colors.primarySurface.toArgb()
// Según los colores que usemos, podemos cambiar a true o a false
controller.isAppearanceLightStatusBars = false

LaunchedEffect(state) {
// Manejo de eventos...
}
LaunchedEffect(key1 = currentDestination) {
// Manejo de eventos...
}

BeersLayout(...)
}

BeerScreen está contenido dentro de Surface. Recuerda que Surface provee la configuración de colores adecuada, así que el código mostrado debe estar siempre dentro de un Surface para que funcione.

primarySurface devuelve automáticamente el color primario si el tema es claro y el color surface si el tema es oscuro.

Con resultados mixtos

Diseño borde a borde en Material 2. Ejemplo cambiando los colores de la barra de estado manualmente.
Muy bonito el tema claro, pero en el tema oscuro la barra de estado no tiene el mismo color debido a la elevación de la TopAppBar.

2.- Reimplementar nuestra TopAppBar así:

@Composable
fun BeersTopBar(
selectedBeer: Beer?,
showBackIcon: Boolean,
onClickNavIcon: () -> Unit,
) {
Surface(
color = MaterialTheme.colors.primarySurface,
contentColor = contentColorFor(backgroundColor = MaterialTheme.colors.primarySurface),
elevation = AppBarDefaults.TopAppBarElevation,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.statusBarsPadding())
// Implementación original...
if (showBackIcon) {
TopAppBar(...)
} else {
TopAppBar(...)
}
}
}
}

También requiere cambiar la variable isAppearanceLightStatusBar como en el primer approach, mas no cambiar el statusBarColor.

Este approach, también, con resultados mixtos:

Diseño borde a borde en Material 2. Ejemplo reimplementado la TopAppBar.
Aquí se ve una delgada línea gris entre la TopAppBar y el Spacer. ¿O es mi imaginación?

3.- Cambiar el color en todas las pantallas de la app desde el tema. El resultado visual es igual al primer approach.

<!-- Un color para themes.xml y otro para themes.xml (night) -->
<item name="android:statusBarColor">@color/primary</item>
<!-- También puede aplicar un valor diferente para (night) -->
<item name="android:windowLightStatusBar">false</item>

Debido a todo esto, considera seriamente actualizar regularmente las dependencia de Jetpack Compose. Hacer esto no solo trae nuevas funciones, también resuelve problemas de rendimiento y le quita la anotación Experimental a varios componentes.

Demo: otras configuraciones de pantalla.

Por supuesto, no nos olvidemos de las demás configuraciones de pantalla. Veamos nuestra app en diferentes dispositivos:

Demo de la app en una Pixel C— Nivel de API 33.
Pixel C — Nivel de API 33.
Demo de la app en un dispositivo de escritrorio — Nivel de API 34.
Escritorio — Nivel de API 34.
Demo de la app en un dispositivo foldable — Nivel de API 34.
Foldable — Nivel de API 34.

Ya sea con las dependencias actualizadas o sin ellas, hemos logrado que nuestra interfaz de usuario sea consistente desde el inicio de la pantalla hasta el fin, vaya, de borde a borde.

En conclusión 🏁

Dos. Solamente dos mejoras han hecho que nuestra app luzca aún mejor de lo que ya era. Mejoras que, en términos técnicos, son fáciles de implementar. Pero recuerda: la forma ideal de implementar un diseño borde-a-borde y/o los diseños canónicos es usando Jetpack Compose

Pero no te limites: aunque definitivamente la experiencia es mejor en Compose, las 2 cosas se pueden implementar en XML y las/os usuarias/os de tu app agradecerán tener una interfaz de usuario consistente y suave, aunque sea inconscientemente.

Te dejo aquí el repositorio de la app en la que podrás ver el código fuente de la app, así como la UI en cada uno de los intentos que hicimos. Cada commit representa la implementación de una mejora.

¡Advertencia! punkapi.com cerrará el 1° de mayo del 2024. Si bien el código compila y funciona, ya no se mostrarán los datos de la API al entrar a la app.

Hemos finalizado esta serie de 2 artículos en la que hemos pasado de una antigua app en XML casi totalmente funcional a una app moderna en Jetpack Compose totalmente funcional y estéticamente bella. Espero que realmente consideres implementar lo visto en el anterior artículo como lo visto en este.

¡Nos vemos en el siguiente artículo! 😄

(Muchas gracias a las desarrolladoras Arheli Cortés e Itzel Galván por revisar y corregir este artículo antes de ser publicado).

--

--

André Michel Pozos

Desarrollador JavaScript y Android 👨🏾‍💻 siempre estudiando/trabajando 🤓 cooperación y comprensión 😄