De qualifiers a Window size classes — (re) implementando una app responsiva en Android
Durante la primera mitad del año 2021, estuve postulándome a varias startups mexicanas y extranjeras con el fin de entrar a alguna de ellas como desarrollador Android. Resolví varios challenges con el objetivo de demostrar mis habilidades como desarrollador. Una de estas startups me puso un reto de 2 partes; sin embargo, quiero que nos enfoquemos en la segunda parte del reto, que constaba de lo siguiente:
Desarrolla una app de Android master-detail que consulte Punk API. Debe usar RecyclerView, SwipeRefreshLayout, LiveData, Navigation Component y corrutinas. Debe tener infinite scrolling como Twitter, cargar las imágenes y los datos de forma asíncrona, implementar MVVM y una base de datos para consultar offline los datos.
Básicamente una app que muestra una lista de cervezas de una REST API junto con su detalle. Al final pude implementar la app cumpliendo todos los requisitos de la prueba. Y se veía así:
Tras revisitar esta app después de un tiempo, note que podría mejorar muchísimo. Incluso comenzaré con una confesión: yo no sabía cómo hacer una app master-detail, (o list-detail, en adelante) así que la UX no es tan buena que digamos. Sabía cómo cumplir con todos los demás requisitos y sabía que Android Studio ofrecía una plantilla para ese estilo de app, pero nunca la había implementado por mi cuenta.
Cómo era el único recurso que conocía terminé usando aquella plantilla para poder implementar todo lo demás.
Más de 2 años han pasado ya desde aquel entonces. Actualmente tenemos 2 tecnologías que han llamado mi atención desde el momento en el que las ví: Jetpack Compose y las Window Size Class.
Así que, tras ver varios videos del Google I/O y de los Android Dev Summit, he decidido actualizar esa app con base en lo que he aprendido de dichos videos. Por lo que, en este artículo explicaré cómo reimplementar ésta app list-detail, ahora usando esas 2 tecnologías.
Comenzaré explicando la estructura actual de la app, luego actualizaré las dependencias clave de la app, posteriormente haré un primer intento usando fragments junto con Jetpack Compose y después, un segundo intento usando 100% Jetpack Compose y las Window Size Classes.
Para este artículo es necesario conocer MVVM y Jetpack Compose. No es necesario tener conocimientos avanzados en éste último. Basta con saber cómo se implementa un diseño y tener una idea de cómo fluyen los datos (Unidirectional Data Flow), con eso es suficiente.
Estructura de la app 🏢
Pequeño disclaimer: en su momento no tenía muy claros los principios SOLID, ni entendía varios patrones de diseño. Así que, si bien el código no es un desastre, hay mucho que mejoraría si la implementase de nuevo al día de hoy. El código que hice ya no se parece al código que haría hoy.
Revisemos primero la arquitectura de la app. Comenzando por la capa de datos y la capa de presentación hasta el viewModel (sí, no hay capa de dominio salvo por los modelos).
Tenemos un viewModel que utiliza un repositorio. Éste se encarga de traer las cervezas de una API y luego añadirlas a la base de datos para poder consultarlas offline. El mecanismo de sincronización es primero consultar el servicio y si no está disponible, consultar la base de datos.
Mientras que la capa de presentación (solo UI) se ve así:
Ésta era la plantilla de Android Studio para aplicaciones list-detail. Para todo dispositivo cuyo ancho mínimo sea 900 dp, se infla un archivo con el mismo nombre pero ubicado en la carpeta w900dp, haciendo uso de los resource qualifiers.
En dicho diagrama podemos ver la estructura del XML que se muestra en pantalla. En rojo, los elementos que se adicionan para beer_list.xml (w900dp)
. Los otros 2 elementos, si bien se comparten, en realidad tienen las mismas vistas XML. Es decir, hay un RecyclerView y un TextView en cada archivo beer_list.xml
.
Mostrando el detalle de la vista en la misma pantalla
Dentro del adapter de ambos RecyclerView
(insisto, hoy lo haría diferente), se hace la siguiente lógica:
- Si la app está en modo tablet, se instancia
BeerDetailFragment
y dicho fragmento se incrusta en elFrameLayout
debeer_list.xml (w900dp)
. - De otra forma, estamos en modo teléfono. Se navega hacia
BeerDetailFragment
.
Y para saber si estamos en modo tablet, hacemos algo no muy recomendado hoy en día: hacer una lógica “isTablet”. Esto significa que, abusando de los resource qualifiers, limitamos nuestra app cuando descubrimos que un recurso con un qualifier está siendo usado.
En nuestro caso, usamos findViewById
para detectar si se encuentra el FrameLayout
. Si es el caso, sabemos que se está en modo tablet, porque se está ocupando la versión w900dp de beer_list.xml
.
Eso fue la estructura del código original. Así era la plantilla en Android Studio. Hoy en día también hay una plantilla, pero funciona de diferente manera.
Preparando el entorno 🏗️
Habiendo explicado cómo está estructurada la app, comenzaré a migrarla a Compose. Lo primero es actualizar las dependencias importantes de ésta app, ya que desde 2021 no se habían actualizado:
- Android Gradle Plugin: 4.1.2 -> 7.2.2
- Gradle: 6.5 -> 7.3.3
compileSdk
&targetSdk
: 30 -> 33- Kotlin: 1.4.21 -> 1.8.0
En cuanto a Compose, consideré añadir la lista de materiales (Bill of Materials - BoM) versión 2023.06.01, la cuál incluye la versión 1.4.3 de todas las bibliotecas de Jetpack Compose.
Podría meter AGP 8.3, Gradle 8.3, Kotlin 1.9.0 y la BoM 2023.09.01 (al menos al momento de escribir este artículo). Sin embargo, consideremos que en un escenario real, una app no puede actualizar de la nada sus dependencias a nivel MAJOR (y mucho menos dar varios saltos de versiones a la vez).
Con estas dependencias, se puede usar Jetpack Compose sin problemas y sin saltar demasiado entre versiones. El punto es tener una buena experiencia en el desarrollo sin limitarnos demasiado y sin migrar todo de golpe, tal y como debería ser en una app real.
Intento #1: Fragments + Compose + Qualifiers 😓
A partir de ahora, ya podemos introducir código Compose. Y el subtítulo lo dice todo: ésta es la forma más dolorosa de hacer una app responsiva. Sin embargo, es mi método preferido para implementar Jetpack Compose en una app rápidamente y sin tanto dolor de cabeza (ejem, ejem).
Los ejemplos de código están simplificados, ya que me enfocaré en crear la UI y la parte responsive. Para ver el código fuente completo, consulta el repositorio de la app que se encuentra al final del artículo.
Para esto, hay que empezar a pensar en Compose. Hagamos composables lo suficientemente pequeños y genéricos como para reutilizarlos más tarde pero lo suficientemente grandes y específicos como para que no sean un simple wrapper de un componente de Material Design.
Comenzando con un elemento de UI que sirva para mostrar una cerveza en la lista de cervezas.
@Composable
fun BeerItem(
beer: Beer,
isSelected: Boolean,
modifier: Modifier = Modifier,
onClickBeer: (Beer) -> Unit,
) {
// Código del composable
}
El cual se ve así:
Es claro que éste elemento no se parece al que usamos en la app original. Esto es intencionado, debido a 2 razones:
- La prueba técnica no estaba limitada en cuanto al diseño de la UI, salvo el hecho de que debía ser list-detail.
- Gracias a Compose, implementar mejoras sobre un elemento UI se vuelve increíblemente rápido.
Podemos seguir con el composable que se encargará de mostrar la lista de cervezas.
@Composable
fun BeerList(
listState: LazyListState, // Estado del LazyColumn que se usa en el composable
selectedBeer: Beer?,
beers: List<Beer>,
onClickBeer: (Beer) -> Unit,
onScrolledToEnd: () -> Unit,
modifier: Modifier = Modifier,
) {
// LazyColumn que emite los `BeerItem`s
}
Que se ve así:
Lo que en XML son 4 archivos mínimo (item.xml
+ Adapter.kt
+ Fragment.kt
+ fragment.xml
), en Compose se vuelven 2, y eso separando al item de la lista.
Hasta aquí, tenemos el equivalente en Compose de beer_list.xml
para teléfonos. Falta la función swipe-to-refresh. Aquí hay algunas cosas que destacar:
- Dicha funcionalidad actúa sobre toda la app. Esto es un error: debería funcionar solamente sobre la lista. Lo descubrí haciendo este artículo al leer a detalle los requisitos del challenge original.
- Combinar
SwipeRefreshLayout
conComposeView
para poder incrustar nuestro composable (o inclusoLazyColumn
únicamente) no funciona adecuadamente. Parece que ambos desplazamientos se desconocen mutuamente.
Por lo que, me veo obligado a no reutilizar nada de beer_list.xml
, ninguno de los 2 archivos. Así como implementar PullRefresh
de Compose Material (a partir de Compose 1.3.0.).
En mi caso, me atreví a crear un composable adicional para abstraer el comportamiento de PullRefresh
, utilizando el concepto de slots en Compose. Esto puede ser opcional, dependiendo de cada caso.
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun BeerListSwipeRefresh(
isRefreshing: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(...) {
content()
PullRefreshIndicator(...)
}
}
¡Recuerda! Para que funcione PullRefresh, el composable que esté “arriba” del composable PullRefreshIndicator
debe tener un scroll. En nuestro caso, content
debe tener ese desplazamiento. Si el contenido “no necesita desplazarse”, simplemente agrega una Column, así:
Column(Modifier.verticalScroll(rememberScrollState())) {
// Contenido
}
Para que la lista tenga la funcionalidad swipe-to-refresh
, creamos otro composable:
@Composable
fun BeerListContent(
state: BeerListState,
onClickBeer: (Beer) -> Unit,
onScrolledToEnd: () -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
BeerListSwipeRefresh(...) {
// BeerList(...) y otros composables
}
}
Como mejora, añadí una clase BeerListState
que se encarga de definir los estados de la pantalla. Originalmente, utilicé una clase Result
que era excesivamente genérica, por lo que no servía para representar los estados de la pantalla fácilmente.
Este composable BeerListContent
se encarga no solo de mostrar la lista, sino también los mensajes de error y de lista vacía, es decir, el TextView
que comparten ambas pantallas. Éstas pantallas se ven así:
Ahora, hasta aquí tenemos lista la pantalla de la lista para usarse. Para la pantalla de detalle, podríamos generar un composable o simplemente reutilizar el XML del fragmento. Hagamos lo primero:
@Composable
fun BeerDetailContent(
beer: Beer,
modifier: Modifier = Modifier,
) {
// Pantalla de detalle
}
Y se ve así:
Con esto, hemos finalizado la UI de las pantallas. Comencemos a hacerlas responsivas.
Si deseas saltarte la parte de los fragmentos y ver cómo usar las Window Size Classes, puedes avanzar a la sección del Intento #2.
Juntando los composables para la vista responsiva
Es aquí donde vamos a crear el último composable encargado de mostrar el contenido. Este composable será el que se muestre en el fragmento en el que se mostraba la lista. Recuerda: el código está simplificado.
@Composable
fun BeerComposeListFragmentScreen(
isTablet: Boolean,
viewModel: BeerListViewModel,
onNavigatedToBeer: (Beer) -> Unit,
onError: () -> Unit,
) {
BeerListContent(
onClickBeer = {
if (isTablet) {
viewModel.onSelectedBeer(it)
} else {
onNavigatedToBeer(it)
}
},
/* Otros parámetros */
)
if (isTablet) {
Divider(...) // No es necesario, pero queda bien
selectedBeer?.let {
// Instanciamos el fragmento igual que en la lógica original
// Y lo mostramos con AndroidViewBinding
AndroidViewBinding(...)
} ?: run {
// Composable con un mensaje de error
}
}
Puntos importantes:
- El parámetro
isTablet
nos ayuda a mostrar uno u otro composable, así como hacer una acción u otra al seleccionar una cerveza. Simple. - Al seleccionar una cerveza, si es tablet, simplemente se actualizará el estado en el viewModel con la cerveza seleccionada. Si no es tablet, se navegará a la pantalla de detalle. Recordemos: utilizando el componente de navegación de Android, como en la app original.
- Si no es una tablet, mostraremos simplemente la lista. Si sí lo es, mostraremos un divider y el contenido de tablets (que bien podría estar en otro composable aparte).
- Si no hay ninguna cerveza seleccionada, se mostrará un mensaje a “pantalla completa” que funciona como placeholder. Si sí hay, se mostrará el fragmento de detalle con ayuda de
AndroidViewBinding
. selectedBeer
viene del estado de la app, observado (con LiveData) a través del ViewModel.
Los composables, en forma de diagrama, se ven así:
Y es así que llegamos a la siguiente pantalla:
Finalmente, para saber si es tablet o no, haremos una lógica similar a la app original: tendremos 2 archivos booleans.xml
, uno adentro de values
y otro adentro de values-w900dp
. Definiéndolos así:
<!--- dentro de values/booleans.xml --->
<resources>
<bool name="is_tablet">true</bool>
</resources>
<!--- dentro de values-w900dp/booleans.xml --->
<resources>
<bool name="is_tablet">true</bool>
</resources>
Finalmente obteniendo el valor con:
val isTablet = resources.getBoolean(R.bool.is_tablet)
Y pasándolo a BeerComposeListFragmentScreen
. Con esto, hemos terminado de implementar la misma vista que ya teníamos, pero ahora con Jetpack Compose y fragments.
La verdad es que hay mucho margen de mejora aún. La user experience mejoró (y fue fácil mejorarla), pero podríamos incluir más. El intento llega hasta aquí por simple conveniencia. La verdad, yo suelo decir lo siguiente:
Una vez que haces UI con Jetpack Compose, no hay vuelta atrás. Nunca querrás volver a los XML y las Custom Views.
Intento #2: Compose + Window Size Classes 😮
Ahora hagamos una app 100% Compose. Nuestro objetivo es deshacernos de todos los fragments, así como de las activities innecesarias.
Debido a que nuestros composables se mostrarán dentro de una actividad completamente vacía, es necesario crear algunos composables adicionales:
Primero, el que se encargará del nuevo gráfo de navegación. Al ya no tener los fragments, es necesario implementar Navigation Compose:
@Composable
fun BeersContent(
state: BeerListState,
navController: NavHostController,
onClickBeer: (Beer?) -> Unit,
onScrolledToEnd: () -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
// ...
NavHost(
navController = navController,
startDestination = BeerDestinations.LIST,
modifier = modifier,
) {
composable(BeerDestinations.LIST) {
BeerListContent(...)
}
composable(BeerDestinations.BEER_DETAIL_EMPTY) {
if (state is BeerListState.Success) {
state.selectedBeer?.let {
BeerDetailContent(beer = it)
}
}
}
}
}
Donde BeerDestinations
es un object
con las diferentes rutas navegables. Aquí es donde sucede la magia de Compose: ninguno de los 2 composables son nuevos, simplemente reutilizamos los que ya teníamos. Tanto BeerListContent
como BeerDetailContent
son los mismos composables que hemos creado anteriormente.
La navegación en sí depende del estado de la pantalla (
BeerListState
), esto con el fin de reutilizar la claseBeers
la cuál esParcelable
. Esto es con fines didácticos, ya que en realidad deberíamos crear otro viewModel y pasar el ID de la cerverza. O limitarnos a los fragmentos, en caso de requerirParcelables
.
Continuamos en segundo lugar con el que mostrará el contenido en tablets:
@Composable
fun BeersExpandedContent(
state: BeerListState,
onClickBeer: (Beer) -> Unit,
onScrolledToEnd: () -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
BeerListContent(...)
Divider(...)
BeerDetailExpandedContent(...)
}
}
Donde BeerDetailExpandedContent
es un composable que contiene el detalle de la cerveza o un mensaje que indica que se debe seleccionar una cerveza. Este composable también podría ser opcional.
@Composable
fun BeerDetailExpandedContent(state: BeerListState, modifier: Modifier = Modifier) {
(state as? BeerListState.Success)?.selectedBeer?.let {
BeerDetailContent(...)
} ?: run {
Box(...) {
ScreenMessage(...)
}
}
}
Tener composables pequeños nos permite reutilizarlos, aislar UI y evitar tener composables gigantes que generen una carga cognitiva muy grande al inspeccionarlos.
En tercer lugar, el que contendrá a ambos elementos y mostrará la TopAppBar
(ActionBar
), así como el que representará a toda la pantalla (sin estado):
@Composable
fun BeersLayout(
state: BeerListState,
isExpanded: Boolean,
// ...
navController: NavHostController,
onClickBeer: (Beer?) -> Unit,
onScrolledToEnd: () -> Unit,
onRefresh: () -> Unit,
) {
Scaffold(
topBar = {
val beer = (state as? BeerListState.Success)?.selectedBeer
BeersTopBar(...)
},
// ...
) { paddingValues ->
if (isExpanded) {
BeersExpandedContent(...)
} else {
BeersContent(...)
}
}
}
Él cuál luce así:
Y para finalizar los composables extras, el que se ejecutará en la Activity
y se encargá de observar el estado y proveer del NavController
.
@Composable
fun BeersScreen(
isExpanded: Boolean,
viewModel: BeerListViewModel
) {
val state by viewModel.state.observeAsState(initial = BeerListState.Initial)
val navController = rememberNavController()
// ...
BeersLayout(
state = state,
isExpanded = isExpanded,
// ...
navController = navController,
onClickBeer = viewModel::onSelectedBeer,
onScrolledToEnd = viewModel::loadMoreBeers,
onRefresh = viewModel::getBeers,
)
}
Es muy útil tener nuestros composables de pantalla divididos en
*Layout
y*Screen
. Esto permite previsualizarlos en Android Studio, así como hacerles pruebas fácilmente.
El diagrama de los nuevos composables se ve así:
Ahora, he reemplazado isTablet
por isExpanded
. Hay varias razones, y es que está lógica de isTablet
tiene algunos problemas:
En primera, estamos limitando la app a partir de una medida de pantalla arbitraria: ¿por qué 900dp? En segunda, deberíamos estar aprovechando lo máximo de la pantalla en vez de segregar por tipo de dispositivo. Pensemos: ¿hay pantallas con 900dp que no sean tablets?
Es aquí donde entra la nueva forma de hacer UI responsiva utilizando 100% Jetpack Compose: las window size classes. Con ayuda de estas clases, podemos definir qué mostrar en pantalla con base en 3 anchos predefinidos: compact, medium y expanded. Gracias a estos tamaños definidos (también disponibles para la altura del dispositivo), podemos enfocarnos en 3 cosas:
- Dejar de limitar a los usuarios por su dispositivo. Ya no tenemos “usuarios de teléfonos” y “usuarios de tablet”.
- Dar soporte a configuraciones diferentes adicionales a estos 2 modos. Ejemplo: foldables y apps en modo ventana (como ChromeOS).
- Ofrecer una UI que aproveche todo el espacio disponible, dando así una buena experiencia de usuario independientemente de la pantalla del dispositivo.
Implementando las window size classes
Comenzando con añadir la dependencia a Gradle. No hay que dejarse llevar por el nombre: dichas clases no requieren implementar Material You (Material Design 3/Material 3).
implementation("androidx.compose.material3:material3-window-size-class")
Gracias a que usamos la lista de materiales de Compose, no necesitamos especificar la versión (la que se incluye es la 1.1.1 en este caso).
Ahora podríamos reemplazar la forma de saber si “es tablet” o no de la siguiente:
// Cambiar:
val isTablet = resources.getBoolean(R.bool.is_tablet)
// Por: (dentro de una activity o usando `LocalContext.current as Activity`
val windowSizeClass = calculateWindowSizeClass(activity = this)
val isExpanded = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded
La biblioteca también permite hacer operaciones con las clases (ejemplo:
windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium
). En este caso, he hecho la comparación conExpanded
porque el diseño lista-detalle solo aplica para dispositivos expanded.
De esta manera, vemos que la app se ve así en tablets:
Y así en teléfonos:
¡Pero hay más! Así se ve en foldables:
Dato curioso: así se veía antes en foldables en modo horizontal, usando resource qualifiers:
Y así en modo ventana (freeform):
Por lo que ahora vemos una mejora significativa en la UI, así como un mejor soporte a diferentes tipos de dispositivos, incluso configuraciones que los usuarios establezcan: podemos ofrecerles una experiencia adaptable a usuarios de Samsung Dex, Chrome OS y futuros usuarios de escritorio.
En conclusión 🏁
El mejor momento para añadir compatibilidad con tablets fue en 2011. El segundo mejor momento es hoy. El mejor momento para añadir compatibilidad con foldables fue en 2019. El segundo mejor momento es hoy. Y la misma analogía aplica para Chromebooks.
Si tú/tu equipo/tu empresa mantienen una app legacy hecha con XML, es posible tener una app adaptable a estos dispositivos también. Pero, considera seriamente migrarla gradualmente a Jetpack Compose. Definitivamente vale la pena y ahora sabes que añadir compatibilidad a otros dispositivos usando Jetpack Compose es increíblemente rápido e intutivo.
Con toda la diversidad de dispositivos que ha salido, los usuarios “que no usan teléfonos” aumentan todos los días. Ya no son 10 personas por ahí distribuidas, ya no son casos aislados, ya son una base importante de usuarios en cada app.
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 un intento.
¡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.
Aún hay cosas que podríamos añadir a la app para ofrecer una mejor experiencia de usuario. Y es por eso que te recomiendo continuar con éste artículo en el que veremos cosas como un diseño “de borde a borde” (edge-to-edge), Material You y mucho más.
¡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.)