wandres.dev
MATERIAL 3 · theming y color dinámico

Color dinámico, roles y modo oscuro

Desde Android 12 el sistema puede entregarle a tu aplicación un esquema de color completo derivado del fondo de pantalla del usuario. Esta lección explica cómo se construye ese esquema —del pixel semilla al espacio perceptual, de ahí a las paletas tonales y de las paletas a los treinta y tantos roles—, por qué los roles se nombran por función y nunca por tono, en qué se diferencia realmente un esquema oscuro de una inversión, cómo entra en juego el ajuste de contraste del sistema, y cuál es el coste identitario de ceder la paleta al usuario: qué partes de tu marca sobreviven, cuáles no, y cómo se negocia esa frontera con el armonizado de colores propios.

⏱ 19 min

Hay una decisión de producto escondida en una línea de código. Cuando llamas a dynamicLightColorScheme, estás renunciando a elegir los colores de tu aplicación y delegándolos en la fotografía que el usuario puso de fondo de pantalla. No es una degradación controlada ni un ajuste fino: es una cesión completa del control cromático a un algoritmo que ni siquiera se ejecuta en tu proceso. A cambio recibes algo que ningún equipo de diseño puede fabricar —la sensación de que la aplicación pertenece a ese teléfono, al del usuario y no al del catálogo—, y recibes también la garantía de contraste que el propio algoritmo construye. Entender qué pasa entre ese pixel del fondo de pantalla y el color final del texto de un botón no es curiosidad académica: es lo que te permite decidir con criterio qué partes de tu identidad puedes ceder y cuáles debes defender.

🎯 Al terminar esta lección sabrás
  • Reconstruir la cadena que va del fondo de pantalla al esquema de color completo.
  • Interpretar los roles de Material 3 como contratos de función y de contraste.
  • Explicar por qué el esquema oscuro es una regeneración y no una inversión.
  • Decidir con criterio qué elementos de marca se ceden al color dinámico y cuáles se protegen.

Del fondo de pantalla al esquema

El sistema extrae del fondo de pantalla un conjunto reducido de colores dominantes y elige uno como semilla. A partir de ahí abandona el espacio de color habitual y pasa a uno perceptual, donde las coordenadas son tono, croma y luminosidad, y donde —esto es lo importante— la luminosidad se corresponde con el brillo que el ojo humano percibe realmente, no con una media aritmética de canales.

Sobre la semilla se generan varias paletas tonales: la principal conserva su tono, las secundarias reducen el croma para obtener acentos más apagados, la terciaria gira el tono para aportar contraste cromático, y las neutras casi lo eliminan para producir superficies. Cada paleta se muestrea en una escala fija de niveles de luminosidad, del negro al blanco, y esa escala es la materia prima de todo lo demás.

Los roles se definen entonces como posiciones en esa escala, no como colores. En un esquema claro, el rol principal toma un tono oscuro de su paleta y su acompañante legible toma uno casi blanco; su contenedor toma un tono muy claro y el contenido del contenedor uno muy oscuro. Como las distancias en la escala están calibradas en luminosidad perceptual, cada pareja mantiene un contraste suficiente sea cual sea la semilla. El contraste no se comprueba a posteriori: está construido dentro de la geometría del esquema.

@Composable
fun TemaApp(oscuro: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
    val contexto = LocalContext.current
    val dinamicoDisponible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
    val esquema = when {
        dinamicoDisponible && oscuro -> dynamicDarkColorScheme(contexto)
        dinamicoDisponible -> dynamicLightColorScheme(contexto)
        oscuro -> ColoresMarcaOscuro
        else -> ColoresMarcaClaro
    }
    MaterialTheme(colorScheme = esquema, typography = TipografiaApp, content = content)
}

Tres detalles de esa llamada merecen comentario. El primero es la comprobación de versión: el color dinámico existe desde Android 12, y por debajo de esa versión hay que tener un esquema de marca preparado, no un fallo. El segundo es que el esquema oscuro y el claro se piden por funciones distintas, porque no se derivan uno del otro. El tercero, el más olvidado, es que el usuario puede haber elegido la semilla a mano en los ajustes del sistema en lugar de heredarla del fondo de pantalla, y también puede haber elegido entre varias variantes de generación; tu código no ve esa diferencia, recibe el esquema ya resuelto.

flowchart TD
A[Fondo de pantalla del usuario] --> B[Color semilla dominante]
B --> C[Espacio perceptual de tono croma y luminosidad]
C --> D[Paletas tonales principal secundaria terciaria y neutras]
D --> E[Muestreo en niveles fijos de luminosidad]
E --> F[Asignacion de roles segun modo claro u oscuro]
F --> G[ColorScheme entregado a MaterialTheme]
style G fill:#a6e3a1,color:#11111b

