Posts

geometryGroup, compositingGroup y drawingGroup en SwiftUI: tres modificadores que no hacen lo mismo

Arturo Rivas Arias

SwiftUI incluye tres modificadores cuyos nombres parecen describir distintas versiones de una misma operación: geometryGroup(), compositingGroup() y drawingGroup(opaque:colorMode:).

Los tres crean distintos tipos de límites alrededor de una vista, pero actúan en momentos diferentes del proceso con el que SwiftUI calcula y representa la interfaz. El primero interviene en la geometría durante una animación, el segundo determina cómo se aplican los efectos de composición y el tercero rasteriza el contenido en una imagen intermedia: convierte la representación a píxeles estáticos.

Confundirlos puede llevarnos a añadir un drawingGroup() para corregir una animación o a utilizar compositingGroup() como supuesto truco de rendimiento. Ninguna de esas decisiones responde al propósito real de estos modificadores.

Una forma sencilla de distinguirlos

Antes de entrar en los ejemplos, podemos resumir sus diferencias de la siguiente manera:

ModificadorAgrupaResulta útil cuando
geometryGroup()Posición y tamañoLos descendientes parecen separarse mientras su contenedor se mueve o cambia de tamaño
compositingGroup()El resultado visual de varias vistasUna opacidad, un modo de mezcla u otro efecto debe aplicarse al conjunto y no a cada descendiente
drawingGroup()Las operaciones de dibujo compatiblesQueremos rasterizar gráficos complejos en una imagen fuera de pantalla y las mediciones demuestran que compensa

Por tanto, la pregunta no es cuál de ellos es mejor, sino en qué fase se encuentra el problema que queremos resolver.

geometryGroup(): una frontera para la geometría

Cuando animamos la posición o el tamaño de un contenedor, SwiftUI suele propagar el cambio por la jerarquía hasta las vistas completas, es decir, hasta elementos como Text, Image o Shape, que son los que finalmente dibujan contenido.

Este comportamiento permite que el framework combine y optimice muchos cambios. Normalmente produce el resultado esperado, pero puede hacerse visible si la geometría del contenedor y su contenido cambian dentro de la misma animación.

Imaginemos un indicador que se desplaza de un extremo al otro mientras cambia su texto, su icono y su color:

struct DownloadExample: View {
    @State private var isAvailable = false

    var body: some View {
        VStack(spacing: 24) {
            DownloadStatus(isAvailable: isAvailable)
                .frame(
                    maxWidth: .infinity,
                    alignment: isAvailable ? .trailing : .leading
                )

            Button("Cambiar estado") {
                withAnimation(.easeInOut(duration: 1.2)) {
                    isAvailable.toggle()
                }
            }
        }
        .padding()
    }
}

private struct DownloadStatus: View {
    let isAvailable: Bool

    private var color: Color {
        isAvailable ? .green : .orange
    }

    var body: some View {
        Label(
            isAvailable ? "Disponible" : "Descargando",
            systemImage: isAvailable ? "checkmark" : "arrow.down"
        )
        .font(.headline)
        .foregroundStyle(color)
        .padding(.horizontal, 16)
        .padding(.vertical, 10)
        .background(color.opacity(0.15), in: .capsule)
    }
}

Durante la transición, el texto antiguo puede permanecer brevemente cerca del origen, el nuevo aparecer próximo al destino y la cápsula cambiar de tamaño mientras se desplaza. Las distintas piezas están participando en la animación, pero no parecen formar una única unidad visual.

Podemos crear una frontera alrededor del indicador antes de que el frame exterior modifique su alineación:

DownloadStatus(isAvailable: isAvailable)
    .geometryGroup()
    .frame(
        maxWidth: .infinity,
        alignment: isAvailable ? .trailing : .leading
    )

Ahora SwiftUI resuelve la posición y el tamaño en el límite creado por geometryGroup(). El contenido interior recibe esa geometría ya resuelta, de modo que el icono, el texto y el fondo permanecen unidos mientras el indicador se desplaza.

Este modificador no crea un nuevo layout ni sustituye a matchedGeometryEffect. Tampoco sirve para leer medidas como GeometryReader u onGeometryChange. Su función es mucho más concreta: evitar que los cambios geométricos de una vista padre continúen propagándose hasta sus descendientes durante una animación.

geometryGroup() está disponible a partir de iOS 17, iPadOS 17 y macOS 14, entre otros sistemas de la misma generación.

Cuándo merece la pena usar geometryGroup()

