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

Canvas: dibujo inmediato de alto rendimiento

El modo inmediato de SwiftUI: GraphicsContext, resolución de símbolos, capas y filtros, animación con TimelineView, y el cálculo honesto de cuándo un lienzo compensa frente a un árbol de vistas.

⏱ 18 min

Un árbol de vistas es una estructura persistente: cada nodo tiene identidad, estado, participación en el layout, respuesta al tacto y una representación en la jerarquía de accesibilidad. Ese lujo cuesta memoria y tiempo de diffing, y hay dominios —visualizaciones densas, partículas, editores de trazos, mapas de calor— donde pagarlo por cada uno de diez mil elementos es sencillamente insostenible. Canvas es la respuesta de SwiftUI: un modo inmediato donde no existen nodos, solo un procedimiento que dibuja píxeles dentro de una sola vista. Cambias identidad por velocidad, y saber cuándo ese intercambio es favorable es una decisión de arquitectura, no de estilo.

🎯 Al terminar esta lección sabrás
  • Comprender el modelo inmediato y la API de GraphicsContext.
  • Resolver símbolos, texto e imágenes para dibujar contenido SwiftUI dentro del lienzo.
  • Componer con capas, recortes, transformaciones y filtros sin salir del contexto.
  • Razonar el coste comparado y decidir entre vistas, drawingGroup y Canvas.

El modelo inmediato

Canvas recibe un cierre al que el sistema pasa un contexto gráfico y el tamaño disponible. Ese cierre se ejecuta entero cada vez que hay que redibujar, y lo que no dibujes en esa pasada simplemente no existe:

Canvas(opaque: true, colorMode: .extendedLinear, rendersAsynchronously: false) { ctx, size in
    for i in 0..<5_000 {
        let x = Double(i % 100) / 100 * size.width
        let y = Double(i / 100) / 50 * size.height
        let punto = Path(ellipseIn: CGRect(x: x, y: y, width: 3, height: 3))
        ctx.fill(punto, with: .color(.blue.opacity(0.6)))
    }
}

Los tres parámetros del inicializador tienen consecuencias medibles. opaque promete que rellenarás todo el área, lo que permite al compositor saltarse la mezcla alfa. colorMode decide el espacio de color intermedio: extendedLinear es correcto para degradados y mezclas físicamente plausibles, mientras que nonLinear ahorra conversiones cuando solo pintas colores planos. rendersAsynchronously permite que el dibujo ocurra fuera del hilo principal, a costa de que un fotograma pueda mostrarse con contenido ligeramente desfasado.

El contexto ofrece las operaciones esperables —fill, stroke, draw— más un conjunto de transformaciones acumulables. Todas ellas mutan el contexto, de modo que el patrón idiomático es copiarlo antes de modificarlo para no contaminar el resto del dibujo:

Canvas { ctx, size in
    var capa = ctx                       // copia de valor: lo que cambie no escapa
    capa.translateBy(x: size.width / 2, y: size.height / 2)
    capa.rotate(by: .degrees(30))
    capa.addFilter(.shadow(color: .black.opacity(0.3), radius: 4, y: 2))
    capa.stroke(aguja, with: .color(.mint), lineWidth: 2)

    ctx.fill(esfera, with: .radialGradient(paleta,
                                           center: .init(x: size.width / 2,
                                                         y: size.height / 2),
                                           startRadius: 0, endRadius: 80))
}

Que GraphicsContext sea un tipo valor no es un detalle de implementación sino la garantía que hace manejable el modo inmediato: en un lienzo con estado global compartido, olvidar restaurar una transformación corrompe todo lo dibujado después, y ese es el error clásico de las API de dibujo procedural. Aquí basta con no propagar la copia.

Símbolos, capas y filtros

El lienzo no está aislado del resto de SwiftUI. Puedes declarar vistas como símbolos y resolverlas para dibujarlas tantas veces como quieras, con el coste de construcción pagado una sola vez:

Canvas { ctx, size in
    let simbolo = ctx.resolveSymbol(id: 1)!
    for punto in posiciones {
        ctx.draw(simbolo, at: punto)
    }
    let etiqueta = ctx.resolve(Text("N").font(.caption).bold())
    ctx.draw(etiqueta, at: CGPoint(x: size.width / 2, y: 12))
} symbols: {
    Image(systemName: "leaf.fill").foregroundStyle(.green).tag(1)
}

resolve convierte texto o imágenes en objetos ya medidos y listos para rasterizar; resolveSymbol hace lo mismo con vistas arbitrarias identificadas por tag. A partir de ahí, la composición avanzada vive en tres mecanismos: drawLayer, que abre un contexto hijo cuyo resultado se compone al final como una unidad; clip y clipToLayer, que restringen el área de pintado con un trazo o con la opacidad de otra capa; y addFilter, que aplica desenfoques, sombras y mezclas de color a todo lo que se dibuje después.

🧱

drawLayer

Aísla un subdibujo para aplicarle opacidad, filtros o mezcla como un todo, igual que un grupo en un editor gráfico.

✂️

clipToLayer

Usa la luminancia o el alfa de otro dibujo como máscara. Es el enmascarado por contenido, sin vistas intermedias.

