ZaatarLABS
Escríbenos →
← Todas las entradas
ZaatarLABS · Ingeniería

Lo que rompieron las ventanas redimensionables de iOS 27 en cuatro apps publicadas

Las ventanas redimensionables en iPhone invalidan en silencio cualquier comprobación del idiom de UIDevice usada para el diseño: la misma rotura, cuatro bases de código, una solución con size classes.

5 min de lectura

La rotura que no estaba en el plan

Lo encontré mientras trabajaba en la puesta al día de The Smart Budget para iOS 27, pero no en Budget, sino en el SceneDelegate de The Smart Billing. Ese archivo leía UIDevice.current.userInterfaceIdiom == .pad una vez al arrancar y usaba la respuesta para decidir si construir una raíz con vista dividida o con barra de pestañas.

iOS 27 hace que las apps de iPhone sean redimensionables. A partir de ahí, la comprobación del idiom deja de responder a la pregunta que se le hacía. Te dice en qué hardware se está ejecutando la app. No te dice qué ancho tiene la ventana en este momento. Un usuario podía abrir la app estrecha (el idiom dice .phone, se construye la barra de pestañas) y luego ensanchar la ventana hasta un tamaño que normalmente justificaría una barra lateral. La raíz nunca se reconstruye. Ve una barra de pestañas en una ventana con espacio para una vista dividida.

Revisé las cuatro apps del conjunto. El patrón aparecía en todas.

Qué hacía la comprobación

La suposición escondida en cada comprobación del idiom era que el hardware determina el diseño. iPhone significa una columna. iPad significa dos. Algo bastante cercano a la verdad durante el tiempo suficiente como para que nadie lo cuestionara.

En Mac Catalyst, ese indicador ya era incorrecto. En Catalyst, UIScreen.main informa de la pantalla física, no de la ventana de la app. The Smart Coach limitaba una lista de resultados de búsqueda al 55% de UIScreen.main.bounds.height. En un monitor grande con una ventana pequeña, ese límite podía superar la altura de toda la ventana. Las vistas no se veían claramente rotas; simplemente se dimensionaban en silencio con el número equivocado.

Las ventanas redimensionables de iOS 27 sacan a la luz el mismo tipo de error en iPhone.

La solución en Billing

The Smart Billing era la que más concentraba el uso del idiom: la decisión entre vista dividida y barra de pestañas en SceneDelegate, la visibilidad del botón de llamada en dos sitios y tres lecturas de UIScreen.main para las alturas de las hojas y el tamaño de la barra accesoria del teclado.

El cambio en SceneDelegate fue el más estructural. El código nuevo lee horizontalSizeClass y se registra para los cambios de rasgos de la ventana:

window?.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { [weak self] in
    self?.updateRootViewControllerIfNeeded()
}

La comprobación ifNeeded importa. Un redimensionado dispara este callback continuamente. Sin la comprobación, cada píxel de arrastre desmonta la pila de navegación y reconstruye la raíz, tirando a la basura el estado de navegación. La comprobación verifica si la size class cambió de verdad antes de hacer nada.

El botón de llamada era otro problema. Estaba oculto tras userInterfaceIdiom != .pad, que intentaba responder una pregunta de capacidad (¿puede este dispositivo hacer llamadas?) con una señal de diseño. Esa ya era la prueba equivocada antes de iOS 27. Se sustituyó por canOpenURL(tel:), que es la pregunta real.

Los sitios con UIScreen.main fueron más mecánicos. Dos límites de altura de hojas pasaron a containerRelativeFrame. La barra accesoria del teclado numérico ya llamaba a sizeToFit(), así que bastó con inicializarla con ancho cero y dejar que se midiera sola.

Ocho pruebas de extremo a extremo, cero fallos en iPhone. Compilación limpia en Mac Catalyst.

La solución en Coach

The Smart Coach tenía un caso más sutil en InvoiceFlowLayout. En una implementación de Layout.sizeThatFits, un proposal.width nil significa que el sistema pregunta «¿qué ancho quieres tener?», no «tan ancho como la pantalla». El código antiguo respondía con el ancho de la pantalla y luego distribuía las filas según esa dimensión, que en realidad nunca se le había dado al layout.

