MaterialTheme: los tres pilares y su propagación
Un tema en Compose no es una hoja de estilos ni un objeto global: es un composable que provee tres esquemas —color, tipografía y formas— a todo su subárbol mediante variables de composición locales. Esta lección desmonta el mecanismo real: por qué las tres variables son estáticas y sin embargo cambiar un solo color no recompone media pantalla, qué implica que las lecturas del tema sean de solo lectura y no abran un ámbito de recomposición propio, cómo se anidan temas para teñir una región concreta sin duplicar la definición, qué ocurre en las ventanas hijas y en las vistas del sistema embebidas, y dónde termina la responsabilidad del tema y empieza la de la superficie que pinta el fondo y decide el color del contenido.
En los sistemas de interfaz clásicos, el tema es una tabla de consulta: un diccionario global de atributos que cada widget interroga al inflarse. Compose rompe esa idea de una forma que al principio parece un detalle de implementación y acaba siendo la clave de todo. Aquí el tema es un composable más, un nodo del árbol que no dibuja nada y cuyo único trabajo es instalar tres valores en el ámbito de sus descendientes. Nadie pasa el tema como parámetro, nadie lo importa, nadie lo busca en un registro estático: los hijos simplemente leen MaterialTheme.colorScheme y obtienen lo que el ancestro más cercano decidió. Esa inversión —de tabla global consultada a contexto propagado por posición en el árbol— es la que permite que una región de la pantalla tenga su propio tema sin contaminar al resto, y también la que explica los errores más desconcertantes del principiante: un diálogo sin colores, una vista embebida con la tipografía del sistema, una previsualización en blanco y negro.
- Delimitar con precisión qué contiene cada uno de los tres pilares y qué no contiene ninguno.
- Explicar el mecanismo de propagación y por qué es estático sin ser caro.
- Anidar y sobrescribir temas por regiones sin duplicar definiciones.
- Distinguir la responsabilidad del tema de la de la superficie que lo materializa.
Tres esquemas y ni un ajuste más
El composable MaterialTheme acepta exactamente cuatro parámetros: un esquema de color, una tipografía, unas formas y el contenido. No hay un quinto hueco para tus espaciados, tus elevaciones de marca o tus iconos. Esa austeridad es deliberada y conviene aceptarla antes de pelearse con ella.
El esquema de color es el pilar más grande: alrededor de una treintena de campos que no se llaman por su tono sino por su papel. primary y onPrimary, primaryContainer y onPrimaryContainer, los mismos pares para secondary, tertiary y error, la familia de superficies —surface, surfaceContainerLowest hasta surfaceContainerHighest, surfaceDim, surfaceBright—, los bordes outline y outlineVariant, y los inversos que usan la barra de mensajes emergentes. Cada par sigue la misma convención: el campo con prefijo on es el color legible encima del otro, y ese contrato es la única garantía de contraste que el sistema te ofrece.
La tipografía son quince estilos organizados en cinco familias —display, headline, title, body, label— por tres tamaños. Desde la revisión expresiva de Material existen además variantes enfatizadas de varios de esos estilos, pensadas para titulares con más carácter tipográfico. Cada estilo es un TextStyle completo: familia, grosor, tamaño, altura de línea y espaciado entre letras.
Las formas son cinco radios nombrados por tamaño —de extraSmall a extraLarge— más las ampliaciones que introdujo la revisión expresiva. No son decoración: cada componente de Material declara de qué categoría toma su recorte, de modo que cambiar medium redondea de golpe todas las tarjetas de la aplicación.
Esa correspondencia entre categoría y componente es el mecanismo que hace que un tema funcione sin tocar componentes. Un botón no pregunta por un radio de ocho unidades: pide la categoría pequeña. Una tarjeta pide la mediana, una hoja inferior la extra grande. Cuando defines las cinco categorías estás describiendo, sin saberlo, la silueta de cada uno de los cuarenta componentes de la biblioteca. Lo mismo ocurre con la tipografía: ningún componente elige un tamaño de fuente, elige un nivel de la jerarquía.
Fuera de esos tres esquemas no hay nada. No hay espaciados, no hay duraciones de animación, no hay elevaciones nombradas, no hay iconografía. Esa ausencia es la que obliga a construir una capa propia encima, y conviene registrarla ahora como un hecho de diseño de la biblioteca y no como una carencia por descubrir más adelante.
@Composable
fun TemaApp(oscuro: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (oscuro) darkColorScheme() else lightColorScheme(),
typography = Typography(),
shapes = Shapes(),
content = content,
)
}
Color
Roles, no tonos. Cada campo describe una función semántica y arrastra su pareja legible con prefijo on.
Tipografía
Quince estilos con jerarquía cerrada. Los componentes eligen el suyo; tú eliges cómo se ve cada nivel.
Formas
Cinco categorías de recorte. Un solo cambio redefine la silueta de toda la aplicación a la vez.
El mecanismo: contexto, no búsqueda
Las tres variables que instala MaterialTheme son estáticas en el sentido técnico del término: están declaradas con la variante que no rastrea lecturas individuales. Cuando el valor de una variable estática cambia, Compose no sabe quién la leía y recompone el subárbol entero. Esa elección parece imprudente para algo tan universal como el color, y sería catastrófica si el tema se sustituyera con frecuencia.
Aquí aparece el truco que casi nadie conoce y que explica el rendimiento observado. MaterialTheme no propaga el esquema que le pasas: recuerda una copia y actualiza los campos de esa copia uno a uno. Como cada campo del esquema es a su vez un estado observable, cambiar el color primario invalida únicamente a quien leyó el color primario, no a los tres mil nodos del subárbol. La variable estática se mantiene apuntando siempre al mismo objeto, y la granularidad la aporta el objeto por dentro.
flowchart TD A[MaterialTheme recibe un esquema nuevo] --> B[Recuerda una copia estable] B --> C[Actualiza campo a campo la copia] C --> D[La variable estatica nunca cambia de identidad] D --> E[El subarbol no se recompone entero] C --> F[Cada campo es estado observable] F --> G[Solo se invalida quien leyo ese campo] style E fill:#a6e3a1,color:#11111b style G fill:#a6e3a1,color:#11111b
La elección de la variante estática, vista así, deja de parecer imprudente. Rastrear lecturas individuales tiene un coste de memoria proporcional al número de lectores, y el tema lo lee prácticamente cada nodo del árbol; pagar ese rastreo para algo que en la práctica nunca se sustituye sería un derroche permanente. La biblioteca elige la variante barata para la variable y coloca la granularidad un nivel más abajo, dentro del objeto. Es una lección de diseño transferible: cuando algo se lee mucho y cambia poco, conviene abaratar la lectura aunque encarezca la escritura.
Hay un segundo detalle de eficiencia. Los accesos MaterialTheme.colorScheme, MaterialTheme.typography y MaterialTheme.shapes están marcados como composables de solo lectura, lo que significa que no abren un ámbito de recomposición propio. Leer el tema es, literalmente, leer una variable de la tabla de contexto: no añade un grupo al árbol de composición ni tiene coste de memoria por llamada. Puedes leerlo dentro de un bucle sin remordimiento.
La asimetría entre los tres pilares también responde a esta lógica. La tipografía y las formas son objetos completamente inmutables y se sustituyen enteros cuando cambian, lo cual es aceptable porque cambian una vez en la vida de la aplicación. El esquema de color es el único que se actualiza campo a campo, porque es el único que cambia en caliente: al alternar el modo oscuro, al variar el fondo de pantalla del usuario, al activarse el ajuste de contraste del sistema.
La tentación de recibir el color como argumento para “ahorrar lecturas” es un antipatrón. Pasarlo por parámetro convierte un valor contextual en una dependencia explícita que ensucia la firma, rompe la posibilidad de tematizar regiones y añade un parámetro que el compilador debe comparar en cada recomposición. Lee el tema en el punto de uso: es más barato y más correcto.
Anidar temas y sobrescribir por regiones
Los tres parámetros de MaterialTheme tienen como valor por omisión el valor actual del tema. Eso convierte el anidamiento en la herramienta natural para sobrescribir un pilar sin repetir los otros dos: envuelves la región, cambias lo que quieras y heredas el resto.
// Una region promocional con formas mas redondas, mismo color y tipografia.
MaterialTheme(shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(24.dp))) {
TarjetaOferta(oferta)
}
Conviene notar por qué la función de copia es aquí imprescindible y no una comodidad. Si construyeras un objeto de formas nuevo indicando solo la categoría mediana, las otras cuatro volverían a sus valores por omisión de la biblioteca y perderías las de tu marca. La copia parte del objeto vigente en esa posición del árbol, que puede no ser el de la raíz si ya hay temas anidados por encima. Anidar y copiar son, en realidad, la misma operación expresada en dos niveles.
La propagación sigue el árbol de composición, no el árbol de vistas de Android. Esto tiene dos consecuencias que se aprenden a base de sorpresas. La primera es buena: los diálogos y las ventanas emergentes crean una ventana nueva del sistema, pero su composición es hija de la tuya, así que heredan el tema sin que hagas nada. La segunda es mala: una vista tradicional embebida mediante interoperabilidad no participa en la composición y por tanto no ve nada del tema; si necesitas que combine, tienes que trasladarle los valores a mano.
// La vista embebida no ve el tema: hay que trasladarle los valores.
val colorTexto = MaterialTheme.colorScheme.onSurface
AndroidView(
factory = { contexto -> TextView(contexto) },
update = { vista -> vista.setTextColor(colorTexto.toArgb()) },
)
El caso del anidamiento tiene además un uso menos evidente y muy práctico: invertir una región. Envolver un bloque en un tema construido con el esquema contrario permite tener una tarjeta oscura dentro de una pantalla clara, con todos sus componentes internos correctamente teñidos y sin escribir un solo color. Es la forma canónica de resolver las secciones destacadas que en otros sistemas obligan a duplicar estilos.
Cada previsualización arranca su propio árbol de composición desde cero, sin el tema que instala la actividad. Si no envuelves el contenido con tu tema, estás viendo los valores por omisión de la biblioteca, no tu producto. La disciplina que evita el problema es que ningún composable de pantalla se previsualice desnudo: envuélvelos siempre en el mismo contenedor de tema que usa la aplicación real.
Donde acaba el tema y empieza la superficie
El tema no pinta. No hay un solo píxel bajo la responsabilidad de MaterialTheme: instala tres valores y se aparta. Quien materializa el color de fondo, la elevación tonal, la sombra y el recorte es Surface, y quien decide el color del texto y los iconos que caen dentro es también Surface, que instala la variable de contenido con el color legible correspondiente al fondo que acaba de pintar.
Esa división de trabajo explica un síntoma clásico: un texto que sale negro sobre un fondo oscuro. No es un fallo del tema, es contenido que vive fuera de cualquier superficie y por tanto hereda el color de contenido por omisión. La regla práctica es que todo bloque de interfaz con fondo propio debe declararlo con una superficie, no con un modificador de fondo suelto, precisamente para que el color de contenido viaje con él.
// Mal: el fondo se pinta pero nadie actualiza el color del contenido.
Box(Modifier.background(MaterialTheme.colorScheme.primary)) { Text("Aviso") }
// Bien: la superficie pinta y ademas instala el color legible correspondiente.
Surface(color = MaterialTheme.colorScheme.primary) { Text("Aviso") }
El mismo patrón se repite con la tipografía. El texto no consulta el tema directamente: lee un estilo vigente que alguien instaló más arriba, y son los componentes de la biblioteca los que lo instalan al colocar sus etiquetas y titulares. Cuando escribes texto suelto fuera de cualquier componente, el estilo vigente es el de cuerpo grande, y si querías otro nivel tienes que decirlo. Esa cadena —tema, superficie, estilo vigente, texto— es la que hay que tener en la cabeza cuando algo se ve raro y no encuentras dónde está escrito el color.
Ante un elemento que se ve mal, recórrelas en orden. ¿Hay un tema instalado por encima de este nodo, o estoy en una previsualización, una ventana o una vista embebida que no lo hereda? ¿Hay una superficie que haya declarado el color de contenido para esta zona? ¿El estilo de texto vigente es el que yo creo o el de omisión? Casi todos los fallos de aspecto se resuelven en una de esas tres preguntas, y ninguna de ellas se responde mirando el fichero del componente.
Conviene abandonar pronto la idea de que un tema es la lista de colores de la marca. Lo que MaterialTheme instala es un sistema de coordenadas semántico: un vocabulario cerrado de papeles —lo primario, lo que va encima de lo primario, la superficie contenedora alta, el borde variante— en el que cada componente expresa sus intenciones sin conocer jamás un valor concreto. Un botón no dice “píntame de azul”, dice “soy la acción principal”, y esa frase se resuelve en tiempo de composición contra las coordenadas vigentes en su posición del árbol. De ahí se sigue todo lo que hace potente al sistema y también todo lo que lo hace exigente. Es potente porque desacopla por completo la decisión estética de su punto de aplicación: el mismo botón, sin tocar una línea, sirve para el modo claro, el oscuro, el de alto contraste y el generado desde el fondo de pantalla del usuario, y funcionará con paletas que aún no existen. Y es exigente porque el sistema solo garantiza lo que el vocabulario expresa: el contrato on promete legibilidad, la jerarquía tipográfica promete orden visual, las categorías de forma prometen coherencia; nada más. En el momento en que escribes un valor literal —un color hexadecimal, un tamaño de fuente, un radio— no estás “personalizando”, estás saliéndote del sistema de coordenadas, y ese píxel deja de participar en cualquier transformación futura: no se adaptará al modo oscuro, no responderá al ajuste de contraste, no se recoloreará con el color dinámico. La madurez profesional en esta capa no consiste en saber muchos roles, consiste en detectar el instante exacto en que estás a punto de escribir una constante y preguntarte qué papel semántico estabas intentando expresar en realidad.
- Cuenta cuántos valores de color literales hay en tu árbol de composición fuera del fichero de tema; cada uno es un punto que no responderá al modo oscuro.
- Envuelve una región de una pantalla en un tema anidado que solo cambie las formas y comprueba que el color y la tipografía se heredan intactos.
- Provoca a propósito el fallo del texto ilegible colocando contenido fuera de toda superficie, y arréglalo sin escribir ningún color.
- Localiza un componente que reciba un color por parámetro y razona si ese parámetro debería ser una lectura del tema en el punto de uso.
- Explica, con tus palabras, por qué cambiar un único campo del esquema no recompone toda la pantalla pese a que la variable de contexto es estática.