Actualmente, disponemos en el mercado de una amplia variedad de dispositivos en cuanto a su pantalla. Además de las pulgadas, también tenemos que tener en cuenta la relación de aspecto, por ejemplo para móviles, 16:9, 18:9, 19:9, etc. así como algunas particularidades como pueden ser los dispositivos plegables. Por tanto, al adaptar nuestra aplicación a todos estos escenarios, el usuario tendrá una experiencia más enriquecedora.

En este artículo, veremos las posibles estrategias a seguir para que nuestra aplicación pueda adaptarse a los distintos tipos de pantalla.

Gracias a la reactividad de Compose, vamos a poder “escuchar” continuamente los cambios de la pantalla y mostrar una disposición de elementos distinta en base al espacio del que dispongamos. Estos cambios pueden ser: rotaciones, pantallas divididas, plegar/desplegar el dispositivo, etc.

Conceptos básicos: dimensiones

Antes de entrar en materia, debemos conocer la diferencia entre las unidades a medir en una pantalla:

Las unidades dp y sp son una solución para que nuestra app no quede desproporcionada en determinados tamaños. Por ejemplo, si tenemos un componente que debe ocupar 8x3 píxeles, usando la unidad de “píxel”, el componente se verá a distintos tamaños en función de las pulgadas del dispositivo:

Componente que se verán a distintos tamaños en función de las pulgadas del dispositivo

En cambio, si usamos la unidad de densidad de pantalla (dp), el componente de tamaño 8x3 queda de la siguiente manera:

Componente de tamaño 8x3

Sobre esta idea de densidad de pantalla construiremos las bases para que nuestra app pueda adaptarse a cualquier tipo de dispositivo.

Formatos de pantalla antes de Compose

Antes de llegar Compose, adaptábamos las pantallas de nuestra aplicación en XML haciendo uso de varios elementos:

Según el tamaño del dispositivo, se usaban los siguientes sufijos en las carpetas de recursos:

Sufijo recurso Píxeles por pulgada Tamaño Resolución
ldpi 120 Pequeño 240x320
mdpi 160 Medio 320x480
hdpi 240 Alto 480x800
xhdpi 320 Extra Alto 720x1280
xxhdpi 480 Extra Extra Alto 1080X1920
xxxhdpi 640 Extra Extra Extra Alto 1440X2560

De esta forma, si nuestra app soportaba, por ejemplo, pantallas en resolución media y alta, y además contemplaba la orientación vertical y horizontal, debíamos crear un layout para cada variante, quedando el árbol de directorios así:

Árbol de directorios

Tipos de pantalla en la actualidad

Hasta ahora, hemos tenido en cuenta el tamaño de la pantalla y su orientación (vertical y horizontal). Sin embargo, han surgido otros formatos, debido a la inmensa variedad de relaciones de aspectos que los fabricantes de móviles y tablets han sacado.

De esta forma, se establecen 3 rangos a tener en cuenta, tanto en altura como en anchura:

3 rangos a tener en cuenta, tanto en altura como en anchura:

De esta forma, los dispositivos encajan de la siguiente forma según el rango:

Rango en dp Dispositivo Window Size Class
0-599 Móvil en vertical Compact
600-839 Móvil plegable en vertical
Tablet en vertical
Medium
840+ Móvil en horizontal
Móvil plegable en horizontal
Tablet en horizontal
Expanded

Y ahora… ¿cómo aplico esta estrategia a mi aplicación?

Una vez comprendida la variedad de tamaños que hay, podemos implementarlo en nuestra aplicación de 2 formas:

Independientemente de qué forma elijamos, la idea es simple: tenemos que propagar esta configuración desde la raíz de nuestra interfaz, para que cualquier composable se alimente de ella y se “pinte” de la forma correcta.

Usando la librería de Material 3

Si usamos la librería de Material 3, “material3-window-size-class”, tendremos gran parte del trabajo hecho. Simplemente, tenemos que incluir la librería en nuestro proyecto y colocar la escucha del estado de las dimensiones del dispositivo, en la raíz de nuestros compostables.

Incluir la librería:

implementation("androidx.compose.material3:material3-window-size-class")

Obtenemos la configuración, mediante la función calculateWindowSizeClass, y la propagamos a toda nuestra aplicación para que cualquier pantalla o componente sepa cómo debe pintarse según las dimensiones del dispositivo:

Material3WindowSizeTheme {
    val windowSize = calculateWindowSizeClass(this)
    val width = windowSize.widthSizeClass
    val height = windowSize.heightSizeClass
    when (width) {
        WindowWidthSizeClass.Compact -> "Screen width is Compact"
        WindowWidthSizeClass.Medium -> "Screen width is Medium"
        WindowWidthSizeClass.Expanded -> "Screen width is Expanded"
    }
    when (height) {
        WindowHeightSizeClass.Compact -> "Screen height is Compact"
        WindowHeightSizeClass.Medium -> "Screen height is Medium"
        WindowHeightSizeClass.Expanded -> "Screen height is Expanded"
    }
    Surface(
        modifier = Modifier.fillMaxSize(),
        color = MaterialTheme.colorScheme.background
    ) {
        // Our app will use this configuration to change his size
        MyApp(windowSize)
    }
}

Nota: esta librería está todavía en fase alpha, por lo que tendremos que anotar todas las clases involucradas con la siguiente anotación:

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)

Creando nuestro sistema de clases

Si por alguna razón la librería de Material no cubre nuestras necesidades, o no nos interesa tener un código en fase alpha, la flexibilidad de Compose nos permite construir nuestras propias clases para gestionar los cambios de pantalla.

En primer lugar, creamos los distintos tipos de tamaños que vamos a cubrir. Siguiendo las recomendaciones de Google, tendremos 3, aunque podemos reducirlo a 2 o incluso crear más si es necesario:

enum class WindowType { 
    Compact,
    Medium,
    Expanded
}

Ahora creamos la clase que va a tener la configuración del dispositivo, contemplando la altura y anchura:

data class WindowSize(
    val widthType: WindowType,
    val heightType: WindowType
)

Por último, necesitamos crear una función composable, rememberWindowSize, que sea reactivo a los cambios del dispositivo:

@Composable
fun rememberWindowSize(): WindowSize {
    val configuration = LocalConfiguration.current
    val screenWidth by remember(key1 = configuration) {
        mutableStateOf(configuration.screenWidthDp)
    }
    val screenHeight by remember(key1 = configuration) {
        mutableStateOf(configuration.screenHeightDp)
    }
    return WindowSize(
        width = getScreenWidth(screenWidth),
        height = getScreenHeight(screenHeight)
    )
}

fun getScreenWidth(width: Int): WindowType = when {
    width < 600 -> WindowType.Compact
    width < 840 -> WindowType.Medium
    else -> WindowType.Expanded
}

fun getScreenHeight(height: Int): WindowType = when {
    height < 480 -> WindowType.Compact
    height < 900 -> WindowType.Medium
    else -> WindowType.Expanded
}

Esta función rememberWindowSize devuelve un objeto con la configuración actual del dispositivo. Como puede apreciarse, este objeto se compone de 2 parámetros, screenWidth y screenHeight, los cuales son reactivos ante cambios en las dimensiones del dispositivo.

De esta forma, si el dispositivo es rotado, la altura y anchura de la pantalla cambiará, forzando a propagar una nueva instancia de la clase WindowSize, que será usada en la recomposición total de la aplicación.

Las funciones auxiliares getScreenWidth y getScreenHeight son las que determinan dónde están los límites para diferenciar entre los 3 tamaños contemplados.

Finalmente, podemos usar todo esto en la raíz de nuestra aplicación:

Material3WindowSizeTheme {
    val windowSize = rememberWindowSize()
    when (windowSize.widthType) {
        WindowType.Compact -> "Screen width is Compact"
        WindowType.Medium -> "Screen width is Medium"
        WindowType.Expanded -> "Screen width is Expanded"
    }
    when (windowSize.heightType) {
        WindowType.Compact -> "Screen height is Compact"
        WindowType.Medium -> "Screen height is Medium"
        WindowType.Expanded -> "Screen height is Expanded"
    }
    Surface(
        modifier = Modifier.fillMaxSize(),
        color = MaterialTheme.colorScheme.background
    ) {
        // Our app will use this configuration to change his size
        MyApp(windowSize)
    }
}

Como se puede apreciar, es bastante similar a usar la librería de Material 3, pero somos nosotros quienes controlamos los límites para determinar las dimensiones usadas.

Un ejemplo práctico

La estrategia es simple, cada composable tiene que ser responsable de la disposición y tamaño de sus elementos, así como propagar a los mismos la información de las dimensiones del dispositivo, para que estos puedan tomar ciertas decisiones internamente.

Imaginemos que tenemos un escenario en el que tenemos que mostrar un listado de elementos.

