Aplicar Temas En Compose

En este tutorial verás cómo aplicar temas en Compose, a fin de estilizar los elementos gráficos de tus aplicaciones Android. Esto significa, personalizar los colores, tipografía y figuras aplicadas en el esquema de Material Design con las herramientas que la API nos provee.


Ejemplo De Aplicar Temas En Compose

Para comprender la forma en que definimos y aplicamos temas en un una App, tomaremos el ejemplo de una Card que presenta un producto con una acción de añadir al carro.

Ejemplo de aplicar temas en Compose

La función componible que la representa se encuentra en Products.kt y es la siguiente:

private val padding = Modifier.padding(horizontal = 16.dp)

@Composable
fun ProductCard(modifier: Modifier) {

    Card(modifier) {
        Column(modifier = Modifier
            .fillMaxWidth()
            .clickable { }) {
            Image(
                painter = painterResource(id = R.drawable.product),
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(194.dp)
            )
            Spacer(Modifier.size(16.dp))
            Row(padding.fillMaxWidth()) {
                Text(
                    text = "Natural Skin Cream",
                    style = MaterialTheme.typography.h6,
                    modifier = Modifier.weight(0.8f)
                )
                Text(
                    "$24",
                    textAlign = TextAlign.End,
                    style = MaterialTheme.typography.h6,
                    modifier = Modifier.weight(0.2f)
                )
            }
            Spacer(Modifier.size(16.dp))
            Text(
                "Esta crema hidratante tiene una fórmula rica y " +
                        "cremosa que es absorbida fácilmente por tu piel " +
                        "para una hidratación profunda",
                modifier = padding,
                style = MaterialTheme.typography.body1
            )
            Spacer(Modifier.size(20.dp))
            Button(
                onClick = { },
                modifier = Modifier.align(Alignment.CenterHorizontally)
            ) {
                Text(text = "AÑADIR AL CARRITO")
            }
            Spacer(Modifier.size(16.dp))
        }
    }
}

Como ves, diseñaremos la jerarquía de UI sin estilo alguno y luego personalizaremos un tema que aplicarle. También definiremos su modo oscuro (Dark Theme). Puedes descargar el proyecto desde el siguiente enlace (módulo p6_temas):

Lectura: Lee Cards en Compose (todo) para conocer más sobre este componente.


Material Theming

Jetpack Compose te permite implementar temas que están basados en los fundamentos del Material Design. Personalizar los elementos de estilo de una App con este sistema se le conoce como Material Theming. El objetivo es que modifiques los componentes (botones, campos de texto, etc.) hasta que encuentres la correcta configuración para tu marca.

¿Qué puedes particularizar?

Tres aspectos: colores, tipografía y la forma de los bordes de los componentes.

Lectura: Aprende más sobre estos temas leyendo Material Design y Material Theming.


La Función MaterialTheme

La manera de configurar el tema general de los elementos con Compose es a través de la función componible MaterialTheme() del paquete androidx.compose.material.

@Composable
fun MaterialTheme(
    colors: Colors = MaterialTheme.colors,
    typography: Typography = MaterialTheme.typography,
    shapes: Shapes = MaterialTheme.shapes,
    content: () -> Unit
): @Composable Unit

Sus parámetros modelan perfectamente el concepto de Material Theming para personalizar colores (colors), tipografía (typography) y figuras (shapes).

Donde content representa la jerarquía a la que se le aplicará el tema definido.


Tema Base Del Material Design

El Material Design viene con un tema preconstruido, donde sus colores de tema son:

Su tipografía usa la fuente Roboto en todos las escales de tipo:

Tipografía base de Material Design
Material Design: Ejemplo de escalas

Y las formas de las esquinas de los componentes por defecto son:

  • Componentes pequeños y medianos: esquinas redondeadas a 4dp
  • Componentes grandes: esquinas redondeadas a 0dp

Por ejemplo:

Si previsualizas la card sin usar ningún tema en específico, se proyectarán los colores, fuentes y tipografías del tema base, pero no se aplicarán estilos adicionales (alfas, tamaños de fuente, etc.):