🌫️

addFilter

Desenfoques, sombras, matrices de color y modos de mezcla aplicados al contexto, no a cada elemento.

Animar sin vistas

Como el lienzo no tiene nodos, no hay animación implícita: nada se interpola solo. La animación se consigue redibujando en función del tiempo, y el componente diseñado para eso es TimelineView, que despierta al cierre en una cadencia declarada.

TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { contexto in
    Canvas { ctx, size in
        let t = contexto.date.timeIntervalSinceReferenceDate
        for particula in sistema.particulas {
            let p = particula.posicion(en: t, dentro: size)
            ctx.opacity = particula.vida(en: t)
            ctx.fill(Path(ellipseIn: CGRect(origin: p, size: .init(width: 4, height: 4))),
                     with: .color(particula.color))
        }
    }
}

Las variantes de cadencia importan: .animation sincroniza con el refresco de pantalla, .periodic fija un intervalo propio y .explicit toma una secuencia de fechas concreta. En dispositivos con pantalla adaptativa conviene declarar el intervalo mínimo realmente necesario, porque pedir sesenta fotogramas por segundo para una animación que solo cambia dos veces por segundo es una factura de batería que nadie percibe como mejora.

flowchart TD
A[Cuantos elementos dibujo] --> B[Son decenas]
B -->|si| C[Vistas normales con identidad y gestos]
B -->|no| D[Son cientos o miles]
D -->|necesito hit testing por elemento| E[Vistas mas drawingGroup]
D -->|solo pixeles| F[Canvas en modo inmediato]
F --> G[Accesibilidad y toques a mano]
style C fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b
style G fill:#f38ba8,color:#11111b

El cálculo de coste

La comparación honesta no es “vistas contra lienzo” sino qué se paga en cada caso. Un árbol de vistas paga construcción de valores, comparación estructural en cada actualización, cálculo de layout y, en el caso de vistas con capa propia, memoria de textura. Un lienzo paga una única pasada de rasterización proporcional a la cantidad de trazos, sin diffing y sin layout interno.

El punto de cruce está en el orden de las decenas altas de elementos, pero la cifra importa menos que la naturaleza del contenido. Tres criterios deciden mejor que cualquier número:

  • Interactividad por elemento. Si cada punto necesita su propio gesto, foco o transición, las vistas te dan gratis lo que en el lienzo tendrías que implementar con un índice espacial propio.
  • Frecuencia de cambio. Datos que cambian sesenta veces por segundo favorecen el lienzo: reconstruir un árbol completo por fotograma es lo que hunde el rendimiento, no dibujar.
  • Accesibilidad. Un Canvas es opaco para VoiceOver. Debes describirlo con accessibilityLabel o exponer una representación alternativa con accessibilityChildren, y ese trabajo no es opcional en producción.

Entre ambos extremos existe una tercera vía: drawingGroup, que mantiene el árbol de vistas pero fuerza a rasterizarlo fuera de pantalla en una única textura acelerada por Metal. Conservas identidad y composición declarativa, y evitas el coste de compositar decenas de capas separadas. Es la primera medida a probar cuando el problema es la composición y no la construcción del árbol.

⚠️
El lienzo no participa en el layout de sus contenidos

Dentro de un Canvas no hay sistema de layout: nada se centra solo, nada se adapta al tamaño del texto ni se invierte en idiomas de derecha a izquierda. Toda esa aritmética pasa a ser tuya, incluido consultar la configuración de tipografía dinámica y la dirección de escritura para colocar las etiquetas. Es una de las partidas de coste que no se ven al comparar solo los fotogramas por segundo.

El lienzo compra velocidad con la moneda de la semántica

Cuando bajas al modo inmediato no estás solo cambiando de API: estás renunciando a que el sistema sepa qué hay en pantalla. Un círculo dibujado con fill no es un objeto para el motor de layout, ni para el de animación, ni para VoiceOver, ni para el sistema de gestos; es un residuo de color en un búfer. Esa renuncia es perfectamente legítima, y en visualización densa es la única salida viable, pero tiene que ser deliberada y acotada. La arquitectura sana casi nunca es un lienzo que ocupa la pantalla entera: es un lienzo para la capa densa —los diez mil puntos, la estela de partículas, el mapa de calor— rodeado de vistas de verdad para todo lo que el usuario debe poder tocar, enfocar o escuchar leído en voz alta. Medir antes de bajar y volver a subir en cuanto el volumen deja de justificarlo es lo que distingue una optimización de una amputación.

⚔️ Mide antes de creer
  1. Dibuja mil círculos con ForEach y vistas normales, y mide el tiempo de fotograma con el instrumento de animación de Xcode.
  2. Reescribe exactamente lo mismo con Canvas y compara las dos mediciones sobre un dispositivo real, no en el simulador.
  3. Prueba la vía intermedia: añade drawingGroup a la versión con vistas y anota qué cambia.
  4. Anima el lienzo con TimelineView y experimenta con intervalos de sesenta, treinta y diez fotogramas por segundo.
  5. Añade una descripción accesible al lienzo y verifica con VoiceOver que la información sigue siendo alcanzable sin ver la pantalla.