Es un buen candidato cuando coinciden estas circunstancias:

  • Una vista padre anima la posición, el tamaño o la alineación de una vista.
  • La vista contiene varios elementos que deberían moverse como un bloque.
  • Las vistas hijas también cambian durante la transición.
  • El resultado intermedio parece deformarse, descomponerse o saltar.

Si la animación ya se ve correctamente, añadirlo no aporta nada. Conviene utilizarlo en el nivel más interior de la jerarquía que necesitemos mantener unido.

compositingGroup(): aplicar efectos al resultado combinado

Una jerarquía visual puede producir resultados diferentes dependiendo de si un efecto se aplica a cada pieza antes de combinarlas o una sola vez después de combinarlas.

El ejemplo más evidente es la opacidad. Supongamos que creamos una marca con varios círculos opacos superpuestos:

private struct OrbitMark: View {
    var body: some View {
        ZStack {
            Circle()
                .fill(.blue)
                .frame(width: 64, height: 64)
                .offset(x: -18)

            Circle()
                .fill(.pink)
                .frame(width: 64, height: 64)

            Circle()
                .fill(.orange)
                .frame(width: 64, height: 64)
                .offset(x: 18)
        }
    }
}

Si reducimos la opacidad del ZStack, SwiftUI puede distribuir el efecto entre sus descendientes. Cada círculo se vuelve translúcido antes de que el resultado se componga y, como consecuencia, en las intersecciones comienzan a verse los círculos que deberían permanecer ocultos.

OrbitMark()
    .opacity(isDisabled ? 0.25 : 1)

compositingGroup() indica que primero debe componerse el contenido del grupo y que los efectos situados fuera de ese límite deben actuar sobre el resultado:

OrbitMark()
    .compositingGroup()
    .opacity(isDisabled ? 0.25 : 1)

Los círculos se dibujan y se solapan con su apariencia original. Después, la opacidad se aplica una sola vez a la marca completa, manteniendo intactas sus intersecciones.

Además de la opacidad, este límite resulta útil con modificadores como blendMode(_:), sombras y determinadas combinaciones de filtros o máscaras. Lo importante no es memorizar una lista de efectos, sino decidir si queremos aplicarlos a los elementos por separado o al resultado que forman entre todos.

El orden de los modificadores cambia el resultado

En SwiftUI, cada modificador envuelve el resultado producido hasta ese momento. Por ello, estas dos expresiones no son equivalentes:

// Primero compone la marca y después reduce su opacidad.
OrbitMark()
    .compositingGroup()
    .opacity(0.25)

// La opacidad queda dentro de la frontera de composición.
OrbitMark()
    .opacity(0.25)
    .compositingGroup()

Podemos pensar en compositingGroup() como una frontera. Los modificadores escritos antes quedan dentro del grupo y los escritos después operan desde fuera sobre el resultado agrupado.

Esto también explica por qué añadir el modificador en un punto arbitrario puede no cambiar nada. La posición exacta dentro de la cadena forma parte de su comportamiento.

drawingGroup(): convertir la jerarquía en una imagen

drawingGroup(opaque:colorMode:) va un paso más allá. SwiftUI representa el contenido compatible en una imagen al margen del cálculo de la pantalla y utiliza esa imagen para la composición final.

Su firma incluye dos parámetros con valores predeterminados:

.drawingGroup(
    opaque: false,
    colorMode: .nonLinear
)

opaque indica si el resultado carece de transparencia. Solo debemos establecerlo a true cuando todos los píxeles del grupo sean completamente opacos. Si el contenido contiene esquinas transparentes, huecos, degradados con alfa o cualquier otra transparencia, el valor correcto es false.

colorMode define el espacio de trabajo utilizado para el renderizado. .nonLinear emplea sRGB no lineal y es el valor predeterminado; .linear utiliza sRGB lineal y puede producir resultados más correctos en determinadas mezclas, degradados y operaciones de iluminación. También existe .extendedLinear, cuyo rango ampliado permite representar componentes fuera del intervalo habitual de sRGB.

Podríamos aplicarlo, por ejemplo, a un sello formado por muchas operaciones de dibujo:

struct RadarSeal: View {
    var body: some View {
        ZStack {
            ForEach(0..<72, id: \.self) { index in
                Capsule()
                    .fill(.mint.opacity(index.isMultiple(of: 3) ? 0.9 : 0.35))
                    .frame(width: 2, height: 14)
                    .offset(y: -70)
                    .rotationEffect(.degrees(Double(index) * 5))
            }

            Circle()
                .fill(.black.gradient)
                .frame(width: 110, height: 110)
                .overlay {
                    Image(systemName: "wave.3.right")
                        .font(.system(size: 38, weight: .semibold))
                        .foregroundStyle(.mint)
                }
                .shadow(color: .mint.opacity(0.5), radius: 14)
        }
        .frame(width: 180, height: 180)
        .drawingGroup(opaque: false, colorMode: .linear)
    }
}

