Brushes y efectos: gradientes, mezcla, sombra y recorte
Pintar con algo más que un color plano: los pinceles de gradiente y su modo de repetición, el ShaderBrush que se adapta al tamaño, la composición de píxeles con blendMode y la capa aislada que exige, las sombras y el desenfoque del sistema, y el coste real de recortar con clip.
Hasta ahora cada primitiva se ha rellenado con un color uniforme, que es el caso degenerado de una idea mucho más general: lo que rellena una figura es un pincel, una función que asigna un color a cada punto del plano. Al abrir esa puerta aparecen los gradientes, las texturas y los efectos que hacen que una interfaz parezca cuidada. Y aparece también la pregunta que Compose no puede responder por ti: qué se combina con qué, en qué capa, y cuánta memoria de vídeo estás dispuesto a pagar por ello.
- Construir pinceles de gradiente lineal, radial y de barrido, con paradas y modo de repetición.
- Usar
ShaderBrushpara que un pincel se adapte al tamaño real del área de dibujo. - Componer píxeles con
blendModeentendiendo por qué necesita una capa aislada. - Aplicar sombra, desenfoque y recorte conociendo el coste de cada uno.
El pincel como función de posición
Brush es la abstracción que sustituye al color plano. Sus fábricas cubren las tres familias clásicas: Brush.linearGradient interpola a lo largo de una recta definida por dos puntos, Brush.radialGradient desde un centro hacia un radio, y Brush.sweepGradient recorre angularmente alrededor de un centro, que es el pincel natural de un anillo de progreso multicolor.
Los colores admiten paradas explícitas: en lugar de una lista simple, se pasan parejas de posición normalizada y color, lo que permite comprimir una transición en una franja estrecha o congelar un color durante buena parte del recorrido. Y tileMode decide qué ocurre más allá de los extremos del gradiente: Clamp prolonga el último color, Repeated reinicia la secuencia, Mirror la refleja alternando el sentido y Decal deja transparente lo que queda fuera.
val pincel = Brush.linearGradient(
0.0f to Color(0xFF11111B),
0.6f to Color(0xFF313244),
1.0f to Color(0xFF89B4FA),
start = Offset.Zero,
end = Offset(0f, 400f),
tileMode = TileMode.Mirror,
)
Box(Modifier.fillMaxWidth().height(200.dp).background(pincel))
Ese fragmento esconde una trampa clásica: las coordenadas start y end están en píxeles absolutos del área de dibujo, no en fracciones. Si el nodo mide más de cuatrocientos píxeles de alto, el gradiente se agota antes de llegar al final y el tileMode decide qué se ve en el resto. Las variantes verticalGradient y horizontalGradient sin coordenadas usan el infinito como límite y se estiran solas, pero en cuanto necesitas control fino el problema reaparece.
La solución canónica es ShaderBrush, una subclase de Brush cuyo método createShader recibe el Size real del área en el momento de pintar. Con él, el pincel deja de ser un valor fijo para volverse una función del tamaño, con la misma virtud que un Shape bien escrito.
val pincelAdaptativo = object : ShaderBrush() {
override fun createShader(size: Size): Shader =
LinearGradientShader(
from = Offset.Zero,
to = Offset(size.width, size.height),
colors = listOf(Color(0xFFF5C2E7), Color(0xFF94E2D5)),
)
}
El efecto de carga que barre una tarjeta gris no necesita ninguna librería. Es un Brush.linearGradient estrecho con TileMode.Clamp cuyas coordenadas de inicio y fin se desplazan según un valor animado, leído dentro de la lambda de dibujo para que solo se invalide el repintado. Un InfiniteTransition que anime un desplazamiento y un drawWithCache que reconstruya el pincel al cambiar el tamaño bastan para reproducirlo con veinte líneas.
val transicion = rememberInfiniteTransition(label = "shimmer")
val avance = transicion.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)),
label = "avance",
)
Modifier.drawWithCache {
val ancho = size.width
onDrawBehind {
val x = -ancho + 2f * ancho * avance.value
drawRect(
brush = Brush.linearGradient(
colors = listOf(Color(0xFF313244), Color(0xFF585B70), Color(0xFF313244)),
start = Offset(x, 0f),
end = Offset(x + ancho / 2f, 0f),
),
)
}
}
Mezclar píxeles: blendMode y la capa aislada
Cada primitiva acepta un blendMode que decide cómo se combina el color que estás pintando —la fuente— con el que ya había debajo —el destino—. El repertorio es el álgebra de Porter y Duff más los modos de separación fotográfica: SrcIn conserva la fuente solo donde el destino es opaco, DstOut perfora el destino con la silueta de la fuente, Multiply oscurece, Screen aclara, Overlay y SoftLight reproducen los modos de capa de un editor de imagen, y Plus suma linealmente.
Aquí aparece el detalle que arruina la primera tentativa de todo el mundo. Un modo de mezcla necesita un destino bien definido, y por defecto el destino es el contenido de toda la ventana dibujado hasta ese momento. Si perforas con DstOut sin más, agujereas lo que hubiera debajo, incluido el fondo de la aplicación. Para acotar la mezcla al nodo hay que forzar que ese nodo se pinte en un búfer aparte, y eso se pide con una estrategia de composición fuera de pantalla.
Box(
Modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
radius = size.minDimension / 3f,
blendMode = BlendMode.DstOut,
)
}
)
flowchart TD N[contenido del nodo] --> B[buffer aislado offscreen] B --> M[se aplica el blendMode dentro del buffer] M --> W[el resultado se compone sobre la ventana] X[sin buffer aislado] --> Y[la mezcla afecta a todo lo pintado antes] style M fill:#a6e3a1,color:#11111b style Y fill:#f38ba8,color:#11111b
Ese búfer es potente y no es gratis: reserva memoria de vídeo proporcional al tamaño del nodo y añade una copia por frame. Es una herramienta de precisión —máscaras, recortes de degradado sobre texto, viñetas— y no algo que convenga esparcir por la jerarquía.
Sombra, elevación y desenfoque
Modifier.shadow proyecta una sombra siguiendo un Shape, y su parámetro elevation no es un radio de desenfoque sino una altura conceptual: el sistema deriva de ella el desplazamiento y la difuminación, e incluso tiñe la sombra ambiental y la direccional por separado. El orden importa, porque shadow recorta el nodo a la forma indicada cuando clip está activo, y colocarlo después de un fondo o antes de él produce resultados distintos.
Por debajo, todo esto vive en graphicsLayer, el modificador que crea una capa de render con propiedades propias: shadowElevation, alpha, rotationZ, scaleX, translationY, clip, shape y renderEffect. Cualquier animación de estas propiedades es especialmente barata porque no toca la composición ni el layout: solo cambia parámetros de una capa que la GPU ya tiene preparada. Y su versión con lambda difiere la lectura del estado hasta la fase de dibujo, exactamente igual que la lambda de drawBehind.
Modifier.graphicsLayer {
scaleX = escala.value
scaleY = escala.value
rotationZ = giro.value
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(20.dp)
clip = true
}
Un matiz que sorprende: alpha por debajo de uno en graphicsLayer también fuerza una capa aislada, porque para atenuar el conjunto hay que componerlo antes como una unidad. Por eso un desvanecimiento sobre un contenedor grande es más caro de lo que parece, y a menudo compensa aplicar la transparencia a los colores de las primitivas en lugar de a la capa entera.
Modifier.blur
Desenfoque real de contenido, disponible desde Android 12. En versiones anteriores no falla, simplemente no hace nada, así que un desenfoque decorativo degrada solo mientras que uno que oculta información sensible necesita alternativa explícita.
Elevación tonal
Material 3 sustituye buena parte de la sombra por un tinte de superficie proporcional a la elevación. Antes de dibujar una sombra a mano, comprueba si un Surface con la elevación correcta ya resuelve la jerarquía visual.
Modifier.clip
Recorta el contenido a un Shape. Con rectángulos y esquinas redondeadas el recorte lo hace el hardware y es casi gratis; con un contorno genérico obliga a un enfoque más costoso.
clipPath en el dibujo
Dentro del scope, clipPath y clipRect acotan lo que pinten las órdenes siguientes. Es la forma más barata de resolver un recorte local sin crear una capa para todo el nodo.
Lo que cuesta cada efecto
Conviene ordenar mentalmente los efectos por precio, porque la API los presenta como si todos costasen lo mismo. Rellenar con un gradiente cuesta prácticamente lo mismo que rellenar con un color: el trabajo lo hace el hardware al interpolar. Recortar con una forma rectangular o redondeada es casi gratis. A partir de ahí el coste sube: un recorte con contorno arbitrario, una capa aislada para mezclar, un desenfoque que muestrea vecindades y una sombra sobre forma genérica implican búferes intermedios, y varios de ellos anidados en la misma rama del árbol multiplican el consumo de memoria de vídeo y el número de cambios de destino de render.
La herramienta para verificarlo no es la intuición sino la barra de renderizado de la GPU y el perfilador de Android Studio. Y la regla práctica que resiste bien: si un efecto se puede conseguir dibujando primitivas dentro de un único scope, casi siempre es preferible a conseguirlo apilando capas con propiedades especiales.
La estética de una interfaz moderna —cristal esmerilado, degradados que respiran, máscaras suaves, sombras difusas— se apoya en un mismo mecanismo físico: capturar el resultado intermedio de un dibujo en un búfer y volver a procesarlo. Ese mecanismo es invisible en la API, donde todo parece un modificador más de una línea, y por eso el desarrollador que no lo conoce compone efectos como quien apila adjetivos, sin percibir que cada uno reserva una textura del tamaño del nodo y añade un cambio de destino de render en la lista de órdenes de la GPU. En un móvil de gama alta la penalización se esconde; en el dispositivo de mil millones de usuarios con memoria compartida y ancho de banda escaso, se manifiesta como fotogramas perdidos exactamente en el momento en que el usuario desliza. La disciplina que separa al gráfico decorativo del gráfico profesional no es saber cuáles son los modos de mezcla, sino saber en qué se convierten: cada blendMode aislado, cada desenfoque y cada recorte de contorno arbitrario es una petición de memoria intermedia, y esas peticiones se suman por rama del árbol, no se comparten. Interiorizarlo cambia la manera de diseñar: en vez de preguntarte cómo consigues un efecto, empiezas a preguntarte cuál es la manera más barata de conseguir algo perceptualmente equivalente, y descubres que un degradado precalculado imita una viñeta cara, que un color con alfa sustituye a una mezcla compleja, y que muchas máscaras se resuelven con una regla de relleno en vez de con una capa entera. La belleza no está reñida con la eficiencia, pero tampoco viene con ella de serie: es el resultado de conocer el precio de cada píxel que pides y decidir, con criterio, cuáles merecen pagarse.
- Crea un
ShaderBrushque genere un gradiente diagonal adaptado al tamaño y compáralo con unBrush.linearGradientde coordenadas fijas al redimensionar el contenedor. - Implementa un efecto de carga tipo shimmer con un gradiente desplazado por un
InfiniteTransition, leyendo el desplazamiento dentro de la lambda de dibujo. - Perfora un agujero circular en una tarjeta con
BlendMode.DstOut; primero sin capa aislada y después conCompositingStrategy.Offscreen, y explica la diferencia. - Mide con la barra de renderizado de la GPU una lista con veinte tarjetas que usan
Modifier.blurfrente a la misma lista sin él, y decide si el efecto merece su coste.