En el anterior artículo, Introducción a Jetpack Compose, vimos una primera aproximación a las herramientas que Android ofrece para la creación de interfaces. En este nuevo post, ahondaremos en las propiedades de los nuevos componentes y diseñaremos una pantalla de ejemplo utilizando lo aprendido.

Layouts: clases Column y Row

A la hora de diseñar una pantalla, hasta ahora nos hemos acostumbrado a jugar con los layouts que Android proporcionaba, como los Linear o los Relative o, más recientemente. los ConstraintLayout.

De manera similar, Compose proporciona una colección de diseños listos para usar que nos ayudan a organizar los elementos de la IU y facilitan la definición de diseños propios más especializados. Vamos a ver algunos de ellos con algunos ejemplos:

Column

Organiza los elementos en sentido vertical en la pantalla, como una columna.

Column(Modifier.background(color = Color.White)) {
   Text(text = "Nombre de la cosa")
   Text(text = "Información y tal")
}

El ejemplo anterior muestra cómo quedarían dos textos organizados en columna. Si te fijas, verás que hemos añadido un fondo blanco (para dar algo de legibilidad), pero no te preocupes que hablaremos sobre ese objeto Modifier más adelante.

Row

Organiza los elementos en sentido horizontal en la pantalla, como una fila.

Row(
   Modifier
       .background(color = Color.White),
   verticalAlignment = Alignment.CenterVertically) {
       Image(
           painter = painterResource(id = R.drawable.ic_launcher_background),
           contentDescription = "avatar",
           contentScale = ContentScale.Inside,
           modifier = Modifier
               .size(64.dp)
               .clip(CircleShape)
       )
   Column(verticalArrangement = Arrangement.SpaceEvenly) {
       Text(text = "Nombre de la cosa")
       Text(text = "Información y tal")
   }
}

Vemos que se ha dispuesto la imagen y a continuación la columna que contiene los dos textos, pero si te fijas también hemos indicado cómo deben organizarse los elementos dentro de la fila y de la columna:

Ejemplo de Arrangement.
Ejemplo de Arrangement.

Box

Por último, ya hemos visto los “sustitutos” del LinearLayout, así que nos falta por introducir al nuevo RelativeLayout para poder colocar un elemento sobre otro: Box. Además, Box admite la configuración de la alineación específica de los elementos que contiene.

Box {
   Image(...)
   Image(
       painter = painterResource(id = R.drawable.ic_launcher_background),
       contentDescription = "icon",
       colorFilter = ColorFilter.tint(Color.Red),
       modifier = Modifier
           .align(Alignment.BottomEnd)
           .size(16.dp)
           .clip(CircleShape)
   )
}

En este caso, podemos ver que la segunda imagen lleva un objeto de tipo Modifier que aparte de otras modificaciones visuales, define un parámetro align que indica a su contenedor padre cómo debe situarse este componente dentro de él.

Jugando con estos tres componentes de diseño podremos definir casi cualquier tipo de interfaz que queramos. Ahora que ya sabemos cómo organizar las vistas, vamos a personalizar un poco más el componente que estamos diseñando con la siguiente sección: Modifiers.

La clase Modifier

Para poder empezar a modificar una vista, primero tenemos que echar un vistazo a la clase Modifier. En su forma más básica, este objeto (en realidad el companion object de la interfaz del mismo nombre) solo implementa una serie de operaciones para poder combinar operaciones entre sí, pero además ofrece un montón de funciones de extensión sobre el mismo para ofrecer muchas funcionalidades distintas.

Para utilizarlo, podemos llamar a las funciones que nos provee a través de su API de tipo Builder, pudiendo así concatenar operaciones hasta llegar al resultado deseado. Un detalle muy importante en la instanciación de la clase Modifier es que el orden altera el producto, como veremos más adelante con un ejemplo.

Esta clase ofrece varios modificadores que podemos agrupar en distintos tipos:

Vamos a ver algunos de estos modificadores y prácticas recomendadas continuando con la interfaz que creamos antes hasta darle un aspecto mejor. En primer lugar, vamos a empezar aplicando padding y estableciendo el tamaño de los contenedores. Un detalle importante es que ya no existen los margins, siendo sustituidos por padding, ya que se obtiene el mismo resultado.

val paddingDefault = 16.dp
Row(
   Modifier
       .padding(all = paddingDefault)
       .background(color = Color.White),
   verticalAlignment = Alignment.CenterVertically
) { ... }

En primer lugar, vemos que hemos definido una variable paddingDefault y para ello utilizamos la función .dp para referirnos al tamaño. También especificamos que lo queremos para todos sus lados con el parámetro all, aunque podríamos fijar cualquiera de ellos con bottom/start/end/top.

