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
Layoutes 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étodoplace(at:anchor:proposal:)2.- El parámetro
proposales unProposedViewSizeconwidthyheightcomo CGFloats opcionales.nilsignifica “usa tu tamaño ideal”; un valor finito es la oferta del padre;.infinitysignifica “usa todo lo que quieras.” Subviewses un typealias deLayoutSubviews, una colección de proxiesLayoutSubview. 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
LayoutValueKeyadjuntados mediante.layoutValue(...)en las vistas hijas, legibles desde los subscripts deLayoutSubviewdentro de los métodos del layout. - El
cachesirve para amortizar el cómputo entresizeThatFitsyplaceSubviews(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:
nilpara un eje significa “usa tu tamaño ideal/natural.” UnTextcon propuesta.zerodevuelve su anchura mínima (un carácter por línea); con propuestanildevuelve su anchura ideal (una línea, sin envolver).- Un valor finito significa “el padre ofrece esta cantidad de espacio; tú decides qué hacer.” Un
Textcon propuesta de 100pt de anchura puede envolverse, puede usar menos, puede usar exactamente 100. .infinitysignifica “usa todo lo que quieras.” UnColorcon propuesta.infinitytoma 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á enat..centerpone el centro de la subview enat;.topLeadingpone allí la esquina superior izquierda.proposal: el tamaño con el que la subview debe renderizar. Pasa el tamaño devuelto porsizeThatFitsde 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:
- 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.
- La información por hijo dirige el posicionamiento. Layouts donde los hijos tienen prioridades, pesos o categorías que el padre usa para posicionarlos.
LayoutValueKeyes el canal correcto. - 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:
- Apilado horizontal/vertical/de profundidad estándar.
HStack,VStack,ZStackcubren los casos comunes. - Cuadrículas con filas/columnas regulares.
GridyLazyVGrid/LazyHGridmanejan la mayoría de los casos de cuadrícula. - Un poco de posicionamiento por superposición.
.overlay,.background,ZStackcon 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.
-
Honra la propuesta en
sizeThatFits. Un layout que devuelve el mismo tamaño sin importarproposalno participa correctamente en el sistema de layout de SwiftUI. Lee la propuesta, devuelve un tamaño apropiado a ella. -
Usa
LayoutValueKeypara 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@Environmento aPreferenceKeypersonalizados para datos que son específicamente sobre decisiones a nivel de layout;LayoutValueKeyes el canal tipado para eso. -
Construye un caché solo cuando la medición sea costosa. El caché
Voidpor 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 ensizeThatFitscomo enplaceSubviews.
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
-
Documentación para desarrolladores de Apple:
Layout. La referencia del protocolo que cubre los requisitos desizeThatFitsyplaceSubviews, más los hooks opcionalesmakeCache,updateCache,spacingy de alineación explícita. ↩ -
Documentación para desarrolladores de Apple:
sizeThatFits(proposal:subviews:cache:)yplaceSubviews(in:proposal:subviews:cache:). Los dos métodos requeridos del protocoloLayout. ↩↩↩ -
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,.zeroe.infinity. ↩ -
Documentación para desarrolladores de Apple:
LayoutSubview. El tipo proxy que representa una vista hija dentro de los métodos deLayout, consizeThatFits(_:)para consultar tamaños preferidos yplace(at:anchor:proposal:)para posicionar. ↩ -
Documentación para desarrolladores de Apple:
LayoutValueKeyylayoutValue(key:value:). El canal tipado para datos a nivel de layout de hijo a padre, accedido mediante subscript enLayoutSubview. ↩ -
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
Layoutfrente a contenedores incorporados. ↩