ZaatarLABS
Scrivici →
← Tutti gli articoli
ZaatarLABS · Ingegneria

Cosa hanno rotto le finestre ridimensionabili di iOS 27 in quattro app pubblicate

Le finestre ridimensionabili su iPhone invalidano in silenzio ogni controllo dell'idiom di UIDevice usato per il layout: lo stesso guasto, quattro codebase, una sola correzione con le size class.

5 min di lettura

Il guasto che non era nei piani

L'ho trovato mentre lavoravo al passaggio a iOS 27 di The Smart Budget, ma non in Budget: nel SceneDelegate di The Smart Billing. Quel file leggeva UIDevice.current.userInterfaceIdiom == .pad una sola volta all'avvio e usava la risposta per decidere se costruire una radice in split view o una tab bar.

iOS 27 rende ridimensionabili le app per iPhone. Una volta che è così, il controllo dell'idiom smette di rispondere alla domanda che gli veniva posta. Ti dice su quale hardware stai girando. Non ti dice quanto è larga la finestra in questo momento. Un utente potrebbe avviare l'app stretta (l'idiom dice .phone, viene costruita la tab bar) e poi allargare la finestra fino a una dimensione che normalmente giustificherebbe una barra laterale. La radice non viene mai ricostruita. Vede una tab bar in una finestra che avrebbe spazio per una split view.

Ho controllato tutte e quattro le app della suite. Lo schema saltava fuori in ognuna.

Cosa faceva il controllo

Il presupposto nascosto in ogni controllo dell'idiom era che l'hardware determini il layout. iPhone vuol dire una colonna. iPad vuol dire due. Abbastanza vicino al vero, e abbastanza a lungo, che nessuno l'ha messo in discussione.

L'approssimazione era già sbagliata su Mac Catalyst. Su Catalyst, UIScreen.main riporta il display fisico, non la finestra dell'app. The Smart Coach limitava un elenco di risultati di ricerca al 55% di UIScreen.main.bounds.height. Su un monitor grande con una finestra dell'app piccola, quel limite poteva superare l'intera altezza della finestra. Le viste non erano palesemente rotte: solo dimensionate, in silenzio, sul numero sbagliato.

Le finestre ridimensionabili di iOS 27 fanno emergere la stessa classe di errore su iPhone.

La correzione in Billing

The Smart Billing aveva l'uso più concentrato dell'idiom: la scelta tra split view e tab bar nel SceneDelegate, la visibilità del pulsante di chiamata in due punti e tre letture di UIScreen.main per l'altezza dei fogli e il dimensionamento della barra accessoria della tastiera.

La modifica al SceneDelegate era la più strutturale. Il nuovo codice legge horizontalSizeClass e si registra per i cambi di trait sulla finestra:

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

La protezione ifNeeded conta. Un ridimensionamento invoca questa callback di continuo. Senza la protezione, ogni pixel di trascinamento smonta lo stack di navigazione e ricostruisce la radice, buttando via lo stato della navigazione. La protezione verifica che la size class sia cambiata davvero prima di fare qualsiasi cosa.

Il pulsante di chiamata era un problema diverso. Era nascosto dietro userInterfaceIdiom != .pad, che cercava di rispondere a una domanda di capacità (questo dispositivo può fare telefonate?) con un segnale di layout. Era il test sbagliato già prima di iOS 27. È stato sostituito con canOpenURL(tel:), che è la domanda vera.

I punti con UIScreen.main erano più meccanici. Due limiti di altezza dei fogli sono passati a containerRelativeFrame. La barra accessoria della tastiera numerica chiamava già sizeToFit(), quindi è bastato inizializzarla con larghezza zero e lasciare che si misurasse da sola.

Otto test end-to-end, zero errori su iPhone. Build pulita su Mac Catalyst.

La correzione in Coach

The Smart Coach aveva un caso più sottile in InvoiceFlowLayout. In un'implementazione di Layout.sizeThatFits, un proposal.width nil significa che il sistema sta chiedendo «quanto vuoi essere largo?», non «largo quanto lo schermo». Il vecchio codice rispondeva con la larghezza del display e poi andava a capo con le righe in base a quella dimensione, che al layout non era mai stata data davvero.

La correzione: una proposta nil significa nessun vincolo, quindi una sola riga. Riportare la larghezza effettivamente usata, non quella del display.

Il limite dei risultati di GlobalSearchView era più facile da vedere:

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

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

Il file aveva già un GeometryReader per topPadding, che legge un inset dell'area sicura che non cambia con il ridimensionamento. Il limite dei risultati aveva bisogno di un proprio reader, perché un ridimensionamento deve rivalutarlo e una proprietà calcolata letta durante body non ha la garanzia di essere rieseguita.

Diciotto test end-to-end verdi. Build pulita su Mac Catalyst.

Cosa si tiene

Non tutti i controlli dell'idiom andavano sostituiti. The Smart Coach ne ha dieci. Sette proteggono l'accesso alla fotocamera insieme al controllo isSourceTypeAvailable, che è quello che decide davvero. Due nascondono il pulsante di chiamata. Uno sceglie la radice in split view nel SceneDelegate, dove UISplitViewController si comprime già alle larghezze compatte.

Sono rimasti tutti e dieci. La presenza della fotocamera è un fatto hardware. Un ridimensionamento della finestra non può cambiarla. Sostituire lì userInterfaceIdiom con una size class significherebbe rispondere alla domanda sbagliata con il primo numero a disposizione.

La regola che ne è uscita: i controlli dell'idiom vanno bene per le domande di capacità. L'idiom era sbagliato per le domande di layout. Il guaio era che le domande di layout erano state formulate come domande di capacità così a lungo che nel codice la distinzione non era evidente.

Il problema di Catalyst c'era già

Il lavoro su iOS 27 ha fatto emergere il bug, ma il bug è precedente a iOS 27. Su Catalyst, UIScreen.main ha sempre misurato il display. Qualsiasi app che usasse UIScreen.main.bounds per dimensionare qualcosa e poi girasse su Catalyst stava misurando, in silenzio, la cosa sbagliata. Le viste erano solo leggermente sfasate, in modi che potevano sembrare normali.

Se supporti Catalyst e non hai controllato le tue letture di UIScreen.main, il bug ce l'hai già oggi.

Cosa farei diversamente

Avrei introdotto un tipo DeviceCapability all'inizio della prima app, invece di lasciare che le domande di capacità si accumulassero come controlli dell'idiom sparsi tra i view controller. Quando mi sono ritrovato a guardare quattro codebase, la logica di visibilità del pulsante di chiamata era stata copiata in due punti in Billing e scritta separatamente in Coach. Entrambe controllavano userInterfaceIdiom. Entrambe andavano cambiate.

Una proprietà canPlaceCalls basata su canOpenURL(tel:) sono tre righe. Anche copiare un controllo dell'idiom sono tre righe, ma risponde a una domanda diversa. Te ne accorgi solo quando l'idiom smette di essere un'approssimazione affidabile.

Per chi legge

Cerca userInterfaceIdiom nel tuo codice. Dividi i risultati tra decisioni di layout e controlli di capacità. Le decisioni di layout vanno spostate sulle size class e su registerForTraitChanges. I controlli di capacità probabilmente vanno bene, ma verifica che ognuno risponda a una domanda sull'hardware e non a una domanda di layout travestita.

Poi cerca UIScreen.main.bounds. Se supporti Catalyst, quelle letture sono già sbagliate. iOS 27 farà emergere lo stesso problema su iPhone.

La protezione su registerForTraitChanges (ricostruire solo quando la size class cambia davvero) è la parte facile da trascurare. Senza, un trascinamento invoca la callback a ogni punto, e puoi buttare via lo stato della navigazione a ogni pixel di ridimensionamento. Il sistema non applica alcun debounce al posto tuo.

Scritto da Omar Al Homaidi · ZaatarLABS · @zaatarlabs