Tras aplicar drawingGroup(), las cápsulas, el círculo, el símbolo y la sombra se procesan como una imagen intermedia. Las siguientes fases del renderizado ya no reciben todas esas operaciones de dibujo por separado.

drawingGroup() no es un botón para mejorar el rendimiento

Rasterizar el contenido también tiene un coste. SwiftUI necesita crear y almacenar la imagen intermedia, y debe volver a generarla cuando el contenido cambia. Una textura de gran tamaño puede aumentar el uso de memoria y una jerarquía que se actualiza continuamente puede hacer que el trabajo fuera de pantalla resulte más demandante incluso que el dibujo original.

Por eso, la presencia de un desenfoque, una sombra o muchas formas no justifica por sí sola el modificador. Tiene más sentido con composiciones gráficas complejas, especialmente si después se transforman como una unidad, y siempre que una medición real muestre una mejora en esa pantalla concreta.

Además, no todo el contenido puede formar parte del resultado rasterizado. SwiftUI puede procesar texto, imágenes, formas y composiciones creadas con sus primitivas de dibujo, pero las vistas implementadas por jerarquías externas, como determinados controles de UIKit o AppKit, vistas web y reproductores multimedia, no son candidatas apropiadas para un drawingGroup().

Para comprobar si aporta una mejora conviene ejecutar una compilación de Release, comparar el tiempo de renderizado antes y después y observar tanto la carga de GPU como la memoria. Optimizar basándonos únicamente en la complejidad aparente de la vista puede trasladar, o incluso aumentar, el coste en lugar de reducirlo.

compositingGroup() y drawingGroup() no son intercambiables

Ambos modificadores pueden hacer que una jerarquía se comporte visualmente como una unidad, pero expresan intenciones distintas.

compositingGroup() establece dónde se combinan los elementos y desde qué límite actúan los efectos gráficos. No estamos pidiendo explícitamente convertir toda la vista en un mapa de bits, sino controlar la semántica de la composición.

drawingGroup(), en cambio, solicita de forma explícita una representación rasterizada fuera de pantalla. Puede cambiar el coste del renderizado y tiene limitaciones sobre el contenido compatible.

Si una opacidad revela elementos que deberían seguir ocultos, el primer candidato es compositingGroup(). Si una ilustración contiene decenas o cientos de operaciones y la depuración señala un cuello de botella de renderizado, podemos probar drawingGroup(). Utilizar el segundo para solucionar el primer problema añade una decisión de rendimiento que probablemente no necesitamos.

Una guía para elegir correctamente

Cuando una interfaz produzca un resultado extraño, estas tres preguntas suelen conducir al modificador adecuado:

  1. ¿El problema aparece mientras el contenedor cambia de posición o tamaño y sus piezas dejan de moverse juntas? En ese caso, probaremos geometryGroup().
  2. ¿El problema aparece cuando aplicamos opacidad, mezcla u otro efecto a elementos superpuestos? Necesitamos revisar el orden de los modificadores y probablemente utilizar compositingGroup().
  3. ¿No existe un error visual, pero una composición con muchas operaciones de dibujo es costosa cuando la analizamos con Instruments? Solo entonces tiene sentido evaluar drawingGroup().

También es posible que no necesitemos ninguno. SwiftUI ya agrupa y optimiza internamente gran parte del renderizado, y estos modificadores están pensados para intervenir cuando queremos establecer una frontera concreta.

Conclusión

Los nombres de geometryGroup(), compositingGroup() y drawingGroup() son parecidos, pero cada uno resuelve un problema en una etapa diferente.

geometryGroup() mantiene unida la geometría de una vista durante determinados cambios animados. compositingGroup() hace que los efectos exteriores trabajen con el resultado combinado de sus descendientes. drawingGroup() transforma las operaciones compatibles en una imagen fuera de pantalla.

Comprender estas fronteras permite corregir artefactos visuales sin recurrir a modificadores al azar y, sobre todo, evita convertir drawingGroup() en una optimización preventiva. En SwiftUI, agrupar geometría, componer capas y rasterizar contenido son decisiones distintas, aunque sus APIs parezcan pertenecer a la misma familia.