Los roles como contrato

Un rol de Material 3 responde a dos preguntas a la vez: qué función cumple el elemento y sobre qué se va a dibujar. El par primary y onPrimary significa “acción de máxima importancia” y “lo que se dibuja encima de ella”; el par primaryContainer y onPrimaryContainer significa lo mismo con menos peso visual, para superficies amplias que no deben gritar. La familia secondary sirve a lo que acompaña, la tertiary existe para introducir un acento que rompa la monotonía sin salirse de la armonía, y la de error es la única que conserva su carga semántica intacta en cualquier paleta.

La familia de superficies merece atención aparte porque su diseño cambió respecto a versiones anteriores de Material. Ya no se calcula un tinte a partir de la elevación: existen niveles nombrados de contenedor de superficie, del más bajo al más alto, y cada componente elige el suyo. Un panel que flota sobre otro no se distingue por una sombra sino por estar un escalón más arriba en esa familia, lo que produce jerarquías legibles también en modo oscuro, donde las sombras son prácticamente invisibles.

💡
Si escribes blanco o negro, has roto la cadena

Cada vez que fijas el color de un texto a blanco porque “el botón es oscuro”, estás asumiendo que conoces el fondo. Con color dinámico no lo conoces: la semilla del usuario puede producir un principal claro con contenido oscuro. Usa siempre el rol acompañante correspondiente y deja que la geometría del esquema resuelva el contraste.

Existe además un ajuste de contraste a nivel de sistema que el usuario controla en las opciones de accesibilidad, y que en las versiones recientes genera variantes del esquema con separaciones tonales mayores. Tu aplicación lo hereda gratis si y solo si expresa sus colores mediante roles; cualquier valor literal se queda al margen del ajuste y se convierte, precisamente para quien necesitaba el contraste, en el elemento ilegible de la pantalla.

La familia de superficies tiene una consecuencia práctica que conviene aplicar desde el primer día: la jerarquía de profundidad se expresa eligiendo un nivel, no calculando un tinte a partir de una elevación. Una hoja modal que aparece sobre una lista no necesita una sombra fuerte, necesita estar dos escalones por encima en la familia de contenedores. Ese cambio de enfoque simplifica el código y produce resultados legibles también en modo oscuro, donde ninguna sombra se distingue.

// La profundidad como nivel semantico, no como calculo de elevacion.
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh) {
    Column(Modifier.padding(16.dp)) {
        Text(titulo, style = MaterialTheme.typography.titleMedium)
        Text(cuerpo, color = MaterialTheme.colorScheme.onSurfaceVariant)
    }
}

Oscuro no es invertido

La intuición de que el modo oscuro se obtiene invirtiendo colores produce interfaces desagradables y es técnicamente falsa en Material 3. Lo que ocurre es una reasignación de niveles tonales sobre las mismas paletas: donde el modo claro tomaba un tono oscuro para el rol principal, el oscuro toma uno claro; donde el contenedor era casi blanco, pasa a ser un tono medio-oscuro. Las paletas no cambian; cambia el mapa de qué nivel ocupa cada rol.

La consecuencia más útil de entenderlo así es que un color no tiene un opuesto oscuro. La pregunta “cuál es la versión oscura de este azul” no tiene respuesta dentro del sistema, porque los dos esquemas no se relacionan por una transformación entre colores sino por dos lecturas distintas de la misma paleta. Por eso no existe una función que convierta un esquema claro en oscuro, y por eso cualquier intento de fabricar el modo oscuro aclarando u oscureciendo valores termina en una interfaz apagada y sucia.

Esa diferencia explica varios comportamientos que sorprenden. Los colores saturados se atenúan en modo oscuro porque el nivel elegido reduce el croma percibido, y eso es intencionado: la saturación alta sobre fondo negro produce fatiga visual y aberración cromática en los bordes. Las superficies no son negro puro sino grises muy oscuros, porque el negro absoluto elimina toda posibilidad de jerarquía por elevación y, en pantallas de píxel autoemisivo, produce transiciones bruscas al desplazar contenido.

Queda por resolver quién decide el modo. La lectura del ajuste del sistema es el valor de partida correcto, pero muchas aplicaciones ofrecen además una preferencia propia, y ahí el modelo adecuado tiene tres estados y no dos: claro, oscuro y “seguir al sistema”. Guardar un simple booleano es un error de diseño de datos que impide volver al comportamiento automático una vez que el usuario ha tocado el ajuste.

enum class Preferencia { Sistema, Claro, Oscuro }

@Composable
fun esOscuro(preferencia: Preferencia): Boolean = when (preferencia) {
    Preferencia.Sistema -> isSystemInDarkTheme()
    Preferencia.Claro -> false
    Preferencia.Oscuro -> true
}
🌗