Por otro lado, vemos que al aplicar primero el padding y después un background, parte de la vista queda sin pintar, esto es porque el orden de los modificadores importa por lo que el orden correcto sería primero el background y luego el padding, obteniendo así un resultado mejor.

val paddingDefault = 8.dp
Row(
   Modifier
       .background(color = Color.White)
       .padding(all = paddingDefault),
   verticalAlignment = Alignment.CenterVertically
)

Nuestro componente empieza a ser bastante grande. Vamos a empezar a dividirlo en funciones más cómodas aplicando uno de los principios básicos de Compose: la ¿componibilidad? (lo siento, es lo que hay).

@Composable
@Preview
fun ClassicCard() {
   Row(
       Modifier
           .background(color = Color.White)
           .padding(all = paddingDefault)
           .fillMaxWidth(),
       verticalAlignment = Alignment.CenterVertically
   ) {
       ImageProfile()
       ContentProfile()
   }
}

@Composable
fun ContentProfile() {
   Column() {
       Text(text = "Nombre de la cosa", fontSize = 16.sp)
       Text(text = "Información y tal", fontSize = 12.sp)
   }
}

@Composable
fun ImageProfile() {
   Box(Modifier.padding(end = paddingDefault)) {
       Image(
           painter = painterResource(id = R.drawable.ic_launcher_background),
           contentDescription = "avatar",
           contentScale = ContentScale.Inside,
           modifier = Modifier
               .size(64.dp)
               .clip(CircleShape)
       )
       Image(
           painter = painterResource(id = R.drawable.ic_launcher_background),
           contentDescription = "icon",
           colorFilter = ColorFilter.tint(Color.Red),
           modifier = Modifier
               .align(Alignment.BottomEnd)
               .size(16.dp)
               .clip(CircleShape)
       )
   }
}

Según las guías de diseño de componentes de Compose, “todos los nuevos componentes deben aceptar un parámetro Modifier llamado modifier como primer argumento opcional en su constructora”.

Esto permite que nuestros componentes puedan recibir comportamiento (en forma de Modifiers) desde fuera de su propio código, haciendo sean más reutilizables y modulares. De momento no vamos a ponerlo en práctica para este ejemplo, pero la constructora de nuestros componentes quedaría así:

@Composable
fun ContentProfile(modifier: Modifier = Modifier) {
    Column(
       modifier
           .fillMaxSize()
           .padding(start = paddingDefault),
       verticalArrangement = Arrangement.SpaceEvenly
    ) {...}

Ahora que ya lo tenemos más dividido, vamos a aplicar una serie de cambios hasta obtener la clásica interfaz de tarjeta de perfil y comentaremos algunos de los cambios más importantes.

En primer lugar, vamos a hacer que el texto del contenido reparta su espacio uniformemente. Podríamos hacer lo normal (alineado arriba) estableciendo el parámetro verticalAlignment del contenedor padre (Row) a Alignment.Top, pero entonces no hablaríamos de uno de los “gremlins” de Compose: los tamaños.

Por defecto, los contenedores como Column o Row se ajustarán al tamaño de su contenido, como el WRAP_SIZE de toda la vida y se alinearán en el centro, así que vamos a cambiar el tamaño del contenedor de ContentProfile para que ocupe todo el espacio del padre.

Intuitivamente, utilizaríamos Modifier.fillMaxHeight(), pero dado que su padre no tiene un tamaño definido, hará que ocupe toda la altura de pantalla. Para evitar esto, definimos el tamaño del padre mediante .height(intrinsicSize = IntrinsicSize.MAX), especificando así que debe medir en altura tanto como la altura mínima del más alto de sus hijos, y mantenemos la columna a máximo de altura.

fun ClassicCard() {
   Row(
       Modifier
           .background(color = Color.White)
           .padding(all = paddingDefault)
           .fillMaxWidth()
           .height(intrinsicSize = IntrinsicSize.Max),
       verticalAlignment = Alignment.CenterVertically
   ) {...}
}

@Composable
fun ContentProfile() {
   Column(
       Modifier
           .fillMaxSize()
           .padding(start = paddingDefault),
       verticalArrangement = Arrangement.SpaceEvenly
   ) {...}
}

Ya podemos decirle a la columna que distribuya sus elementos de manera uniforme en su vertical, resultando en un diseño más agradable y menos apelotonado.

Ahora vamos a “mejorar” el aspecto de los textos definiendo su tipografía, color, peso, etc.

@Composable
fun ContentProfile() {
   val headerTextStyle = TextStyle(
       fontSize = 16.sp,
       fontWeight = FontWeight.Black,
       fontFamily = FontFamily.Monospace,
       shadow = Shadow(
           color = Color.Red,
           offset = Offset(5.0f, 10.0f),
           blurRadius = 3f
       )
   )

   Column(
       Modifier
           .fillMaxSize()
           .padding(start = paddingDefault),
       verticalArrangement = Arrangement.SpaceEvenly
   ) {
       Text(
           text = "Nombre de la cosa",
           style = headerTextStyle
       )
       Text(
           text = "Información y tal",
           fontSize = 12.sp,
           color = Color.Gray,
       )
       Text(
           text = "Hora de la última conexión",
           modifier = Modifier.fillMaxWidth(),
           fontSize = 8.sp,
           fontStyle = FontStyle.Italic,
           textAlign = TextAlign.End
       )
   }
}

Como puedes ver en el código de ejemplo, todo es bastante intuitivo, aunque merece la pena aclarar algunos puntos:

  1. Podemos definir individualmente valores de nuestro texto0 (fontSize, fontStyle…) o crear objetos TextStyle como headerTextStyle para reutilizarlos. Estas definiciones pueden estar alojados en un archivo independiente.
  2. Al igual que utilizamos la función .dp para los tamaños de vista, se utilizará .sp para los de texto.
  3. El color utilizado para la sombra es un color definido por nosotros en la clase Color.kt dentro de la carpeta theme creada por defecto para cada proyecto Compose. Dentro pueden definirse objetos de tipo Color:
val customColor = Color(0xFFBB86FC)

Por último vamos a aplicar algunos retoques a la sección de la imagen y al contenedor principal, para añadir un borde, elevación, etc. Veamos el código final y el resultado:

@Composable
@Preview
fun ClassicCard() {
   Row(
       Modifier
           .clip(RoundedCornerShape(8.dp))
           .shadow(8.dp)
           .background(color = Color.White)
           .padding(all = paddingDefault)
           .fillMaxWidth()
           .height(intrinsicSize = IntrinsicSize.Max),
       verticalAlignment = Alignment.CenterVertically
   ) {
       ImageProfile()
       ContentProfile()
   }
}

@Composable
fun ContentProfile(modifier: Modifier = Modifier) {
   val headerTextStyle = TextStyle(
       fontSize = 16.sp,
       fontWeight = FontWeight.Black,
       fontFamily = FontFamily.Monospace,
       shadow = Shadow(
           color = customColor,
           offset = Offset(5.0f, 10.0f),
           blurRadius = 3f
       )
   )

   Column(
       modifier
           .fillMaxSize()
           .padding(start = paddingDefault),
       verticalArrangement = Arrangement.SpaceEvenly
   ) {
       Text(
           text = "Nombre de la cosa",
           style = headerTextStyle
       )
       Text(
           text = "Información y tal",
           fontSize = 12.sp,
           color = Color.Gray,
       )
       Text(
           text = "Hora de la última conexión",
           modifier = modifier.fillMaxWidth(),
           fontSize = 8.sp,
           fontStyle = FontStyle.Italic,
           textAlign = TextAlign.End
       )
   }
}

@Composable
fun ImageProfile() {
   Box(Modifier.padding(end = paddingDefault)) {
       Image(
           painter = painterResource(id = R.drawable.ic_launcher_background),
           contentDescription = "avatar",
           contentScale = ContentScale.Inside,
           modifier = Modifier
               .size(64.dp)
               .clip(CircleShape)
               .border(2.dp, Color.Black, CircleShape)
       )
       Image(
           painter = painterResource(id = android.R.drawable.ic_menu_view),
           contentDescription = "icon",
           colorFilter = ColorFilter.tint(Color.DarkGray),
           modifier = Modifier
               .border(2.dp, Color.Black)
               .align(Alignment.BottomEnd)
               .size(16.dp)
               .background(Color.White)
       )
   }
}

Algunos detalles de esta implementación:

Conclusiones

Con esto hemos visto los principios básicos de diseño de interfaces con Compose, pero habrás visto que en nuestros ejemplos no hemos utilizado ningún dato dinámico, ya que eso cae dentro de la gestión de estados y la recomposición y se tratará en otro post (ya que da para mucho).

De momento, nos vale para poder ir jugando con las vistas y diseños y ver todas las opciones de personalización que ofrecen la clase Modifier y los contenedores por defecto que provee Compose.

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

Estamos comprometidos.

Tecnología, personas e impacto positivo.