Nuestra aplicación va a contemplar el ancho de la pantalla, para poder mostrar un listado, si la pantalla es compacta, o un grid (con columnas variables) cuando es más ancha, además de incrementar los tamaños de las fuentes y los elementos.

Para este ejemplo, vamos a mostrar una lista de 20 elementos, que tiene una imagen, un título y una descripción:

    // List to display items
    val loremList = (0..19).map { index ->
        Item(
            icon = R.drawable.lorem,
            name = "Lorem Ipsum $index",
            description = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged."
        )
    }

La pantalla que va a mostrar ese listado, Screen1, recibe por parámetro la configuración del dispositivo detectada previamente. Con esta podemos tomar ciertas decisiones para mostrar un listado o un grid.

@Composable
fun Screen1(windowSize: WindowSize) {
    
    when (windowSize.widthType) {
        WindowType.Compact -> {
            LazyColumn {
                items(count = loremList.size) { index ->
                    MyItem(
                        item = loremList[index],
                        windowSize = windowSize
                    )
                }
            }
        }

        WindowType.Expanded, WindowType.Medium -> {
            val maxColumns = if (windowSize.widthType == WindowType.Medium) {
                2
            } else {
                3
            }
            LazyVerticalGrid(
                columns = GridCells.Fixed(count = maxColumns)
            ) {
                items(count = loremList.size) { index ->
                    MyItem(
                        item = loremList[index],
                        windowSize = windowSize
                    )
                }
            }
        }
    }
}

A su vez, el composable MyItem, recibe la configuración (windowSize), para que internamente pueda adaptarse a una pantalla compacta, mediana o expandida:

@Composable
private fun MyItem(item: Item, windowSize: WindowSize) {

    val textStyleHeadline: TextStyle
    val textStyleSupporting: TextStyle
    val itemHeight: Dp
    val iconSize: Dp

    when (windowSize.widthType) {
        WindowType.Compact -> {
            textStyleHeadline = TextStyle(
                fontWeight = FontWeight.Bold,
                fontSize = 20.sp
            )
            textStyleSupporting = TextStyle(
                textAlign = TextAlign.Justify,
                fontSize = 16.sp
            )
            itemHeight = 120.dp
            iconSize = 80.dp
        }

        WindowType.Medium, WindowType.Expanded -> {
            textStyleHeadline = TextStyle(
                fontWeight = FontWeight.Bold,
                fontSize = 26.sp
            )
            textStyleSupporting = TextStyle(
                textAlign = TextAlign.Justify,
                fontSize = 20.sp
            )
            itemHeight = 200.dp
            iconSize = 120.dp
        }
    }

    ListItem(leadingContent = {
        Box(modifier = Modifier.fillMaxHeight()) {
            Icon(
                painter = painterResource(id = item.icon),
                contentDescription = "",
                modifier = Modifier.size(iconSize),
                tint = Color.Unspecified
            )
        }
    }, modifier = Modifier.height(itemHeight), headlineText = {
        Text(
            text = item.name, style = textStyleHeadline
        )
    }, supportingText = {
        Text(
            text = item.description,
            style = textStyleSupporting,
            overflow = TextOverflow.Ellipsis
        )
    })
}

Simplemente, para cada dimensión, damos unos estilos de texto y alguna propiedad más que pueda resultar necesaria cuando la pantalla es más ancha.

Previsualización de distintos formatos de pantalla

Por último, para poder hacernos una idea lo más ajustada posible de la previsualización de nuestra pantalla, podemos usar el parámetro device para indicar el tamaño del dispositivo:

@Preview(
    showBackground = true,
    device = NEXUS_5X,
    showSystemUi = true
)
@Composable
fun Screen1CompactPreview() {
    Material3WindowSizeTheme {
        Screen1(WindowSize(WindowType.Compact, WindowType.Compact))
    }
}

@Preview(
    showBackground = true,
    device = TABLET,
    showSystemUi = true
)
@Composable
fun Screen1ExpandedPreview() {
    Material3WindowSizeTheme {
        Screen1(WindowSize(WindowType.Expanded, WindowType.Expanded))
    }
}
Previsualización de la pantalla

Resultado final

Ya solo queda lanzar nuestra app en un móvil o en una tablet para ver el resultado final:

Resultado final en móvil
Resultado final en tablet

Conclusiones

Con esto, hemos visto la principal estrategia a la hora de abordar los tamaños de varias pantallas en Compose. Simplemente, debemos tomar las decisiones oportunas en cada pantalla o componente a la hora de mostrarse, así como la propagación de la configuración del dispositivo a componentes internos.

Referencias

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete