← Todos los articulos

Protocolo Layout de SwiftUI: Construir layouts personalizados desde sizeThatFits hasta placeSubviews

iOS 16 añadió el protocolo Layout a SwiftUI, la API pública para construir vistas contenedoras personalizadas que participan en la pasada de layout de SwiftUI1. Antes de Layout, las formas de contenedores personalizados requerían o bien hacks con GeometryReader (que rompen la composición porque solicitan el tamaño completo propuesto) o trabajo personalizado con ViewModifier que pelea contra el sistema. Layout es la respuesta correcta: un protocolo de dos métodos (sizeThatFits y placeSubviews) más extensiones opcionales de espaciado y caché, con un contrato que se integra limpiamente con el modelo de layout de SwiftUI: el padre propone, el hijo dispone.

Este artículo recorre el protocolo siguiendo la documentación de Apple. El enfoque es “qué contrata realmente Layout”, porque el patrón de mal uso (tratar Layout como una herramienta de espacio de coordenadas en lugar de una herramienta de negociación de tamaño) produce layouts que funcionan en una pantalla y fallan en otra, y el artículo del clúster What SwiftUI Is Made Of argumentaba que la arquitectura de SwiftUI se entiende mejor leyendo sus protocolos públicos.

TL;DR

  • Layout es un protocolo con dos métodos requeridos: sizeThatFits(proposal:subviews:cache:) devuelve el tamaño preferido del layout dado lo que propone el padre; placeSubviews(in:proposal:subviews:cache:) posiciona cada hijo llamando a su método place(at:anchor:proposal:)2.
  • El parámetro proposal es un ProposedViewSize con width y height como CGFloats opcionales. nil significa “usa tu tamaño ideal”; un valor finito es la oferta del padre; .infinity significa “usa todo lo que quieras.”
  • Subviews es un typealias de LayoutSubviews, una colección de proxies LayoutSubview. A cada proxy se le puede consultar su tamaño dada cualquier propuesta y colocarlo en cualquier punto. Los proxies son la única manera en que Layout interactúa con los hijos.
  • Los valores de layout personalizados fluyen desde los hijos hacia el padre a través de tipos LayoutValueKey adjuntados mediante .layoutValue(...) en las vistas hijas, legibles desde los subscripts de LayoutSubview dentro de los métodos del layout.
  • El cache sirve para amortizar el cómputo entre sizeThatFits y placeSubviews (cada pasada llama a ambos, a menudo con los mismos valores intermedios). Tipa el caché como un struct que contiene los tamaños precomputados; constrúyelo una vez y reúsalo en ambos métodos.

El contrato del protocolo

Un Layout es un struct (típicamente) que declara dos métodos que el framework de Apple llama durante la pasada de layout2:

struct DiagonalLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Compute and return the size your layout wants
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // Position each subview by calling subview.place(...)
    }
}

Úsalo como un contenedor incorporado:

DiagonalLayout {
    Text("First")
    Text("Second")
    Text("Third")
}

El framework llama a sizeThatFits con el tamaño propuesto desde el padre (un ProposedViewSize), y luego llama a placeSubviews con los límites que se le han concedido al layout. Los dos métodos juntos describen el comportamiento del layout: qué tan grande quiere ser, y dónde va cada hijo dentro de esa asignación.

ProposedViewSize: la oferta del padre

El layout en SwiftUI sigue un contrato de padre-propone-hijo-dispone3. El padre pasa un tamaño propuesto; el hijo devuelve su tamaño real; el padre posiciona al hijo dentro de sus propios límites. Layout participa en este contrato a través de ProposedViewSize:

struct ProposedViewSize {
    var width: CGFloat?
    var height: CGFloat?
}

Los ejes opcionales tienen significado semántico:

  • nil para un eje significa “usa tu tamaño ideal/natural.” Un Text con propuesta .zero devuelve su anchura mínima (un carácter por línea); con propuesta nil devuelve su anchura ideal (una línea, sin envolver).
  • Un valor finito significa “el padre ofrece esta cantidad de espacio; tú decides qué hacer.” Un Text con propuesta de 100pt de anchura puede envolverse, puede usar menos, puede usar exactamente 100.
  • .infinity significa “usa todo lo que quieras.” Un Color con propuesta .infinity toma todo el espacio disponible.

La convención ProposedViewSize.unspecified (width: nil, height: nil) es la solicitud del tamaño ideal; ProposedViewSize.zero es la solicitud del tamaño mínimo; ProposedViewSize.infinity es la solicitud de expansión avariciosa.

El sizeThatFits de un Layout personalizado debe respetar la propuesta: devolver un tamaño que el layout realmente quiera para los límites propuestos, no siempre el mismo valor codificado a fuego. Los tamaños codificados a fuego rompen la capacidad del layout para adaptarse a diferentes contenedores (una vista de tarjeta, una celda de lista, una hoja).

Leer los tamaños de las subviews a través de LayoutSubview

Dentro de sizeThatFits, el layout pregunta a cada hijo qué tamaño quiere para varias propuestas. La consulta pasa por el proxy LayoutSubview4:

func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) -> CGSize {
    let proposed = ProposedViewSize(
        width: proposal.width.map { $0 / CGFloat(subviews.count) },
        height: proposal.height
    )

    let sizes = subviews.map { $0.sizeThatFits(proposed) }
    let totalWidth = sizes.reduce(0) { $0 + $1.width }
    let maxHeight = sizes.map(\.height).max() ?? 0

    return CGSize(width: totalWidth, height: maxHeight)
}

El patrón subviews.map { $0.sizeThatFits(proposal) } es como un layout descubre qué tamaños quieren sus hijos. El método sizeThatFits(_:) del proxy LayoutSubview no es lo mismo que el método del protocolo Layout; es la consulta del proxy sobre el tamaño preferido del hijo dada una propuesta. Los dos comparten nombre porque participan en la misma negociación, pero son capas diferentes del contrato.

Un layout que quiere conocer los tamaños de los hijos llama a proxy.sizeThatFits(_:). Un layout que quiere posicionar a los hijos llama a proxy.place(at:anchor:proposal:) dentro de placeSubviews.

Colocar las subviews

placeSubviews es donde el layout toma decisiones de posicionamiento2:

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) {
    var x = bounds.minX
    let y = bounds.midY

    for subview in subviews {
        let size = subview.sizeThatFits(.unspecified)
        subview.place(
            at: CGPoint(x: x + size.width / 2, y: y),
            anchor: .center,
            proposal: ProposedViewSize(size)
        )
        x += size.width
    }
}

La llamada place(at:anchor:proposal:) posiciona una sola subview. Tres parámetros:

  • at: la posición en el espacio de coordenadas del padre.
  • anchor: qué punto de la subview está en at. .center pone el centro de la subview en at; .topLeading pone allí la esquina superior izquierda.
  • proposal: el tamaño con el que la subview debe renderizar. Pasa el tamaño devuelto por sizeThatFits de la misma subview para honrar su preferencia, o pasa una propuesta personalizada para restringirla.

Cada subview debe ser colocada exactamente una vez por cada llamada a placeSubviews. Saltarse una subview la deja sin posicionar (desaparece del layout renderizado); colocar una dos veces es un error en tiempo de ejecución.

Valores de layout personalizados a través de LayoutValueKey

Cuando un hijo necesita comunicar algo a su layout padre (una prioridad, un span, una categoría), el canal es LayoutValueKey5:

struct PriorityKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

extension View {
    func layoutPriority(_ value: Int) -> some View {
        layoutValue(key: PriorityKey.self, value: value)
    }
}

// Inside the Layout:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let sortedSubviews = subviews.sorted {
        $0[PriorityKey.self] > $1[PriorityKey.self]
    }
    // ... place sortedSubviews
}

El protocolo LayoutValueKey proporciona un canal tipado para la comunicación entre padre e hijo. El hijo adjunta un valor mediante el modificador de valor de layout; el padre lo lee mediante el subscript de LayoutSubview. Cada clave tiene un valor por defecto para las subviews que no especifiquen uno explícitamente.

El patrón es conceptualmente lo que expresan los modificadores incorporados como .layoutPriority(_:). El framework expone ese valor específico mediante una propiedad dedicada priority: Double en LayoutSubview en lugar de a través de un LayoutValueKey público, así que el acceso del proxy para la prioridad de layout es subview.priority en lugar de un subscript de clave. Los layouts personalizados declaran sus propios tipos LayoutValueKey para cualquier otro dato estructurado que necesiten de los hijos.

El parámetro cache

Ambos métodos del layout reciben un parámetro cache: inout. El caché es el lugar del layout para amortizar trabajo entre sizeThatFits y placeSubviews6:

struct DiagonalLayout: Layout {
    struct Cache {
        var sizes: [CGSize]
    }

    func makeCache(subviews: Subviews) -> Cache {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return Cache(sizes: sizes)
    }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
        let totalWidth = cache.sizes.reduce(0) { $0 + $1.width }
        let totalHeight = cache.sizes.reduce(0) { $0 + $1.height }
        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
        var x = bounds.minX
        var y = bounds.minY
        for (subview, size) in zip(subviews, cache.sizes) {
            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .topLeading,
                proposal: ProposedViewSize(size)
            )
            x += size.width
            y += size.height
        }
    }
}

El tipo de cache por defecto es Void. La mayoría de layouts pueden ignorar el caché; se gana su lugar cuando el cómputo del tamaño es genuinamente costoso (mediciones recursivas, decisiones de dimensionado dinámico) y los mismos intermedios alimentan ambos métodos del layout.

makeCache(subviews:) se ejecuta una vez por pasada de layout; updateCache(_:subviews:) se ejecuta cuando las subviews cambian entre pasadas. El patrón permite al layout invalidar correctamente el estado en caché cuando los hijos cambian.

Layouts personalizados comunes que vale la pena construir

Tres patrones que vale la pena construir tú mismo:

Layout de flujo (elementos que se envuelven). Los elementos se envuelven en múltiples filas cuando desbordan la anchura disponible. El HStack de Apple no envuelve. Un Layout personalizado sí puede: medir cada hijo, colocarlos de izquierda a derecha, avanzar a la siguiente fila cuando la anchura de la fila excede la anchura de la propuesta.

Pila diagonal. Los elementos se escalonan diagonalmente (cada hijo posicionado ligeramente abajo y a la derecha del anterior). Útil para UIs de tarjetas apiladas, layouts de previsualización de galería, pilas con sensación de parallax.

Layout circular/de pastel. Elementos dispuestos alrededor de la circunferencia de un círculo. Útil para menús radiales, UIs basadas en tiempo, etiquetas categóricas con espaciado igual.

Cada uno de estos es implementable con sizeThatFits + placeSubviews + (opcionalmente) un caché personalizado. El framework gestiona la negociación padre-propone-hijo-dispone; el desarrollador gestiona las matemáticas del posicionamiento.

Fallos comunes de layout

Tres patrones que producen layouts personalizados rotos:

Tamaños codificados a fuego que ignoran la propuesta. Un layout que siempre devuelve CGSize(width: 200, height: 100) no se adapta a su contenedor. El resultado: el layout se ve bien en el simulador pero se rompe en pantallas más pequeñas, en orientaciones diferentes, o dentro de contenedores redimensionables.

Saltarse subviews en placeSubviews. Cada subview debe ser colocada exactamente una vez por llamada. Un bucle for que tiene un continue para alguna condición deja esas subviews sin posicionar; desaparecen de la salida renderizada.

Usar GeometryReader dentro de los hijos de un Layout personalizado. GeometryReader siempre propone el espacio completo recibido a su contenido, lo que pelea con las propuestas por hijo del layout. La combinación produce tamaños sin sentido. Los layouts personalizados no deben poner GeometryReader dentro de sí mismos; si un hijo necesita conocer su tamaño asignado, el mecanismo de propuesta del protocolo de layout es el canal correcto.

Cuándo recurrir a Layout (y cuándo no)

Tres señales de que un Layout personalizado es la herramienta correcta:

  1. La forma no es expresable mediante composición de HStack/VStack/ZStack/Grid. Layouts circulares, mallas tipo masonry, envolturas de flujo personalizadas. Los primitivos incorporados no pueden componerse en estas formas.
  2. La información por hijo dirige el posicionamiento. Layouts donde los hijos tienen prioridades, pesos o categorías que el padre usa para posicionarlos. LayoutValueKey es el canal correcto.
  3. El dimensionado del layout depende de negociar con los hijos. Layouts que preguntan “¿cuál es la altura más pequeña que cabe en la línea más larga?” o “¿qué anchura da columnas iguales para N hijos?” necesitan acceso a las consultas de subviews.sizeThatFits(...).

Tres señales de que la composición incorporada es suficiente:

  1. Apilado horizontal/vertical/de profundidad estándar. HStack, VStack, ZStack cubren los casos comunes.
  2. Cuadrículas con filas/columnas regulares. Grid y LazyVGrid/LazyHGrid manejan la mayoría de los casos de cuadrícula.
  3. Un poco de posicionamiento por superposición. .overlay, .background, ZStack con alineación cubren la mayoría de los patrones “X encima de Y”.

La regla práctica: no construyas un Layout personalizado para una forma que los incorporados manejan. Constrúyelo cuando la forma esté genuinamente más allá del conjunto de expresión de los incorporados.

Qué significa este patrón para las apps de iOS 26+

Tres conclusiones.

  1. Honra la propuesta en sizeThatFits. Un layout que devuelve el mismo tamaño sin importar proposal no participa correctamente en el sistema de layout de SwiftUI. Lee la propuesta, devuelve un tamaño apropiado a ella.

  2. Usa LayoutValueKey para la comunicación estructurada entre padre e hijo. Pasar datos a través de claves adjuntadas mediante modificadores de vista es el patrón nativo de SwiftUI. No recurras a @Environment o a PreferenceKey personalizados para datos que son específicamente sobre decisiones a nivel de layout; LayoutValueKey es el canal tipado para eso.

  3. Construye un caché solo cuando la medición sea costosa. El caché Void por defecto está bien para la mayoría de los layouts. Recurre a un tipo de caché personalizado solo cuando el mismo cómputo costoso aparece tanto en sizeThatFits como en placeSubviews.

El clúster Apple Ecosystem completo: App Intents tipados; servidores MCP; la cuestión del enrutamiento; Foundation Models; la distinción runtime vs tooling LLM; tres superficies; el patrón de fuente única de verdad; Dos Servidores MCP; hooks para desarrollo Apple; Live Activities; el contrato de runtime de watchOS; internos de SwiftUI; el modelo mental espacial de RealityKit; disciplina de esquema de SwiftData; patrones de Liquid Glass; envío multi-plataforma; la matriz de plataformas; framework Vision; Symbol Effects; inferencia con Core ML; API de Writing Tools; Swift Testing; Privacy Manifest; Accesibilidad como plataforma; tipografía SF Pro; patrones espaciales de visionOS; framework Speech; migraciones de SwiftData; motor de foco de tvOS; internos de @Observable; sobre qué me niego a escribir. El hub está en la serie Apple Ecosystem. Para un contexto más amplio sobre iOS con agentes de IA, consulta la guía de desarrollo de agentes iOS.

FAQ

¿Por qué no usar simplemente GeometryReader?

GeometryReader siempre propone su tamaño completo recibido a su contenido (no tiene opinión sobre lo que su contenido quiere). El resultado es que cualquier vista dentro de un GeometryReader recibe infinity propuesto para los ejes que el reader no restringe, y vistas como Text se dimensionan a sí mismas avariciosamente. La composición pelea consigo misma: el reader pasa sin cambios, el contenido pide tamaño máximo, el layout se rompe. Layout es la herramienta correcta porque permite al desarrollador tomar decisiones explícitas por hijo sobre el tamaño propuesto.

¿Puedo escribir un reemplazo personalizado de HStack?

Sí. Un Layout personalizado equivalente a HStack lee los tamaños preferidos de los hijos, suma sus anchuras, toma la altura máxima y los coloca de izquierda a derecha. El HStack real hace más (espaciado, alineación, resolución de prioridad de layout), pero la forma básica es directa en Layout. El ejercicio es una manera útil de internalizar cómo funciona el protocolo.

¿Cómo soporto .layoutPriority(_:) en mi layout personalizado?

Léelo a través de la propiedad dedicada priority: Double del proxy LayoutSubview: subview.priority. SwiftUI expone .layoutPriority(_:) directamente en el proxy en lugar de a través de un LayoutValueKey público. El valor por defecto es 0. Usa la prioridad cuando distribuyas espacio extra (dáselo preferentemente a los hijos de alta prioridad) o cuando trunques (trunca primero los hijos de baja prioridad).

¿Cuál es la diferencia entre proposal: .infinity y proposal: .zero?

.infinity propone el tamaño máximo en cada eje (width: .infinity, height: .infinity). Los hijos que responden a propuestas avariciosas (como Color) toman todo el espacio disponible. .zero propone el tamaño mínimo (width: 0, height: 0). Los hijos devuelven su tamaño mínimo (Text devuelve el tamaño de su token irrompible más largo). Los dos son puntos finales útiles para medir el rango de dimensionado de los hijos; muchos layouts usan .unspecified (ambos nil) para preguntar “¿cuál es tu tamaño ideal?”.

¿Funciona Layout en watchOS, tvOS y visionOS?

Sí. El protocolo Layout está en el núcleo multiplataforma de SwiftUI. Los layouts personalizados funcionan de la misma manera en iOS, iPadOS, macOS, watchOS, tvOS y visionOS. El artículo del clúster Apple Platform Matrix argumenta que la inclusión de plataformas es una decisión de producto; el mecanismo Layout de SwiftUI es agnóstico a la plataforma para los casos donde aplican múltiples plataformas.

¿Cómo interactúa Layout con los modelos @Observable?

Layout es un struct que no contiene estado observable directamente; no rastrea cambios. Cuando un modelo se actualiza, el body de la vista padre se re-evalúa, lo que hace que el Layout se vuelva a ejecutar con los hijos que produzca el body. El Layout es reactivo a través del body en el que vive, no a través de hooks de observación propios. El artículo del clúster internos de @Observable cubre el lado de la observación.

Referencias


  1. Documentación para desarrolladores de Apple: Layout. La referencia del protocolo que cubre los requisitos de sizeThatFits y placeSubviews, más los hooks opcionales makeCache, updateCache, spacing y de alineación explícita. 

  2. Documentación para desarrolladores de Apple: sizeThatFits(proposal:subviews:cache:) y placeSubviews(in:proposal:subviews:cache:). Los dos métodos requeridos del protocolo Layout

  3. Documentación para desarrolladores de Apple: ProposedViewSize. El tipo de dos CGFloat opcionales que lleva la propuesta de tamaño del padre, con los valores convencionales .unspecified, .zero e .infinity

  4. Documentación para desarrolladores de Apple: LayoutSubview. El tipo proxy que representa una vista hija dentro de los métodos de Layout, con sizeThatFits(_:) para consultar tamaños preferidos y place(at:anchor:proposal:) para posicionar. 

  5. Documentación para desarrolladores de Apple: LayoutValueKey y layoutValue(key:value:). El canal tipado para datos a nivel de layout de hijo a padre, accedido mediante subscript en LayoutSubview

  6. Apple Developer: Composing custom layouts with SwiftUI. La guía de Apple que cubre el caché, las guías de alineación y cuándo recurrir a Layout frente a contenedores incorporados. 

Artículos relacionados

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min de lectura

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 min de lectura

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min de lectura