wandres.dev
DIBUJO Y GRÁFICOS · Canvas, Shape, Charts

Shape y Path: geometría propia y animable

El protocolo Shape como función de un rectángulo a un trazo: construir Path con líneas, arcos y curvas de Bézier, animarlo con animatableData y VectorArithmetic, y componer formas con operaciones booleanas, trim e InsettableShape.

⏱ 18 min

Toda la capa de dibujo de SwiftUI descansa sobre una abstracción de una sencillez casi matemática: una forma es una función pura que, dado un rectángulo, devuelve un trazo. No hay estado, no hay lienzo, no hay orden de dibujo; hay una descripción geométrica que el sistema resolverá cuando conozca el tamaño real. Entender Shape a ese nivel —como morfismo del espacio de rectángulos al espacio de trazos— es lo que convierte el dibujo personalizado de un truco puntual en una herramienta de composición con las mismas garantías de resolución independiente, animación e interpolación que el resto del framework.

🎯 Al terminar esta lección sabrás
  • Interiorizar Shape como función pura de CGRect a Path y sus consecuencias de layout.
  • Dominar las primitivas de Path: líneas, arcos y curvas de Bézier cuadráticas y cúbicas.
  • Animar geometría con animatableData, VectorArithmetic y AnimatablePair.
  • Componer con operaciones booleanas, trim y el contrato de InsettableShape.

Shape como función pura

El protocolo tiene un único requisito, y ese requisito lo explica todo:

struct Triangulo: Shape {
    func path(in rect: CGRect) -> Path {
        var p = Path()
        p.move(to: CGPoint(x: rect.midX, y: rect.minY))
        p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        p.closeSubpath()
        return p
    }
}

Tres propiedades se derivan de esa firma. Primera: la forma no sabe su tamaño hasta que el sistema de layout se lo propone, así que todo cálculo debe expresarse en función de rect. Escribir coordenadas absolutas —un 120 en vez de rect.midX— rompe la independencia de resolución y convierte la forma en un dibujo que solo funciona en un tamaño. Segunda: Shape refina View, de modo que una forma es también una vista, y su comportamiento de layout por defecto es voraz: acepta cualquier tamaño propuesto y lo ocupa entero. Tercera: al ser pura, es memoizable, interpolable y componible; el framework puede llamar a path(in:) cuantas veces quiera sin efectos observables.

El sistema de coordenadas es el de Core Graphics adaptado a UIKit: origen en la esquina superior izquierda, eje y creciente hacia abajo. Es el detalle que más despista al venir de la geometría analítica, donde los ángulos crecen en sentido antihorario; aquí un ángulo de cero apunta a la derecha y crece en el sentido de las agujas del reloj.

💡
Trabaja siempre en proporciones

Un patrón robusto es normalizar: calcula con fracciones de rect.width y rect.height, o define un lado de referencia con min(rect.width, rect.height) cuando la forma deba permanecer isótropa. Así la misma forma sirve para un icono de 24 puntos y para una ilustración a pantalla completa sin una sola línea condicional.

El vocabulario de Path

Path es un tipo valor que envuelve un CGPath inmutable y ofrece un constructor incremental. Sus primitivas cubren todo el espacio de curvas representables en Core Graphics:

Path { p in
    p.move(to: CGPoint(x: 0, y: 100))
    p.addLine(to: CGPoint(x: 60, y: 20))
    p.addQuadCurve(to: CGPoint(x: 140, y: 20),
                   control: CGPoint(x: 100, y: -40))
    p.addCurve(to: CGPoint(x: 200, y: 100),
               control1: CGPoint(x: 180, y: 20),
               control2: CGPoint(x: 200, y: 60))
    p.addArc(center: CGPoint(x: 100, y: 100), radius: 40,
             startAngle: .degrees(0), endAngle: .degrees(180),
             clockwise: false)
    p.closeSubpath()
}

addQuadCurve traza una Bézier de grado dos con un punto de control; addCurve una cúbica con dos. La diferencia práctica es de continuidad: encadenar cúbicas permite mantener tangentes coincidentes en las uniones y obtener transiciones suaves, mientras que las cuadráticas bastan para redondeos simples y cuestan menos de teselar.

🧭

Subtrazos

Cada move abre un subtrazo nuevo. Un mismo Path puede contener varios, y la regla de relleno decide qué queda dentro.

🔀

Regla de relleno

FillStyle con eoFill activa la regla par-impar; por defecto se usa la de bobinado distinto de cero. Es la palanca para hacer agujeros.

🧮

Transformaciones

applying compone el trazo con una CGAffineTransform, útil para repetir un motivo rotado sin recalcular la geometría.

Animar geometría con animatableData

