Crear interfaces que se adapten correctamente a diferentes tamaños de ventana se ha convertido en una necesidad cada vez más importante dentro del ecosistema Apple. SwiftUI lleva años ofreciendo herramientas para construir interfaces flexibles, pero durante mucho tiempo muchas soluciones han terminado recurriendo a GeometryReader, tamaños calculados manualmente o valores almacenados como estado.
containerRelativeFrame() ofrece una alternativa mucho más declarativa cuando lo único que necesitamos es que una vista mida su tamaño en relación con el contenedor disponible. No necesitamos observar cambios de geometría, almacenar medidas ni introducir estado adicional: describimos la relación entre la vista y su contenedor y dejamos que SwiftUI la resuelva dentro del propio sistema de layout.
Disponible desde iOS 17, macOS 14, tvOS 17 y watchOS 10, este modificador dispone de tres variantes principales. Con ellas podemos hacer que una vista ocupe por completo una dimensión del contenedor, dividir el espacio en varias porciones o calcular un tamaño personalizado a partir del espacio disponible.
Qué considera SwiftUI un contenedor
Antes de utilizar containerRelativeFrame() hay que entender qué significa exactamente contenedor para esta API. SwiftUI no utiliza necesariamente la vista padre inmediata.
Entre los contenedores compatibles están:
- La ventana en iPadOS o macOS, o la pantalla en iOS.
- Una columna del
NavigationSplitView. - Un
NavigationStack. - Una pestaña de
TabView. - Una vista desplazable como
ScrollViewoList.
Esto implica que un HStack o un VStack situado justo por encima de nuestra vista no se convierte automáticamente en la referencia. SwiftUI continúa ascendiendo por la jerarquía hasta encontrar el contenedor compatible más cercano.
Además, la dimensión proporcionada por containerRelativeFrame() ya tiene en cuenta los márgenes correspondientes al área segura del contenedor. En un ScrollView, los márgenes configurados mediante contentMargins() también forman parte del cálculo del espacio que realmente tiene disponible el contenido.
Ocupar una dimensión completa del contenedor
La variante más sencilla recibe uno o varios ejes:
.containerRelativeFrame(.horizontal)
Con .horizontal, la vista utiliza el ancho disponible del contenedor. Con .vertical, utiliza su altura. También podemos solicitar ambos ejes:
.containerRelativeFrame([.horizontal, .vertical])
Un caso especialmente útil son las interfaces paginadas. Por ejemplo, podemos crear una introducción donde cada pantalla ocupe exactamente el ancho visible de un ScrollView horizontal:
struct OnboardingView: View {
let pages: [OnboardingPage]
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(pages) { page in
OnboardingPageView(page: page)
.containerRelativeFrame(.horizontal)
}
}
}
.scrollTargetBehavior(.paging)
}
}
Lo importante es que LazyHStack no es el contenedor utilizado para calcular el ancho. SwiftUI continúa buscando y encuentra el ScrollView. Cada página recibe así el ancho visible del área desplazable.
Esto resulta especialmente interesante cuando cambia el tamaño de la ventana. No tenemos que escuchar ningún evento ni recalcular manualmente el ancho: SwiftUI vuelve a resolver el layout usando el nuevo tamaño del contenedor.
Por qué no es lo mismo que .frame(maxWidth: .infinity)
A primera vista podría parecer que esto ya se podía conseguir mediante:
.frame(maxWidth: .infinity)
Pero ambas APIs expresan cosas distintas.
frame(maxWidth: .infinity) indica que una vista está dispuesta a aceptar todo el espacio que su padre inmediato le proponga. containerRelativeFrame(.horizontal), en cambio, busca expresamente el tamaño de un contenedor compatible dentro de la jerarquía.
La diferencia se vuelve importante dentro de un ScrollView. En el eje de desplazamiento, el contenido puede recibir una propuesta sin un límite equivalente al ancho visible de la pantalla. Por ello, intentar convertir una tarjeta en una página simplemente usando maxWidth: .infinity no describe realmente que queremos igualar el ancho de la región visible del ScrollView.
containerRelativeFrame() sí expresa exactamente esa intención.
Dividir el espacio en varias porciones
🧮 La segunda variante permite dividir una dimensión del contenedor en un número concreto de partes:
.containerRelativeFrame(
.horizontal,
count: 3,
span: 2,
spacing: 12
)
Aquí aparecen tres conceptos:
countindica en cuántas porciones se divide el espacio disponible.spanindica cuántas de esas porciones ocupará la vista.spacingindica el espacio que SwiftUI debe tener en cuenta entre las porciones.
El cálculo conceptual es el siguiente:
let availableWidth = containerWidth - spacing * CGFloat(count - 1)
let columnWidth = availableWidth / CGFloat(count)
let itemWidth = columnWidth * CGFloat(span)
+ spacing * CGFloat(span - 1)
Un detalle muy importante es que containerRelativeFrame() no añade el espaciado visual. Ese valor solo se utiliza para calcular correctamente el tamaño de cada elemento. Debemos aplicar el mismo spacing al contenedor que distribuye las vistas.
Por ejemplo, podemos construir una galería horizontal donde cada fotografía ocupe dos de las tres porciones disponibles y quede visible una parte de la siguiente:
struct PhotoGalleryView: View {
let photos: [Photo]
var body: some View {
let spacing: CGFloat = 12
ScrollView(.horizontal) {
LazyHStack(spacing: spacing) {
ForEach(photos) { photo in
PhotoCard(photo: photo)
.containerRelativeFrame(
.horizontal,
count: 3,
span: 2,
spacing: spacing
)
}
}
}
.contentMargins(.horizontal, 20)
}
}
Este patrón evita utilizar anchos fijos como 280 o fórmulas basadas en UIScreen.main.bounds.width. En un iPhone estrecho, una ventana de iPad o una aplicación ejecutándose en macOS, el mismo código mantiene la proporción definida respecto al espacio realmente disponible.
contentMargins() y containerRelativeFrame() combinan especialmente bien
Los márgenes de contenido son una parte importante de este tipo de interfaces. Si queremos que una tarjeta ocupe todo el ancho visible menos 20 puntos a cada lado, no necesitamos restarlos manualmente.
Podemos escribir:
ScrollView(.horizontal) {
LazyHStack(spacing: 20) {
ForEach(items) { item in
DetailCard(item: item)
.containerRelativeFrame(.horizontal)
}
}
}
.contentMargins(.horizontal, 20)
SwiftUI calcula el marco relativo utilizando el área disponible para el contenido después de aplicar esos márgenes. El resultado es mucho más resistente a cambios de tamaño que una expresión manual como:
.frame(width: UIScreen.main.bounds.width - 40)
Además, UIScreen.main.bounds representa un cálculo bastante pobre y propenso a errores en un mundo donde una aplicación puede vivir en ventanas cuyo tamaño cambia y no coincide con el tamaño completo de la pantalla.
Calcular una proporción personalizada
La tercera variante proporciona un bloque que recibe la longitud disponible del contenedor y el eje que SwiftUI está calculando:
.containerRelativeFrame(.horizontal) { length, axis in
length * 0.75
}
El valor devuelto se convierte en la longitud de la vista para ese eje.
Esto permite expresar reglas que no encajan directamente en una división mediante count y span. Por ejemplo, podemos hacer que un panel de reproducción ocupe el 60 % de la altura disponible dentro de un contenedor de navegación:
struct PlayerView: View {
var body: some View {
NavigationStack {
AlbumArtworkView()
.containerRelativeFrame(.vertical) { length, _ in
length * 0.6
}
.aspectRatio(1, contentMode: .fit)
.padding()
.frame(maxHeight: .infinity)
.navigationTitle("Reproduciendo")
}
}
}
Como solo hemos solicitado el eje vertical, el ancho continúa resolviéndose de forma normal dentro del layout de SwiftUI.
También podemos actuar de forma distinta dependiendo del eje:
RoundedRectangle(cornerRadius: 24)
.containerRelativeFrame([.horizontal, .vertical]) { length, axis in
switch axis {
case .horizontal:
length * 0.8
case .vertical:
length * 0.45
}
}
Esta variante sigue siendo declarativa: no estamos observando la geometría y reaccionando posteriormente a ella, sino proporcionando a SwiftUI una regla para calcular el tamaño durante el proceso de layout.
Cuándo usar onGeometryChange() en su lugar
containerRelativeFrame() y onGeometryChange() pueden trabajar con dimensiones, pero resuelven problemas distintos.
containerRelativeFrame() es apropiado cuando la geometría sirve directamente para determinar el tamaño de una vista.
onGeometryChange(), por el contrario, es útil cuando necesitamos observar una geometría ya resuelta y convertirla en otro dato que afecte al comportamiento de la aplicación.
Por ejemplo, si queremos detectar cuándo un elemento está visible en más de un 75 % dentro de un ScrollView para iniciar automáticamente la reproducción de un vídeo, necesitamos observar su posición y su intersección con la región visible. Ese problema no consiste en dimensionar la vista, por lo que onGeometryChange() encaja mejor.
Apple también advierte de que la geometría puede cambiar con mucha frecuencia, especialmente dentro de vistas desplazables. Por eso conviene transformar esa información en el valor mínimo que realmente necesitamos y evitar actualizaciones innecesarias de grandes partes de la aplicación.
¿Y GeometryReader?
GeometryReader sigue siendo una herramienta válida, pero tampoco debería ser la solución automática cada vez que necesitamos conocer un tamaño.
Su contenido recibe un GeometryProxy, lo que permite consultar dimensiones y coordenadas, pero GeometryReader también participa como contenedor dentro del layout y puede modificar la forma en la que se propone el espacio a sus hijos. En diseños sencillos, utilizarlo únicamente para obtener el ancho disponible puede añadir más complejidad de la necesaria.
Si nuestro objetivo es simplemente decir «esta tarjeta debe ocupar el 80 % del contenedor», esto:
CardView()
.containerRelativeFrame(.horizontal) { width, _ in
width * 0.8
}
expresa mejor la intención que envolver todo el contenido en un GeometryReader, extraer proxy.size.width y calcular el marco manualmente.
GeometryReader sigue teniendo sentido cuando realmente necesitamos trabajar con coordenadas, posiciones o información geométrica más compleja. La clave está en utilizar la herramienta que corresponda al problema.
Una API especialmente adecuada para interfaces redimensionables
El beneficio de containerRelativeFrame() aumenta a medida que dejamos de pensar en un único tamaño de pantalla. Una interfaz SwiftUI puede ejecutarse en diferentes orientaciones, ventanas de iPad, macOS, distintas columnas de NavigationSplitView o regiones cuyo tamaño cambia durante la ejecución.
El enfoque tradicional de leer el ancho de la pantalla y derivar a partir de él los tamaños de los elementos mezcla dos conceptos que ya no tienen por qué coincidir: el tamaño físico de la pantalla y el espacio que el contenedor ofrece realmente a nuestra vista.
containerRelativeFrame() trabaja con este segundo concepto. La vista no necesita conocer el dispositivo, la orientación ni el tamaño de la ventana. Solo necesita describir cómo quiere relacionarse con el espacio disponible.
Cuándo utilizar cada variante
Como regla práctica, podemos elegir entre las tres variantes de esta forma:
containerRelativeFrame(.horizontal)o.verticalcuando una vista debe ocupar completamente una dimensión disponible.containerRelativeFrame(_:count:span:spacing:)cuando queremos que varias vistas compartan el espacio en proporciones regulares.containerRelativeFrame(_:alignment:_:)cuando necesitamos una relación matemática personalizada con el tamaño del contenedor.
Y si lo que necesitamos no es dimensionar una vista sino reaccionar a su posición, visibilidad o geometría ya calculada, entonces APIs como onGeometryChange() serán una opción más adecuada.
Conclusión
👨💻 containerRelativeFrame() es una de esas APIs de SwiftUI cuyo valor aumenta cuando se comprende el problema que intenta resolver. No sustituye a frame, GeometryReader ni onGeometryChange(), pero evita utilizarlos para situaciones en las que solo queremos expresar una relación de tamaño entre una vista y su contenedor.
Frente a calcular dimensiones manualmente, leer el tamaño de la pantalla o almacenar medidas en estado, el modificador mantiene el cálculo dentro del sistema de layout de SwiftUI. El resultado es un código más declarativo, más fácil de entender y, sobre todo, preparado para interfaces que ya no pueden asumir que siempre van a ejecutarse dentro de un rectángulo de tamaño fijo.