Saltar al contenido
Todas las entradas

Color dinámico Material You en Jetpack Compose: conservar tu color de marca cuando gana el fondo de pantalla

dynamicColorScheme() reemplaza tu paleta con una construida a partir del fondo de pantalla del usuario. Una guía práctica para armonizar los colores de marca en vez de perderlos.

MFKAPPS 6 min de lectura

Activa el color dinámico en una app de Compose y lo primero que notas es que tu marca desaparece. El verde de Granyn, el azul de Hydrame — desaparecidos, reemplazados por lo que dynamicColorScheme() derivó del fondo de pantalla del usuario. En un fondo de pantalla azul estándar, no pasa nada. En uno naranja o magenta, una app cuya identidad visual completa es “la verde” o “la azul” ahora se parece a cualquier otra app en el cajón de aplicaciones. Eso es Material You funcionando como fue diseñado, y también es un problema real si el color es la forma en que un usuario reconoce tu app entre muchas otras.

La solución no es desactivar el color dinámico — los usuarios que han optado por un tema de todo el sistema notan cuando una app se niega a coincidir. La solución es armonizar: conservar los neutros y los colores de interacción derivados del fondo de pantalla, pero empujar tu tono de marca de vuelta al esquema en lugar de dejar que sea sobrescrito en silencio.

Qué da realmente dynamicColorScheme

En API 31+, dynamicLightColorScheme(context) y dynamicDarkColorScheme(context) leen android.R.color.system_accent1 hasta system_accent3 — paletas tonales que el sistema extrajo del fondo de pantalla — y devuelven un ColorScheme completo de Material 3. Es cómodo porque obtienes un esquema garantizado para verse coherente con el resto del sistema operativo. También es un esquema con cero conocimiento de qué color se supone que es tu app.

@Composable
fun AppTheme(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val colorScheme = when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            if (isSystemInDarkTheme()) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        else -> if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
    }
    MaterialTheme(colorScheme = colorScheme, content = content)
}

Ahí es donde se detienen la mayoría de los tutoriales, y es exactamente el código que hizo que el ícono de gota de agua de Hydrame y su color de acento dentro de la app dejaran de coincidir en la mitad de los teléfonos en los que lo probé.

Armonizar en vez de reemplazar

androidx.core.graphics.ColorUtils — sin dependencia extra, viene incluido en core-ktx — tiene un blendHSL que puedes usar para desplazar un color de marca fijo hacia el primary dinámico del esquema, en pasos pequeños, hasta que quede lo bastante cerca como para sentirse nativo sin perder su identidad. Las propias guías de diseño de Material llaman a esto “armonización”: tomar un color que el sistema no posee (un rojo de error, un tono de marca, una serie de visualización de datos) y mezclarlo parcialmente hacia el rol dinámico más cercano para que no desentone.

fun harmonize(designColor: Color, dynamicColor: Color, fraction: Float = 0.2f): Color {
    val blended = ColorUtils.blendHSL(
        designColor.toArgb(),
        dynamicColor.toArgb(),
        fraction,
    )
    return Color(blended)
}

Aplicado a Hydrame, cuyo color primario de marca es #3B82F6:

val scheme = dynamicColorScheme(context)
val harmonizedAccent = harmonize(
    designColor = Color(0xFF3B82F6),
    dynamicColor = scheme.primary,
    fraction = 0.15f,
)

En fraction = 0.15f el acento todavía se lee inconfundiblemente como el azul de Hydrame, pero se sitúa cómodamente junto a cualquier neutro tonal que haya producido el fondo de pantalla en lugar de pelear con él. Uso el color armonizado solo para las piezas que llevan identidad de marca — el eco del ícono de la app dentro de la app, el anillo de progreso de la gota de agua, las ilustraciones de onboarding — y dejo todo lo demás (superficies, texto, divisores, botones) en los roles dinámicos propios del sistema. Armonizar cada color del esquema anula el propósito mismo del color dinámico; el objetivo es un único acento identificable que sobrevive dentro de una paleta por lo demás nativa del sistema, no una toma de control total de la marca.

El fallback por debajo de API 31

Dos tercios de la lógica de fallback de arriba caben en una sola rama when, pero vale la pena ser deliberado sobre cómo luce el esquema pre-Android-12, porque no es un esquema dinámico degradado — es tu único esquema en esos dispositivos, punto. Mantengo un ColorScheme construido a mano por app, usando lightColorScheme(primary = ..., secondary = ..., ...) alimentado con los valores de marca exactos que ya existen en los tokens de diseño de cada app (el #22C55E de Granyn, el #F59E0B de Mintly, el #6366F1 de Subly), en lugar de intentar aproximar lo que un esquema dinámico habría producido. En esos dispositivos el problema de color de marca del que trata este artículo simplemente no existe — eres dueño de toda la paleta — así que no gastes esfuerzo simulando un comportamiento dinámico que de todos modos no puedes obtener.

Probar entre fondos de pantalla, no solo claro y oscuro

El color dinámico añade una dimensión de prueba fácil de saltarse: tema claro, tema oscuro, y ahora un rango de tonos de fondo de pantalla. Una pantalla que se ve correcta contra el fondo de pantalla predeterminado de un Pixel puede tener un contraste ilegible contra uno saturado, porque system_accent1 desplaza el tono y el croma juntos. Antes de publicar una pantalla con tema, la reviso al menos contra un fondo de pantalla frío, uno cálido y uno gris de baja saturación — configurable desde Ajustes → Fondo de pantalla y estilo → elegir color, lo que permite forzar un tono específico sin buscar una imagen que coincida. Si la relación de contraste de tu acento armonizado contra scheme.surface cae por debajo de 4.5:1 en cualquiera de esos fondos, el fraction fijo de arriba es demasiado agresivo para ese rango de tonos y necesita una verificación de contraste, no una constante fija:

fun harmonizeWithContrast(designColor: Color, scheme: ColorScheme, fraction: Float = 0.15f): Color {
    val candidate = harmonize(designColor, scheme.primary, fraction)
    return if (ColorUtils.calculateContrast(candidate.toArgb(), scheme.surface.toArgb()) >= 4.5) {
        candidate
    } else {
        designColor // volver al color de marca sin modificar en vez de enviar bajo contraste
    }
}

Qué revisar antes de publicar

  • Cada pantalla que lleva color de marca (ecos de ícono, ilustraciones, gráficos) usa un valor armonizado, no el primary dinámico crudo ni el hex de marca sin tocar.
  • El esquema de fallback pre-API-31 es un ColorScheme real, ajustado a mano, no una copia del dinámico con valores fijos.
  • El contraste se verifica contra al menos un fondo de pantalla saturado y uno de croma bajo, no solo el predeterminado.
  • Las superficies que no son de marca (fondos, divisores, botones por defecto) permanecen intactas en los roles dinámicos del sistema — resiste el impulso de armonizarlo todo.

El color dinámico es una de las pocas funciones de la plataforma Android que hace que una app se sienta parte de un teléfono específico en vez de uno genérico. Vale la pena conservarlo. Simplemente no vale la pena sacrificar tu color de marca para conservarlo.