Aquí está la bisagra conceptual. El motor de animación de SwiftUI no interpola trazos: interpola vectores. Toda forma es Animatable y expone una propiedad animatableData que debe conformar a VectorArithmetic, es decir, ser un espacio vectorial con suma, resta, escalado y magnitud. Por defecto vale EmptyAnimatableData, y por eso una forma con parámetros propios salta en vez de animarse hasta que declaras el puente:

struct Sector: Shape {
    var inicio: Double        // en grados
    var fin: Double

    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(inicio, fin) }
        set { inicio = newValue.first; fin = newValue.second }
    }

    func path(in rect: CGRect) -> Path {
        let radio = min(rect.width, rect.height) / 2
        var p = Path()
        p.move(to: CGPoint(x: rect.midX, y: rect.midY))
        p.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
                 radius: radio,
                 startAngle: .degrees(inicio - 90),
                 endAngle: .degrees(fin - 90),
                 clockwise: false)
        p.closeSubpath()
        return p
    }
}

Durante la animación el sistema fija animatableData en cada fotograma y vuelve a invocar path(in:). Dos consecuencias de ingeniería: el cálculo del trazo se ejecuta a la frecuencia de refresco, de modo que debe ser barato y sin asignaciones innecesarias; y cualquier parámetro que no viaje dentro de animatableData cambiará de golpe. Para tres o más magnitudes se anidan pares, AnimatablePair dentro de AnimatablePair, o se define un tipo propio conforme a VectorArithmetic, que suele ser más legible.

flowchart TD
A[Estado cambia dentro de withAnimation] --> B[SwiftUI interpola animatableData]
B --> C[Fija el valor del fotograma en la forma]
C --> D[Invoca path in rect otra vez]
D --> E[Rasteriza el trazo resultante]
E --> F[Coste por fotograma proporcional al trazo]
style B fill:#cba6f7,color:#11111b
style F fill:#fab387,color:#11111b

Componer: booleanas, trim e InsettableShape

Las formas se combinan con un álgebra de conjuntos que evita tener que resolver intersecciones a mano: union, intersection, subtracting y symmetricDifference devuelven una forma nueva a partir de dos. Es la vía natural para agujeros, recortes y siluetas compuestas.

let placa = RoundedRectangle(cornerRadius: 24)
    .subtracting(Circle().size(width: 40, height: 40).offset(x: 12, y: 12))

let progreso = Circle()
    .trim(from: 0, to: 0.72)
    .stroke(style: StrokeStyle(lineWidth: 8, lineCap: .round))

let borde = Capsule()
    .strokeBorder(.tint, lineWidth: 6)   // exige InsettableShape

trim reparametriza el trazo por longitud de arco normalizada y es la técnica canónica para animar un dibujo que se traza solo: basta animar el extremo final de cero a uno. Y la distinción entre stroke y strokeBorder merece precisión: stroke centra el grosor sobre el trazo, de modo que la mitad se sale del rectángulo asignado; strokeBorder primero reduce la forma hacia dentro mediante inset(by:) y luego la traza, por lo que el resultado queda contenido. Ese inset(by:) es justamente el requisito de InsettableShape, y conformarlo en tus formas propias cuesta unas pocas líneas cuando la geometría admite un desplazamiento hacia el interior bien definido.

Una forma no es un dibujo: es una descripción

La tentación al llegar aquí es tratar Shape como un lienzo en miniatura donde uno pinta. El cambio de mentalidad que separa el dibujo amateur del profesional es aceptar que estás escribiendo una función matemática, no un procedimiento gráfico. De esa pureza se derivan todos los superpoderes: como no hay estado, el sistema puede llamarla en cualquier momento y en cualquier tamaño; como la salida depende solo del rectángulo, la forma es automáticamente independiente de resolución y correcta en cualquier densidad de pantalla; como los parámetros viven en un espacio vectorial, la animación se reduce a interpolar coordenadas y volver a evaluar. La disciplina que impone esto es real: nada de leer relojes dentro de path(in:), nada de aleatoriedad no sembrada, nada de coordenadas absolutas. A cambio obtienes composición sin límite, y ese es el contrato que hace que la geometría declarativa escale a interfaces completas en lugar de quedarse en un adorno.

⚔️ Construye tu vocabulario geométrico
  1. Escribe una Shape que dibuje un polígono regular de n lados, con n como parámetro y todo el cálculo relativo a rect.
  2. Hazla animable: expón el número de lados o el ángulo de rotación en animatableData y comprueba la interpolación con withAnimation.
  3. Sustituye un lado recto por una addQuadCurve y observa cómo cambia la silueta al mover el punto de control.
  4. Construye un anillo de progreso con trim y stroke, y compáralo con la versión hecha mediante subtracting de dos círculos.
  5. Conforma tu forma a InsettableShape y verifica que strokeBorder ya no se sale del marco asignado.