La solución: una propuesta nil significa sin restricciones, lo que significa una sola fila. Informar del ancho realmente usado, no del ancho de la pantalla.

El límite de resultados de GlobalSearchView era más fácil de ver:

// Before
.frame(maxHeight: UIScreen.main.bounds.height * 0.55)

// After — GeometryReader reads the view's actual container
resultsList(maxHeight: proxy.size.height * 0.55)

El archivo ya tenía un GeometryReader para topPadding, que leía un margen del área segura que no cambia al redimensionar. El límite de resultados necesitaba su propio lector, porque un redimensionado tiene que volver a evaluarlo y no está garantizado que una propiedad calculada leída durante body se vuelva a ejecutar.

Dieciocho pruebas de extremo a extremo en verde. Compilación limpia en Mac Catalyst.

Lo que se queda

No hacía falta reemplazar todas las comprobaciones del idiom. The Smart Coach tiene diez. Siete protegen el acceso a la cámara junto a la comprobación isSourceTypeAvailable, que es la que realmente decide. Dos ocultan el botón de llamada. Una elige la raíz con vista dividida en SceneDelegate, donde UISplitViewController ya se contrae en ancho compacto.

Las diez se quedaron. Tener cámara es un hecho del hardware. Redimensionar una ventana no puede cambiarlo. Sustituir userInterfaceIdiom por una size class ahí sería responder la pregunta equivocada con el número que tuvieras a mano.

La regla que salió de esto: las comprobaciones del idiom están bien para preguntas de capacidad. El idiom era incorrecto para preguntas de diseño. El problema era que las preguntas de diseño llevaban tanto tiempo formuladas como preguntas de capacidad que la distinción no era evidente en el código.

El problema de Catalyst ya estaba ahí

El trabajo para iOS 27 destapó el bug, pero el bug es anterior a iOS 27. UIScreen.main en Catalyst siempre ha medido la pantalla. Cualquier app que usara UIScreen.main.bounds para dimensionar algo y luego se ejecutara en Catalyst estaba midiendo en silencio lo que no era. Las vistas solo quedaban ligeramente desajustadas, de formas que podían pasar por normales.

Si tienes compatibilidad con Catalyst y no has revisado tus lecturas de UIScreen.main, ya tienes el bug hoy.

Lo que haría distinto

Habría introducido un tipo DeviceCapability al empezar la primera app, en lugar de dejar que las preguntas de capacidad se acumularan como comprobaciones del idiom repartidas por los controladores de vista. Para cuando estaba revisando cuatro bases de código, la lógica de visibilidad del botón de llamada se había copiado en dos sitios en Billing y escrito por separado en Coach. Todas comprobaban userInterfaceIdiom. Todas tenían que cambiar.

Una propiedad canPlaceCalls respaldada por canOpenURL(tel:) son tres líneas. Copiar una comprobación del idiom también son tres líneas, pero responde a otra pregunta. Solo te das cuenta cuando el idiom deja de ser un indicador fiable.

Para quien lee

Busca userInterfaceIdiom en tu código. Separa los resultados en decisiones de diseño y comprobaciones de capacidad. Las decisiones de diseño tienen que pasar a size classes y registerForTraitChanges. Las comprobaciones de capacidad probablemente estén bien, pero confirma que cada una responde a una pregunta sobre el hardware, y no a una pregunta de diseño disfrazada.

Después busca UIScreen.main.bounds. Si tienes compatibilidad con Catalyst, esas lecturas ya son incorrectas. iOS 27 sacará a la luz el mismo problema en iPhone.

La protección de registerForTraitChanges (reconstruir solo cuando la size class cambia de verdad) es la parte fácil de pasar por alto. Sin ella, un arrastre dispara el callback en cada punto, y puedes perder el estado de navegación con cada píxel de redimensionado. El sistema no aplica ningún debounce por ti.

Escrito por Omar Al Homaidi · ZaatarLABS · @zaatarlabs