Primitivas y paths: de la línea a la forma propia
El catálogo geométrico del scope de dibujo: líneas, rectángulos, arcos y su parametrización angular; la construcción de un Path con segmentos y curvas de Bézier, las reglas de relleno, las operaciones booleanas entre caminos, y cómo convertir una figura en un Shape reutilizable para recortar y elevar.
Con el receptor de dibujo en la mano llega el vocabulario. Compose ofrece dos niveles de expresión geométrica que conviene no mezclar: las primitivas, figuras cerradas que se emiten en una llamada, y el Path, un objeto que acumula segmentos y curvas hasta describir cualquier contorno imaginable. Las primeras resuelven el ochenta por ciento de los casos con una línea de código; el segundo es la puerta a las formas que ningún catálogo trae hechas, y también el objeto que después reutilizarás para recortar, animar y medir.
- Manejar el catálogo de primitivas y la parametrización de arcos y esquinas redondeadas.
- Construir un
Pathcon segmentos, curvas cuadráticas y cúbicas, y entender el punto de control. - Distinguir las reglas de relleno y combinar caminos con operaciones booleanas.
- Encapsular una figura en un
Shapepropio para recortar, elevar y reutilizar.
El catálogo de primitivas
Las primitivas cubren la geometría regular y se distinguen por cómo declaran su posición. drawRect y drawImage aceptan una esquina superior izquierda y un tamaño; drawCircle y drawOval trabajan alrededor de un centro; drawLine toma dos puntos; drawPoints recibe una lista y un modo que decide si son puntos sueltos, un polígono abierto o segmentos por parejas. Todas comparten los mismos parámetros de estilo: color o pincel, opacidad, style de relleno o trazo, y modo de mezcla.
El arco es la primitiva más rica y la que más confusión genera. drawArc se define sobre el rectángulo que circunscribe la elipse, con un ángulo de inicio y un ángulo de barrido, no un ángulo final. El cero está a las tres en punto y los grados crecen en sentido horario, de modo que un anillo de progreso que deba arrancar arriba necesita empezar en menos noventa. El parámetro useCenter decide entre una porción de tarta —los extremos se unen al centro— y un simple segmento de aro.
Canvas(Modifier.size(160.dp)) {
val grosor = 14.dp.toPx()
val recuadro = Size(size.width - grosor, size.height - grosor)
val esquina = Offset(grosor / 2f, grosor / 2f)
drawArc(
color = Color(0xFF313244),
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
topLeft = esquina,
size = recuadro,
style = Stroke(width = grosor),
)
drawArc(
brush = Brush.sweepGradient(listOf(Color(0xFF89B4FA), Color(0xFFF5C2E7))),
startAngle = -90f,
sweepAngle = 240f,
useCenter = false,
topLeft = esquina,
size = recuadro,
style = Stroke(width = grosor, cap = StrokeCap.Round),
)
}
Fíjate en el encogimiento del rectángulo: como el trazo se dibuja centrado sobre la geometría, la mitad del grosor sobresale hacia fuera y quedaría recortada por el borde del área. Restar el grosor al tamaño y desplazar el origen medio grosor es el gesto que evita ese corte, y aparece en prácticamente todos los indicadores circulares que escribirás. El mismo razonamiento explica el redondeo de esquinas: drawRoundRect acepta un CornerRadius con radios horizontal y vertical independientes, lo que permite esquinas elípticas además de circulares.
Un Stroke no es solo un grosor. cap decide cómo termina una línea abierta —a hueso, redondeada o cuadrada—, join cómo se resuelve el vértice donde se encuentran dos segmentos, miter cuánto puede alargarse una punta antes de recortarse, y pathEffect permite discontinuidades. PathEffect.dashPathEffect produce líneas de puntos y PathEffect.cornerPathEffect redondea todos los vértices de un camino sin tocar su geometría, dos atajos que evitan cálculos manuales tediosos.
Path: acumular geometría
Un Path es un objeto mutable que guarda una secuencia de instrucciones de contorno. Se construye con un cursor imaginario: moveTo lo teletransporta sin dibujar, lineTo traza un segmento hasta un punto, close cierra la subruta uniendo con el origen, y las variantes relativeLineTo o relativeMoveTo interpretan sus argumentos como desplazamientos respecto a la posición actual en lugar de coordenadas absolutas.
Las curvas son la razón de existir del objeto. quadraticTo describe una curva de Bézier cuadrática con un punto de control, y cubicTo una cúbica con dos. La intuición geométrica es sencilla y vale la pena fijarla: la curva nunca pasa por sus puntos de control, sino que sale del punto inicial dirigiéndose hacia el primero y llega al final procediendo del último. Los puntos de control son imanes que tiran del trazo, y colocarlos fuera de la figura produce esa tensión suave que distingue una curva bien hecha de un polígono disimulado.
flowchart LR M[moveTo origen] --> L[lineTo segmentos] L --> Q[quadraticTo con un control] Q --> C[cubicTo con dos controles] C --> K[close cierra la subruta] K --> D[drawPath emite el contorno] style D fill:#a6e3a1,color:#11111b
fun burbuja(size: Size, radio: Float, pico: Float): Path = Path().apply {
val cuerpo = Rect(0f, 0f, size.width, size.height - pico)
addRoundRect(RoundRect(cuerpo, CornerRadius(radio, radio)))
moveTo(radio * 2f, cuerpo.bottom)
lineTo(radio * 2f + pico, size.height)
lineTo(radio * 3f + pico, cuerpo.bottom)
close()
}
Ese ejemplo mezcla dos formas de alimentar un camino: los métodos de alto nivel addRect, addRoundRect, addOval y addArc, que vuelcan una primitiva entera de golpe, y el trazado manual del pico. Ambas conviven en el mismo objeto y el resultado se pinta con una sola llamada a drawPath.
Rellenos, huecos y operaciones
Cuando un camino contiene varias subrutas superpuestas surge la pregunta de qué queda dentro y qué fuera. La respuesta la da la regla de relleno, expuesta en fillType. Con PathFillType.NonZero, la opción por defecto, cuenta el sentido de recorrido y dos círculos concéntricos trazados en la misma dirección producen un disco macizo. Con PathFillType.EvenOdd, cada cruce alterna dentro y fuera, y esos mismos círculos producen un anillo con el centro hueco. Es el mecanismo más barato para vaciar una forma sin recurrir a mezclas de píxeles.
Para combinaciones más ambiciosas está Path.op, que aplica operaciones booleanas entre dos caminos y deposita el resultado en un tercero. PathOperation.Union los funde, Difference recorta el segundo del primero, Intersect conserva lo común y Xor lo exclusivo. Con una diferencia se construye en dos líneas la muesca lateral de un billete o el hueco de un icono recortado, sin razonar sobre trigonometría.
val ticket = Path().apply {
val base = Path().apply { addRoundRect(RoundRect(0f, 0f, w, h, CornerRadius(24f))) }
val muescas = Path().apply {
addOval(Rect(center = Offset(0f, h / 2f), radius = 18f))
addOval(Rect(center = Offset(w, h / 2f), radius = 18f))
}
op(base, muescas, PathOperation.Difference)
}
Los efectos de trazo ofrecen un tercer camino para modificar geometría sin recalcularla. PathEffect.dashPathEffect recibe un patrón de intervalos y un desfase, y animar ese desfase produce la clásica marcha de hormigas de una selección; PathEffect.cornerPathEffect redondea todos los vértices de golpe, lo que convierte un polígono anguloso en una figura orgánica sin tocar una sola coordenada; y PathEffect.chainPathEffect reparte un sello a lo largo del contorno.
val patron = PathEffect.dashPathEffect(
intervals = floatArrayOf(12.dp.toPx(), 8.dp.toPx()),
phase = desfaseAnimado,
)
drawPath(contorno, Color(0xFFF9E2AF), style = Stroke(width = 2.dp.toPx(), pathEffect = patron))
Un camino terminado sirve además para medir. PathMeasure recibe un Path, informa de su longitud total y sabe extraer el fragmento comprendido entre dos distancias mediante getSegment. Ese es el mecanismo canónico para animar un trazo que se dibuja solo: en lugar de recalcular la geometría en cada frame, se mide una vez y se extrae el porcentaje que corresponde al progreso actual.
Un Path es un objeto pesado que asigna memoria nativa. Crearlo en cada frame de una animación fabrica basura a sesenta hercios y provoca las pausas de recolección que el usuario percibe como tirones. Constrúyelo en remember, si depende solo de datos, o en el bloque exterior de drawWithCache, si depende del tamaño; y si necesitas mutarlo, reutiliza la instancia llamando a reset en lugar de crear otra.
De la figura al Shape reutilizable
Un dibujo directo pinta, pero no recorta ni proyecta sombra. Para eso hay que ascender de nivel y encapsular la geometría en un Shape, la interfaz que Compose usa en clip, background, border, shadow y los componentes de Material. Implementarla consiste en devolver un Outline a partir del tamaño, la dirección de escritura y la densidad; los tres argumentos que faltaban para que la figura fuese verdaderamente portátil.
class BurbujaShape(private val pico: Dp) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline = Outline.Generic(
burbuja(size, radio = with(density) { 12.dp.toPx() }, pico = with(density) { pico.toPx() })
)
}
Devolver Outline.Generic con un camino arbitrario es lo más flexible, pero conviene saber que existen Outline.Rectangle y Outline.Rounded: cuando la figura encaja en uno de esos dos casos, el sistema puede recortar y proyectar sombra por hardware, mucho más barato que con un camino genérico. El parámetro layoutDirection no es decorativo: una forma asimétrica debe reflejarse en interfaces de derecha a izquierda, y ese es el punto donde se decide.
El salto conceptual de esta lección no está en las curvas de Bézier, que son matemáticas de los años sesenta bien empaquetadas, sino en lo que significa encapsular una figura en un Shape. Cuando dibujas directamente estás pintando en un rectángulo concreto; cuando implementas un Shape estás declarando una función que, dado cualquier tamaño, cualquier densidad y cualquier dirección de escritura, produce el contorno correspondiente. Esa diferencia parece administrativa y es profunda: convierte una ilustración en un componente del sistema de diseño. Un dibujo directo no puede recortar el contenido de otro nodo, no puede proyectar una sombra coherente con la elevación de Material, no puede animarse cuando el usuario pulsa un botón y las esquinas se redondean, y no puede reutilizarse en un tema para que todos los diálogos de la aplicación compartan silueta. Un Shape puede hacer las cuatro cosas porque no es un resultado, es una regla. La misma lección se repite en toda la programación gráfica seria: la potencia no viene de saber pintar una figura bonita, sino de aislar la geometría en un objeto independiente de la resolución y del momento, capaz de responder a preguntas —“¿qué contorno tendrías si midieras esto?”— en lugar de limitarse a dejar tinta. Quien interioriza esto empieza a escribir formas paramétricas donde antes copiaba coordenadas de un diseño, y descubre que su interfaz sobrevive intacta al cambio de un plegable, a una fuente enorme por accesibilidad y a una traducción al árabe. La geometría no era el objetivo: era el vehículo para llegar a una abstracción que se adapta sola.
- Dibuja un anillo de progreso con
drawArcque arranque arriba y respete el grosor del trazo sin recortarse en los bordes. - Escribe un
Pathde burbuja de diálogo con el pico apuntando abajo y curvas cuadráticas en la unión, y píntalo condrawPath. - Recorta dos muescas circulares en un rectángulo redondeado usando
Path.opconPathOperation.Difference, y compara el resultado con el que obtienes cambiandofillTypeaEvenOdd. - Encapsula esa figura en un
Shapey aplícala aModifier.clipsobre una imagen; observa si la sombra deModifier.shadowsigue el contorno o el rectángulo.