Mejorando una app de Android responsiva
É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:
- Una mejora al diseño expandido basándonos en un diseño canónico.
- 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:
- Si pasamos de compact o medium a expanded con la lista mostrándose, la lista y un elemento placeholder se muestran juntos.
- 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.
- 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.
Regla 2°: De compact a expanded, con el detalle mostrándose.
Regla 3°: De expanded a compact, con lista y detalle mostrándose.
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:
first
: el contenido de la izquierda (o derecha en right-to-left).second
: el contenido de la derecha (o izquierda en right-to-left).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.displayFeatures
: funciones que tiene la pantalla en la que se está mostrando la app. Ejemplo: los pliegues que tiene un dispositivo foldable.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
delTwoPane
.
Al implementar TwoPane
nuestra app incluso luce un poco mejor. Así se ve en un foldable en modo landscape:
Y así se ve en una tablet:
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:
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.
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:
- Con dependencias actualizadas.
- 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ón1.8.0
.compileSdk
a nivel 34.- Compose a la versión
1.6.0
(BoM2024.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í:
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:
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.
Podemos solucionar este grave error visual añadimos el siguiente padding a la Snackbar:
snackbarHost = { SnackbarHost(..., Modifier.navigationBarsPadding()) }
SnackbarHost
no acepta lasWindowInsets
. Para dichos composables se deben usar losModifiers
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:
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:
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 deSurface
. Recuerda queSurface
provee la configuración de colores adecuada, así que el código mostrado debe estar siempre dentro de unSurface
para que funcione.
primarySurface
devuelve automáticamente el color primario si el tema es claro y el colorsurface
si el tema es oscuro.
Con resultados mixtos
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:
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:
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).