De las size classes al espacio disponible: interfaces realmente adaptativas en SwiftUI
Arturo Rivas Arias
Durante años, una de las formas más habituales de crear interfaces adaptativas en SwiftUI ha consistido en consultar horizontalSizeClass. Si el valor era .compact, mostrábamos una interfaz estrecha; si era .regular, aprovechábamos el espacio con columnas, paneles laterales o controles adicionales.
Esta asociación nunca fue completamente exacta, pero funcionaba lo bastante bien como para convertirse en una regla práctica: compact significaba iPhone y regular, iPad. Las ventanas redimensionables de iPadOS ya hacían aguas con esa equivalencia. Con iOS 27, las aplicaciones de iPhone redimensionables en iPad y mediante iPhone Mirroring en macOS terminan de romperla.
Una aplicación puede continuar ejecutándose bajo el idiom de iPhone y conservar una horizontalSizeClass compacta aunque su ventana sea mucho más ancha que la pantalla de un iPhone. El valor no está roto ni se trata necesariamente de un error de una beta: simplemente nunca prometió representar una medida continua de la anchura disponible.
Qué representa realmente una size class
Una size class es una clasificación contextual y aproximada que el sistema propaga mediante el entorno. Sirve para comunicar las características del contenedor en el que se presenta una vista, pero no equivale a un número de puntos ni mantiene una correspondencia fija con un dispositivo.
struct CollectionView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .regular {
CollectionWithInspector()
} else {
CompactCollection()
}
}
}
Este código presupone que .regular significa «hay anchura suficiente para mostrar un inspector». Sin embargo, son dos conceptos diferentes. La size class describe la interpretación que hace el sistema del entorno, mientras que la decisión de introducir un panel adicional depende del tamaño concreto que ha recibido esta vista.
El problema no consiste en utilizar horizontalSizeClass, sino en pedirle una precisión que no ofrece. Sigue siendo útil cuando queremos participar en la semántica del sistema o adaptar comportamientos generales de navegación y presentación. Para un observador propio —por ejemplo, pasar de una a dos columnas a partir de 720 puntos— conviene observar la geometría real del contenedor.
El dispositivo ya no define el lienzo
Apple insiste en este cambio de modelo en la sesión Modernize your UIKit app de la WWDC26. Una aplicación de iPhone puede redimensionarse libremente cuando se refleja en un Mac y también cuando se ejecuta en un iPad. Aun así, puede conservar el user interface idiom de teléfono.
Por tanto, estas comprobaciones han dejado de ser una base fiable para tomar decisiones de layout:
// El dispositivo no determina el espacio de esta vista.
UIDevice.current.userInterfaceIdiom == .pad
// La pantalla principal puede no ser la pantalla de la escena.
UIScreen.main.bounds.width > 700
// La orientación tampoco describe la forma de una ventana redimensionable.
windowScene.interfaceOrientation.isLandscape
En iPhone Mirroring, por ejemplo, la orientación de interfaz puede seguir siendo vertical aunque la ventana tenga una proporción horizontal. En iPad, una aplicación puede ocupar una fracción de la pantalla, compartirla con otras ventanas o trasladarse a una pantalla externa. Preguntar qué dispositivo aloja la aplicación aporta menos información que preguntar cuánto espacio ha recibido la vista que estamos componiendo.
La fuente de verdad adecuada ya no es la pantalla, sino la escena, la ventana y, en último término, el contenedor inmediato de cada componente.
Primero, dejar que SwiftUI intente encajar el contenido
No todas las interfaces adaptativas necesitan un observador manual. ViewThatFits permite declarar varias representaciones por orden de preferencia y deja que SwiftUI seleccione la primera cuyo tamaño ideal cabe en el espacio propuesto.
Imaginemos una barra de acciones para una biblioteca de audio. Cuando hay sitio queremos mostrar texto e iconos; en una anchura menor, solo los símbolos:
struct LibraryActions: View {
var body: some View {
ViewThatFits(in: .horizontal) {
HStack {
Button("Reproducir", systemImage: "play.fill") {
playAll()
}
Button("Descargar", systemImage: "arrow.down.circle") {
downloadAll()
}
Button("Compartir", systemImage: "square.and.arrow.up") {
shareCollection()
}
}
HStack {
Button("Reproducir", systemImage: "play.fill") {
playAll()
}
.labelStyle(.iconOnly)
Button("Descargar", systemImage: "arrow.down.circle") {
downloadAll()
}
.labelStyle(.iconOnly)
Button("Compartir", systemImage: "square.and.arrow.up") {
shareCollection()
}
.labelStyle(.iconOnly)
}
}
}
}
Según la documentación de ViewThatFits, el contenedor comprueba las vistas hijas en el orden declarado y selecciona la primera cuyo tamaño ideal cabe en los ejes indicados. El código no necesita conocer la anchura, el dispositivo ni la orientación.
Esta solución funciona especialmente bien cuando existen varias representaciones naturales de un mismo componente. No es tan apropiada si la aplicación necesita conocer el modo de presentación seleccionado, si el cambio depende de varias condiciones de negocio o si ambas alternativas comunican arquitecturas de navegación distintas.
Breakpoints basados en el tamaño real
Cuando sí necesitamos una regla explícita, onGeometryChange permite transformar la geometría de una vista en un valor Equatable y reaccionar únicamente cuando ese valor cambia. En lugar de guardar cada variación de anchura durante el redimensionado, podemos convertirla directamente en un modo de layout.
private enum DashboardLayoutMode: Equatable {
case narrow
case wide
}
struct ListeningDashboard: View {
@State private var layoutMode: DashboardLayoutMode = .narrow
var body: some View {
let layout = switch layoutMode {
case .narrow:
AnyLayout(VStackLayout(spacing: 20))
case .wide:
AnyLayout(HStackLayout(alignment: .top, spacing: 24))
}
layout {
ListeningSummary()
RecentSessions()
}
.padding()
.onGeometryChange(for: DashboardLayoutMode.self) { proxy in
proxy.size.width >= 720 ? .wide : .narrow
} action: { newMode in
layoutMode = newMode
}
.animation(.smooth, value: layoutMode)
}
}
La transformación solo produce dos valores. Aunque el usuario arrastre continuamente el borde de la ventana, el estado cambia al cruzar el umbral, no por cada punto recorrido. Esto reduce actualizaciones innecesarias y expresa la intención mejor que almacenar un CGFloat completo.
AnyLayout borra el tipo concreto del layout y permite alternar entre HStackLayout y VStackLayout sin destruir el estado de las subvistas. Apple presentó esta API junto con ViewThatFits y el protocolo Layout en Compose custom layouts with SwiftUI. Ambas están disponibles desde iOS 16, al igual que la versión retrocompatible de onGeometryChange en los SDK actuales.
El valor de 720 puntos no debería convertirse en otra constante universal. Es una decisión del componente: el ancho mínimo a partir del cual sus dos bloques se muestran correctamente, teniendo en cuenta márgenes, contenido localizado, Dynamic Type y tamaños mínimos razonables. Otro componente de la misma pantalla puede necesitar un umbral diferente.
Evitar que los breakpoints sustituyan al sistema de layout
Medir el espacio disponible no implica escribir una versión móvil, otra para tablet y otra para escritorio. SwiftUI ya cuenta con herramientas que resuelven gran parte de la adaptación de manera continua:
frame(minWidth:idealWidth:maxWidth:)expresa límites y preferencias sin imponer un tamaño absoluto.ViewThatFitselige entre composiciones con diferentes tamaños ideales.Gridy los stacks distribuyen el espacio entre sus hijos.Layoutpermite implementar contenedores que negocian propuestas de tamaño directamente.AnyLayoutcambia la estrategia de colocación conservando la identidad y el estado de las subvistas.- Las prioridades de layout, el truncado y los tamaños mínimos ayudan a que el contenido responda antes de necesitar otra composición.
Los breakpoints siguen siendo útiles, pero deberían representar cambios estructurales reales. Si la única diferencia entre dos modos son unos pocos puntos de separación, normalmente es mejor expresar esa flexibilidad mediante las herramientas del motor de layout.
Size classes y geometría no son alternativas excluyentes
Utilizar el tamaño real no significa eliminar todas las lecturas de horizontalSizeClass. Cada fuente responde a una pregunta distinta:
| Información | Pregunta que responde | Uso apropiado |
|---|---|---|
userInterfaceIdiom | ¿Bajo qué semántica de plataforma se ejecuta la interfaz? | Integraciones o convenciones específicas de una plataforma, no el layout por anchura |
horizontalSizeClass | ¿Cómo clasifica el sistema este entorno de traits? | Adaptaciones contextuales de grano grueso y comportamiento de contenedores del sistema |
| Geometría de la vista | ¿Cuánto espacio tiene realmente este componente? | Columnas propias, paneles, densidad y observadores precisos |
ViewThatFits | ¿Cuál de estas alternativas cabe en la propuesta actual? | Variantes intrínsecas sin medir ni almacenar la anchura |
También pueden combinarse. Una pantalla puede dejar que NavigationSplitView responda a los traits proporcionados por el sistema y, dentro de una de sus columnas, utilizar la anchura local para reorganizar un panel de estadísticas. Lo importante es medir en el nivel donde se toma la decisión, porque la anchura de una columna no tiene por qué coincidir con la de la ventana.
No conviene forzar globalmente una size class con .environment(\.horizontalSizeClass, .regular) para conseguir que una ventana ancha se comporte como un iPad. Esa modificación afecta a todo el subárbol de jerarquía, incluidos componentes que interpretan el trait con otra finalidad, y crea una combinación que el sistema no ha elegido: semántica de teléfono con una clasificación regular inyectada manualmente.
El equivalente en UIKit
El mismo principio se aplica fuera de SwiftUI. Para decidir el layout de un controlador, la referencia más próxima suele ser view.bounds, consultada después de que UIKit haya realizado el layout:
final class ReadingViewController: UIViewController {
private var usesTwoColumns = false
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let newValue = view.bounds.width >= 720
guard newValue != usesTwoColumns else { return }
usesTwoColumns = newValue
updateLayoutConstraints()
}
}
Cuando la decisión pertenece a toda la escena y no a una vista concreta, UIWindowScene.effectiveGeometry describe el espacio efectivo. Su delegado puede observar los cambios mediante windowScene(_:didUpdateEffectiveGeometry:). Apple recomienda reservar la geometría de escena para ese nivel y preferir el tamaño de la vista o de su supervista al componer componentes reutilizables.
Probar formas, no modelos de dispositivo
Una interfaz adaptativa necesita una matriz de pruebas basada en tamaños y contextos, no solo en una lista de dispositivos. Xcode 27 permite redimensionar libremente las previews y las simulaciones desde el nuevo Device Hub. Además de los tamaños habituales de iPhone e iPad, conviene revisar:
- Una ventana muy estrecha y otra inusualmente ancha bajo el
idiomde iPhone. - Una ventana de iPad pequeña, mediana y a pantalla completa.
- iPhone Mirroring en macOS con distintas proporciones.
- Contenido en varios idiomas y categorías grandes de Dynamic Type.
- Vistas presentadas dentro de una columna, un
sheeto un contenedor cuyo tamaño sea menor que el de la escena.
Las previews también pueden documentar los estados que importan al componente:
#Preview("Anchura reducida") {
ListeningDashboard()
.frame(width: 390, height: 700)
}
#Preview("Ventana ancha de iPhone") {
ListeningDashboard()
.frame(width: 900, height: 650)
}
Una nueva jerarquía para tomar decisiones
La estrategia más robusta consiste en empezar por la opción que menos presuposiciones introduce:
- Permitir que los contenedores del sistema adapten navegación y presentación.
- Expresar tamaños mínimos, ideales y máximos mediante el motor de layout.
- Utilizar
ViewThatFitscuando existan varias representaciones capaces de encajar por sí mismas. - Consultar la geometría local cuando un cambio estructural requiera un umbral preciso.
- Recurrir a traits e
idioms únicamente cuando la decisión sea realmente semántica y no una aproximación indirecta al tamaño.
horizontalSizeClass no ha dejado de ser fiable; ha dejado de ser una aproximación suficiente para algo que nunca representó con exactitud. En un ecosistema de ventanas libres, escenas múltiples, pantallas externas y aplicaciones de iPhone redimensionables, el layout debe responder al espacio que recibe cada componente. El lienzo fijo desaparece y la geometría disponible pasa a ser parte del estado dinámico de la interfaz.