El scope de dibujo: Canvas, drawBehind y drawWithContent
La fase de dibujo como un territorio propio: el receptor DrawScope y su sistema de coordenadas en píxeles, la diferencia real entre Canvas, drawBehind, drawWithContent y drawWithCache, y la lectura diferida de estado que permite repintar un frame sin recomponer ni volver a medir.
Cuando dibujas a mano en Compose dejas de describir qué hay en pantalla y pasas a decidir cómo se rellena cada píxel de un rectángulo. Es un cambio de registro completo: se acaban los composables, los modificadores de layout y la negociación de tamaños, y aparece un receptor con primitivas geométricas y un sistema de coordenadas. Pero antes de la primera línea de tinta hay una decisión más importante que cualquier primitiva: en qué fase del pipeline cuelgas tu dibujo. Elegirla bien es la diferencia entre una animación que cuesta un repintado y otra que arrastra al árbol entero.
- Situar la fase de dibujo en el pipeline de Compose y entender su jerarquía de invalidación.
- Manejar
DrawScope: coordenadas, unidades, estilo y transformaciones anidadas. - Distinguir
Canvas,drawBehind,drawWithContentydrawWithCache, y saber cuándo usar cada uno. - Repintar leyendo el estado dentro de la lambda de dibujo, sin recomponer ni volver a medir.
Tres fases y una jerarquía de invalidación
Compose produce cada frame en tres pasadas sucesivas. La composición ejecuta las funciones componibles y decide qué nodos existen y con qué parámetros. El layout recorre ese árbol midiendo y colocando cada nodo. El dibujo visita los nodos ya colocados y emite las órdenes de pintura. Lo decisivo no es la secuencia, sino que cada fase se puede invalidar por separado y que la invalidación fluye siempre hacia abajo: si recompones, casi seguro remides y repintas; si remides, repintas; pero si solo invalidas el dibujo, las dos primeras fases ni se enteran.
flowchart LR E[cambio de estado] --> C[composicion] C --> L[layout] L --> D[dibujo] E -.solo dibujo.-> D style D fill:#a6e3a1,color:#11111b style C fill:#f38ba8,color:#11111b
Esa asimetría es la economía entera del nivel. Un progreso que se anima sesenta veces por segundo no tiene por qué costar sesenta recomposiciones: si el valor solo se consulta en el momento de pintar, el sistema salta directamente a la última pasada. Toda la biblioteca de dibujo de Compose está diseñada alrededor de esa posibilidad, y las lecciones siguientes la darán por supuesta.
El receptor de dibujo
Cada lambda de dibujo se ejecuta con DrawScope como receptor implícito. Ese receptor te da tres cosas: las dimensiones del área asignada mediante size y center, un catálogo de primitivas —drawRect, drawCircle, drawLine, drawArc, drawPath, drawImage— y un conjunto de transformaciones que alteran el espacio de coordenadas de lo que dibujes a continuación.
El sistema de coordenadas tiene el origen en la esquina superior izquierda y el eje vertical crece hacia abajo, como en casi toda la informática gráfica y al revés que en las matemáticas del instituto. Las unidades son píxeles físicos, no dp: dibujar un círculo de radio 12f produce un círculo distinto en cada densidad de pantalla. Como DrawScope también implementa Density, la conversión está siempre a mano y debe volverse un reflejo.
Canvas(modifier = Modifier.size(120.dp)) {
val radio = size.minDimension / 2f
drawCircle(
color = Color(0xFF89B4FA),
radius = radio,
center = center,
style = Stroke(width = 6.dp.toPx()),
)
withTransform({ rotate(degrees = 35f, pivot = center) }) {
drawLine(
color = Color(0xFFF5C2E7),
start = center,
end = Offset(center.x, center.y - radio),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round,
)
}
}
Dos detalles de ese fragmento merecen atención. El parámetro style decide si la primitiva se rellena —Fill, el valor por defecto— o se traza con un grosor dado —Stroke—, y esa elección afecta al tamaño real ocupado: un trazo se dibuja centrado en la geometría, de modo que la mitad del grosor sobresale del radio nominal y puede quedar recortada si el círculo llena el área justa. Y withTransform acota la transformación al bloque, restaurando el espacio de coordenadas al salir; sus hermanos sueltos translate, rotate, scale, inset y clipRect se comportan igual, cada uno como un paréntesis en la conversación con el lienzo.
Casi todo dibujo que escribas debería expresarse en función de size, nunca en constantes absolutas. Un indicador que calcula su radio como size.minDimension / 2f funciona igual en un móvil pequeño y en una tablet; uno que escribe 60f se rompe en cuanto alguien cambia el modificador de tamaño. El área de dibujo la decide el layout, no tú, y tratarla como un dato de entrada es lo que hace tu componente reutilizable.
Elegir dónde colgar el dibujo
Existen cuatro puertas de entrada a la fase de dibujo, y confundirlas es el error más común al empezar. No se diferencian en lo que puedes pintar —el receptor es el mismo— sino en su relación con el contenido del nodo y con la caché.
Canvas
Un composable que no es más que un Spacer con un drawBehind encima. Al no tener contenido, necesita que le des un tamaño explícito o un modificador que lo llene; si no, mide cero y no verás nada.
Modifier.drawBehind
Dibuja detrás del contenido del nodo al que se aplica. Es la vía natural para fondos decorativos, subrayados, indicadores y auras que deben quedar por debajo del texto o del icono.
Modifier.drawWithContent
Te entrega el control del orden: dentro de la lambda invocas drawContent cuando quieras. Pintar antes es un fondo, pintar después es una capa superpuesta, y no invocarlo nunca es ocultar el nodo sin sacarlo del layout.
Modifier.drawWithCache
Separa la preparación del pintado. El bloque exterior se ejecuta solo cuando cambian el tamaño o las claves que lees, y devuelve un onDrawBehind o onDrawWithContent que se ejecuta en cada frame con los objetos ya construidos.
De los cuatro, drawWithContent es el que abre más posibilidades porque convierte el contenido del nodo en una orden más de tu secuencia. Un degradado de desvanecimiento en el borde de una lista, un velo de deshabilitado, una marca de agua encima de una imagen o un texto recortado por un pincel se resuelven decidiendo dónde cae la llamada al contenido.
// Desvanecer el borde inferior de una lista sin tocar su contenido.
Modifier.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color(0xFF11111B)),
startY = size.height - 48.dp.toPx(),
endY = size.height,
),
)
}
drawWithCache es el que más se infrautiliza y el que más rendimiento devuelve. Construir un Path, un Brush de gradiente o un ImageBitmap es caro y genera basura; hacerlo dentro de la lambda de dibujo significa repetirlo en cada frame de una animación. El bloque de caché existe precisamente para que ese trabajo ocurra una vez por cambio de tamaño y el frame se limite a emitir órdenes.
Modifier.drawWithCache {
val trazo = Path().apply {
moveTo(0f, size.height)
lineTo(size.width / 2f, 0f)
lineTo(size.width, size.height)
close()
}
val pincel = Brush.verticalGradient(listOf(Color(0xFFA6E3A1), Color(0xFF94E2D5)))
onDrawBehind {
drawPath(trazo, pincel)
}
}
Leer el estado en la fase correcta
Aquí se cierra el círculo con la primera sección. Compose registra las lecturas de estado y anota en qué fase ocurrieron; cuando ese estado cambia, invalida solo desde esa fase hacia abajo. Leer un color en el cuerpo de un composable y pasarlo como parámetro obliga a recomponer en cada cambio. Leerlo dentro de la lambda de dibujo —que se ejecuta en la última fase— reduce el coste a un repintado.
// El valor se lee en composicion: cada frame de la animacion recompone.
val color by animateColorAsState(if (activo) Verde else Gris, label = "fondo")
Box(Modifier.background(color))
// El valor se lee en la fase de dibujo: la animacion solo repinta.
val color = animateColorAsState(if (activo) Verde else Gris, label = "fondo")
Box(Modifier.drawBehind { drawRect(color.value) })
Queda una puerta trasera que conviene conocer aunque se use poco. drawIntoCanvas entrega el lienzo nativo de la plataforma, con el que se accede a APIs que el receptor de Compose no expone —cierta manipulación de tipografía, algunos efectos heredados o código gráfico ya escrito para el sistema de vistas—. Es un puente legítimo para migraciones, pero sale del modelo de fases y de la seguridad de tipos de Compose, así que la norma sensata es tratarlo como último recurso y no como comodidad.
La diferencia entre ambos bloques no es estilística. En el primero, la delegación con by desempaqueta el valor durante la composición y ata la animación al ciclo completo; en el segundo, se conserva el State y su lectura se difiere al momento de pintar. El mismo truco explica por qué Modifier.offset con lambda es preferible a su versión con valores para desplazamientos animados: mueve la lectura de la composición al layout. Toda la API moderna de Compose está sembrada de esta dualidad, y reconocerla es una habilidad transferible a cualquier animación que escribas.
La tentación al llegar a los gráficos es medir tu progreso por el catálogo de primitivas que dominas: arcos, curvas, degradados, mezclas. Pero el conocimiento que de verdad separa a quien dibuja bien en Compose de quien solo dibuja es de otra naturaleza, y es económico antes que estético. Cada frame tiene un presupuesto de unos ocho milisegundos a ciento veinte hercios, y ese presupuesto se gasta en tres cajones distintos cuyo precio es radicalmente asimétrico: recomponer implica ejecutar código Kotlin, comparar parámetros y reconciliar un árbol; medir implica recorrer ese árbol negociando restricciones; pintar implica, en el mejor de los casos, empaquetar unas cuantas órdenes y mandarlas a la GPU. Cuando un desarrollador se queja de que su animación va a tirones, en la inmensa mayoría de los casos el problema no está en lo que dibuja sino en dónde leyó el número que cambia. Una barra de progreso que actualiza un Float en la composición pone en marcha la maquinaria entera sesenta veces por segundo; la misma barra leyendo ese Float dentro de drawBehind mueve píxeles y nada más. Interiorizar esto reordena la manera de programar interfaces: dejas de preguntarte qué componente uso y empiezas a preguntarte en qué fase quiero que viva este cambio, que es la pregunta que se hacen los motores gráficos desde hace décadas. Compose no inventó esa disciplina, pero la puso al alcance de la mano con una API tan cómoda que resulta fácil ignorarla. El día en que miras un modificador y ves, además de su efecto visual, la fase que invalida, has dejado de usar Compose para empezar a entenderlo. Y a partir de ahí las primitivas se aprenden en una tarde.
- Escribe un indicador circular animado en dos versiones: una que lea el progreso en el cuerpo del composable y otra que lo lea dentro de
drawBehind. - Activa el inspector de composiciones de Android Studio y compara el contador de recomposiciones de ambas mientras la animación corre.
- Convierte la segunda versión a
drawWithCache, construyendo elBrushy elPathen el bloque exterior; comprueba cuántas veces se ejecuta ese bloque al redimensionar. - Sustituye
drawBehindpordrawWithContenty coloca un velo semitransparente sobre el contenido; razona por qué el orden de la llamada adrawContentcambia el resultado.