Regeneración

El esquema oscuro se genera con las mismas paletas y otro reparto de niveles. Nunca es una inversión aritmética.

🪜

Jerarquía sin sombras

En oscuro las sombras no se ven. La profundidad la aportan los niveles de contenedor de superficie.

Contraste del sistema

El ajuste de accesibilidad reescribe las separaciones tonales. Solo alcanza a lo que se expresa con roles.

La marca frente al algoritmo

Aquí está la decisión difícil. Si el usuario tiene un fondo de pantalla rojizo, el botón principal de tu aplicación —cuya marca es azul— será rojizo. No hay negociación parcial: el esquema dinámico es un bloque. Lo que sí puedes es sacar del esquema aquellos elementos cuya identidad es innegociable, típicamente el logotipo, un color de estado con significado convencional o la superficie de una pantalla de bienvenida.

La herramienta para hacerlo con elegancia es el armonizado: coges tu color de marca, lo empujas ligeramente en tono hacia la semilla del sistema y conservas su croma y su luminosidad. El resultado sigue siendo reconociblemente tuyo pero convive con la paleta ambiente en lugar de chocar contra ella. Es la diferencia entre un invitado que se adapta a la casa y uno que trae sus propios muebles.

// Un rol propio, con su pareja legible, armonizado hacia el esquema vigente.
@Immutable
data class ColoresEstado(val exito: Color, val onExito: Color)

val LocalColoresEstado = staticCompositionLocalOf { ColoresEstado(VerdeMarca, Color.White) }

Hay una excepción que conviene reconocer: el color dinámico no se aplica a las pantallas donde tu identidad es el contenido. La pantalla de arranque, el logotipo, una ilustración de marca o un gráfico cuya leyenda depende de colores fijos son elementos que se sacan del esquema por decisión consciente. La regla es que se cede el color de los controles —lo que el usuario opera— y se conserva el de los signos —lo que el usuario reconoce—.

ℹ️
Ofrecer el color dinamico como una preferencia mas

No todas las aplicaciones deben activarlo incondicionalmente. Un producto con identidad visual fuerte puede exponerlo como una opción explícita y dejarlo desactivado por omisión, o al revés. Lo que no debe hacer es no tener el esquema de marca preparado: por debajo de Android 12 no hay color dinámico, y esa rama del código se ejecuta en millones de dispositivos que siguen en circulación.

Ceder el color es ceder autoridad, y ahi esta el valor

El color dinámico se suele presentar como una función de personalización, y esa lectura se queda muy corta. Lo que ocurre en realidad es un desplazamiento de autoridad: durante décadas, la identidad visual de un producto de software fue una constante impuesta por su fabricante y soportada por el usuario; con el color dinámico, el fabricante define relaciones y el usuario define valores. Tu aplicación deja de declarar “soy azul” para declarar “mi acción principal contrasta con su fondo, mi acento terciario rompe la monotonía sin salirse de la armonía, mi error se distingue de todo lo demás”, y esas afirmaciones son ciertas bajo cualquier semilla. Es exactamente el mismo movimiento intelectual que llevó del diseño de página fija al diseño adaptativo, solo que en el eje del color en vez del eje del tamaño: se abandona la especificación del resultado y se especifica la invariante. Y como todo movimiento de ese tipo, tiene un precio que hay que pagar consciente: una aplicación que solo se ha probado con una paleta no está probada. El fallo característico no es un choque estético sino una ilegibilidad puntual —un icono decorativo que asumía fondo claro, un texto sobre una imagen que asumía superficie oscura, un separador que desaparece— y aparece únicamente en los teléfonos de los usuarios cuyo fondo de pantalla nadie del equipo tenía. Por eso el criterio profesional no es “activo el color dinámico o no”, que es una pregunta de gusto, sino esta otra, que es una pregunta de ingeniería: para cada píxel coloreado de mi interfaz, ¿existe una relación de rol que lo justifique, o hay ahí una suposición implícita sobre un color que ya no me pertenece?

⚔️ Somete tu paleta al usuario
  1. Cambia el fondo de pantalla del dispositivo a tres imágenes de temperaturas muy distintas y recorre la aplicación entera en cada una.
  2. Anota todos los elementos que se vuelven ilegibles o feos; para cada uno, identifica el color literal o la suposición de fondo que lo causa.
  3. Activa el ajuste de contraste alto del sistema y repite el recorrido: los fallos que aparecen ahora afectan justo a quien más lo necesita.
  4. Toma un color de marca que consideres innegociable y decide, argumentando, entre dejarlo fijo o armonizarlo hacia la semilla.
  5. Explica por qué las superficies del modo oscuro no son negro puro, en términos de jerarquía y de percepción.