@Composable
@Preview("Sin Tema")
fun NoThemePreview() {
    Surface {
        ProductCard(Modifier.padding(16.dp))
    }
}
Ejemplo de Card sin tema en Compose

Ahora, apliquemos el tema base a nuestra tarjeta de producto con la función mencionada.

Para ello ve a Products.kt y crea una función componible que invoque a ProductCard(). Recubrela por Surface() y luego por MaterialTheme() sin parámetros:

@Composable
@Preview("Tema Base Material Design")
fun BaselineThemePreview() {
    MaterialTheme {
        Surface {
            ProductCard(Modifier.padding(16.dp))
        }
    }
}

Nota: La función Surface representa el concepto de superficie del Material Design para determinar elevación, profundidad y sombras.

Así luciría la tarjeta del producto al aplicar el tema base explícitamente:

Ejemplo de Card con MaterialTheme base en Compose

Crear Un Tema Personalizado

Ahora veamos cómo crear los parámetros que MaterialTheme() necesita para personalizar el tema.

1. Para crear nuestro propio tema, comenzaremos añadiendo un nuevo paquete llamado theme y le agregaremos un nuevo archivo Kotlin llamado Theme.kt.

Este será el lugar donde añadiremos el resto de archivos asociados a la configuración.

2. Dentro de este archivo crearemos una función composable de nivel superior llamada ProductsTheme(). Ella representa nuestro tema para la tarjeta de producto. Su objetivo por el momento es recibir el contenido a estilizar y transmitirlo hacia MaterialTheme():

@Composable
fun ProductsTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(content = content)
}

Esto nos permite aislar y reutilizar el tema con cualquiera de nuestras interfaces de usuario.

3. Crea una nueva función componible en Products.kt para invocar el nuevo tema.

@Composable
@Preview("Tema Personalizado Claro")
fun CustomThemePreview() {
    ProductsTheme {
        Surface {
            ProductCard(Modifier.padding(16.dp))
        }
    }
}

Por el momento no se notará el cambio, ya que aún no definimos ninguna característica. Por esta razón, comencemos viendo la aplicación de colores.


Definir Colores Del Tema

1. Crea un nuevo archivo Kotlin llamado Color.kt dentro del paquete theme.

2. Incluye como propiedades de nivel superior a los colores que componen nuestra paleta personalizada.

Los colores en Compose están modelados por la clase Color, la cual posee un constructor que recibe la definición hexadecimal del color como un Long.

Para este ejemplo usaremos los siguientes tonos de azul y amarillo:

val Yellow500 = Color(0xFFFFEB3B)

val Blue200 = Color(0xFF81D4FA)
val Blue500 = Color(0xFF2196F3)
val Blue700 = Color(0xFF1976D2)

3. Luego los aplicaremos desde MaterialTheme en Theme.kt con el primer parámetro colors.

Debido a que el tipo que recibe es de tipo Colors, podremos usar su constructor público Colors():

Colors(
    primary: Color,
    primaryVariant: Color,
    secondary: Color,
    secondaryVariant: Color,
    background: Color,
    surface: Color,
    error: Color,
    onPrimary: Color,
    onSecondary: Color,
    onBackground: Color,
    onSurface: Color,
    onError: Color,
    isLight: Boolean
)

O la función de utilidad lightColors(), la cual crea una instancia de Colors con los valores del tema base claro:

fun lightColors(
    primary: Color = Color(0xFF6200EE),
    primaryVariant: Color = Color(0xFF3700B3),
    secondary: Color = Color(0xFF03DAC6),
    secondaryVariant: Color = Color(0xFF018786),
    background: Color = Color.White,
    surface: Color = Color.White,
    error: Color = Color(0xFFB00020),
    onPrimary: Color = Color.White,
    onSecondary: Color = Color.Black,
    onBackground: Color = Color.Black,
    onSurface: Color = Color.Black,
    onError: Color = Color.White
): Colors

Como solo definiremos el color primario y su variante, eligimos lightColors() con el objetivo de reutilizar los valores de los otros colores.

Así que añadimos una propiedad a Theme.kt llamada LightColors y la pasamos al tema personalizado:

private val LightColors = lightColors(
    primary = Blue500,
    primaryVariant = Blue700,
    secondary = Yellow500
)

@Composable
fun ProductsTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(
        colors = LightColors,
        content = content
    )
}

Ve a las previsualizaciones creadas y refresca su contenido para reflejar los nuevos colores:

Lectura: Aprende más sobre colores en el tutorial Colores En Compose (todo).


Definir Tipografía

Para personalizar la tipografía del tema existen cuatro clases que trabajan en conjunto para representar los estilos tipográficos:

  • Typography: Representa el conjunto de estilos dentro del sistema de tipos que será aplicado al tema
  • TextStyle: Configuración de estilo para los elementos Text
  • FontFamily: Representa una familia de fuentes
  • Font: Fuente tipográfica en los recursos del proyecto

Nuestra tarea es crear un objeto Typography con los estilos necesarios para representar a las escalas de tipo que usaremos (H6, Body2, Caption, etc.).

Por ejemplo:

Definamos una tipografía con las escalas de tipo H6, Body 2 y Button. Los títulos usarán la fuente Overlock y los elementos de cuerpo Nunito.

1. Lo primero es descargar ambas fuentes y añadir los archivos de formato tipográfico a res/font. Para el ejemplo que llevamos, subiremos los pesos regular y bold de ambas:

2. Luego crea un nuevo archivo Kotlin llamado Typography.kt y añade dos propiedades con las familias de fuentes.

val Overlock = FontFamily(
    Font(R.font.overlock_regular),
    Font(R.font.overlock_bold, FontWeight.Bold)
)
val Nunito = FontFamily(
    Font(R.font.nunito_regular),
    Font(R.font.nunito_bold, FontWeight.Bold)
)

En el código anterior pasas como parámetro el Id de la fuente junto a la representación de su peso por la clase FontWeight. Con este emparejamiento le dices a Compose cómo estilizar el grueso del texto.

3. Acto seguido, declara una propiedad llamada ProductsTypography e inicializa un nuevo objeto Typography con instancias de TextStyle.

@ExperimentalUnitApi
val ProductsTypography = Typography(
    h6 = TextStyle(
        fontFamily = Overlock,
        fontWeight = FontWeight.SemiBold,
        fontSize = 22.sp,
        letterSpacing = TextUnit(0.15f, TextUnitType.Sp)
    ),
    body1 = TextStyle(
        fontFamily = Nunito,
        fontWeight = FontWeight.Normal,
        fontSize = 17.sp,
        letterSpacing = TextUnit(0.5f, TextUnitType.Sp)
    ),
    button = TextStyle(
        fontFamily = Nunito,
        fontWeight = FontWeight.Medium,
        fontSize = 15.sp,
        letterSpacing = TextUnit(1.25f, TextUnitType.Sp)
    )
)

Si revisas la definición del constructor de Typography, este recibe como parámetros desde H1 hasta Overline y cada uno tiene valores por defecto. Por lo que es posible tan solo nombrar a los que necesitemos.

4. Finaliza incluyendo a ProductsTypography como el argumento de typography en ProductsTheme:

@ExperimentalUnitApi
@Composable
fun ProductsTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(
        colors = LightColors,
        typography = ProductsTypography,
        content = content
    )
}

5. Especifica a los elementos Text la escala que usarán a través de la propiedad MaterialTheme.typograhic. En el caso de la Card, asignaremos h6 al título y el precio; y body1 a la descripción:

//...
Row(padding.fillMaxWidth()) {
    Text(
        text = "Natural Skin Cream",
        style = MaterialTheme.typography.h6,
        modifier = Modifier.weight(0.8f)
    )
    Text(
        "$24",
        textAlign = TextAlign.End,
        style = MaterialTheme.typography.h6,
        modifier = Modifier.weight(0.2f)
    )
}
Spacer(Modifier.size(16.dp))
Text(
    "Esta crema hidratante tiene una fórmula rica y " +
            "cremosa que es absorbida fácilmente por tu piel " +
            "para una hidratación profunda",
    modifier = padding,
    style = MaterialTheme.typography.body1
)
//...

6. Al actualizar la previsualización verás reflejado las nuevas fuentes:

Lectura: Aprende más sobre este tema leyendo Tipografía En Compose (todo).


Definir Formas

Los componentes del Material Design están agrupados en las siguientes categorías de figuras según su tamaño: pequeños, medianos y grandes. La siguiente es una imagen que muestra esta clasificación:

Jetpack Compose representa el sistema de formas con las clases RoundedCornerShape y CutCornerShape (hijas de CornedBasedShape) con el fin de aplicar redondeo o cortes en las esquinas de los componentes.

Estilos de formas y sus bordes en Compose
Material Design: Familia de figuras

Y para definir el estilo de los bordes en las tres categorías, usaremos la clase Shapes, la cual posee un constructor público que los refleja:

Shapes(
    small: CornerBasedShape,
    medium: CornerBasedShape,
    large: CornerBasedShape
)

Por ejemplo:

Apliquemos un redondeo a la esquina superior derecha de los componentes medianos y un redondeo para todos los bordes de los componentes pequeños.

¿Cómo lo haces?

1. Crea un nuevo archivo Kotlin con el nombre de Shape.kt en el paquete theme.

2. Declara en su interior una propiedad llamada ProductsShapes y asignale una instancia de Shapes():

val ProductsShapes = Shapes(
    small = RoundedCornerShape(50),
    medium = RoundedCornerShape(topEnd = 80.dp),
    large = RoundedCornerShape(8.dp)
)

3. Y finalmente, abre Theme.kt y pasale como tercer parámetro las figuras que acabamos de definir:

MaterialTheme(
    //..
    shapes = ProductsShapes,
    //..
)

4. Refresca la previsualización para reflejar el cambio en el borde superior de la card (medium component) y en los cuatro bordes del botón (small component):


Definir Tema Oscuro

Para personalizar el tema oscuro de nuestra App realizamos los mismos pasos que con la definición del claro.

1. Abre Color.kt y declara los colores con la saturación adecuada para tu tema oscuro. En este ejemplo bajaremos a 200 el azul actual.

val Blue200 = Color(0xFF81D4FA)

2. En Theme.kt, añade una propiedad llamada DarkColors y asígnale el resultado de la función darkColors() con los colores del tema oscuro.

private val DarkColors = darkColors(
    primary = Blue200,
    primaryVariant = Blue700,
    secondary = Yellow500
)

3. Añade un parámetro booleano a ProductsTheme que especifique si se debe usar o no el tema oscuro. El valor por defecto será el resultado de la función isSystemInDarkTheme(). Con ella, Compose te ayuda a determinar si el sistema tiene aplicado el tema oscuro.

@ExperimentalUnitApi
@Composable
fun ProductsTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    MaterialTheme(
        colors = if (darkTheme) DarkColors else LightColors,
        typography = ProductsTypography,
        shapes = ProductsShapes,
        content = content
    )
}

Gracias a darkTheme, es posible usar una expresión if para seleccionar entre LightColors o DarkColors.

4. Desde Products.kt, crea una nueva función componible que previsualice la Card a partir de la invocación de ProductsTheme con darkTheme en true:

@ExperimentalUnitApi
@Composable
@Preview("Tema Personalizado Oscuro")
fun DarkThemePreview() {
    ProductsTheme(true) {
        Surface {
            ProductCard(Modifier.padding(16.dp))
        }
    }
}

5. Refresca el editor de componibles para evidenciar el resultado:

Ejemplo de tema oscuro en MaterialTheme de Compose

6. Como siguiente movimiento, escribe una función componible que represente la pantalla donde vive ProductCard:

@ExperimentalUnitApi
@Composable
fun Products() {
    ProductCard(Modifier.padding(16.dp))
}

7. Termina yendo a ProductsActivity e invoca a ProductsTheme desde setContent():

class ProductsActivity : ComponentActivity() {
    @ExperimentalUnitApi
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ProductsTheme {
                Surface {
                    Products()
                }
            }
        }
    }
}

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!