Ce que les fenêtres redimensionnables d'iOS 27 ont cassé dans quatre apps publiées
Les fenêtres redimensionnables sur iPhone invalident en silence chaque test d'idiome UIDevice utilisé pour la mise en page — la même casse, quatre bases de code, une seule correction par size class.
La casse qui n'était pas prévue
Je l'ai découverte en travaillant sur le passage de The Smart Budget à iOS 27, mais pas dans Budget lui-même — dans le SceneDelegate de The Smart Billing. Ce fichier lisait UIDevice.current.userInterfaceIdiom == .pad une seule fois au lancement et s'appuyait sur la réponse pour décider de construire une racine en vue partagée ou une barre d'onglets.
iOS 27 rend les apps iPhone redimensionnables. Dès lors, le test d'idiome cesse de répondre à la question qu'on lui posait. Il vous dit sur quel matériel vous tournez. Il ne vous dit pas quelle est la largeur de la fenêtre à cet instant. Un utilisateur peut lancer l'app dans une fenêtre étroite — l'idiome dit .phone, la barre d'onglets est construite —, puis élargir la fenêtre jusqu'à une taille qui justifierait normalement une barre latérale. La racine n'est jamais reconstruite. Il voit une barre d'onglets dans une fenêtre qui a la place pour une vue partagée.
J'ai audité les quatre apps de la suite. Le même schéma est apparu dans chacune.
Ce que faisait le test
L'hypothèse inscrite dans chaque test d'idiome, c'était que le matériel détermine la mise en page. iPhone veut dire une colonne. iPad veut dire deux. Assez proche de la vérité, assez longtemps, pour que personne ne la remette en question.
L'approximation était déjà fausse sur Mac Catalyst. Sur Catalyst, UIScreen.main décrit l'écran physique, pas la fenêtre de l'app. The Smart Coach plafonnait une liste de résultats de recherche à 55% de UIScreen.main.bounds.height. Sur un grand moniteur avec une petite fenêtre d'app, ce plafond pouvait dépasser la hauteur totale de la fenêtre. Les vues n'étaient pas visiblement cassées — simplement dimensionnées en silence d'après le mauvais chiffre.
Les fenêtres redimensionnables d'iOS 27 font apparaître la même catégorie d'erreur sur iPhone.
La correction de Billing
The Smart Billing concentrait le plus d'usages de l'idiome : le choix entre vue partagée et barre d'onglets dans SceneDelegate, la visibilité du bouton d'appel à deux endroits, et trois lectures de UIScreen.main pour la hauteur des feuilles et le dimensionnement de la barre d'accessoires du clavier.
La modification de SceneDelegate était la plus structurelle. Le nouveau code lit horizontalSizeClass et s'abonne aux changements de traits de la fenêtre :
window?.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { [weak self] in
self?.updateRootViewControllerIfNeeded()
}
La garde ifNeeded compte. Un redimensionnement déclenche ce callback en continu. Sans la garde, chaque pixel de glissement démonte la pile de navigation et reconstruit la racine, en jetant l'état de navigation. La garde vérifie que la size class a réellement basculé avant de faire quoi que ce soit.
Le bouton d'appel posait un autre problème. Il était masqué derrière userInterfaceIdiom != .pad, qui tentait de répondre à une question de capacité — cet appareil peut-il passer des appels téléphoniques ? — avec un signal de mise en page. C'était déjà le mauvais test avant iOS 27. Il a été remplacé par canOpenURL(tel:), qui est la vraie question.
Les endroits utilisant UIScreen.main étaient plus mécaniques. Deux plafonds de hauteur de feuille sont passés à containerRelativeFrame. La barre d'accessoires du clavier numérique appelait déjà sizeToFit(), donc l'initialiser avec une largeur nulle et la laisser se mesurer elle-même a suffi.
Huit tests de bout en bout, zéro échec sur iPhone. Build Mac Catalyst propre.
La correction de Coach
The Smart Coach avait un cas plus subtil dans InvoiceFlowLayout. Dans une implémentation de Layout.sizeThatFits, un proposal.width à nil signifie que le système demande « quelle largeur voulez-vous ? » — et non « aussi large que l'écran ». L'ancien code répondait avec la largeur de l'écran, puis renvoyait les lignes à la ligne selon cette dimension, que la mise en page n'avait en réalité jamais reçue.
La correction : une proposition à nil signifie sans contrainte, donc une seule ligne. Indiquer la largeur réellement utilisée, pas la largeur de l'écran.
Le plafond des résultats de GlobalSearchView était plus facile à repérer :
// Before
.frame(maxHeight: UIScreen.main.bounds.height * 0.55)
// After — GeometryReader reads the view's actual container
resultsList(maxHeight: proxy.size.height * 0.55)
Le fichier avait déjà un GeometryReader pour topPadding, qui lisait une marge de zone sûre qui ne change pas au redimensionnement. Le plafond des résultats avait besoin de son propre lecteur, parce qu'un redimensionnement doit le réévaluer, et qu'une propriété calculée lue pendant body n'est pas garantie d'être réexécutée.
Dix-huit tests de bout en bout au vert. Build Mac Catalyst propre.
Ce que vous gardez
Tous les tests d'idiome n'avaient pas besoin d'être remplacés. The Smart Coach en a dix. Sept protègent l'accès à l'appareil photo, à côté du test isSourceTypeAvailable qui tranche réellement la question. Deux masquent le bouton d'appel. Un choisit la racine en vue partagée dans SceneDelegate, où UISplitViewController se replie déjà en largeur compacte.
Les dix sont restés. La présence d'un appareil photo est un fait matériel. Un redimensionnement de fenêtre ne peut pas la changer. Remplacer userInterfaceIdiom par une size class à cet endroit reviendrait à répondre à la mauvaise question avec le premier chiffre disponible.
La règle qui en est sortie : les tests d'idiome conviennent aux questions de capacité. L'idiome était faux pour les questions de mise en page. Le problème, c'est que les questions de mise en page avaient été formulées comme des questions de capacité depuis si longtemps que la distinction ne sautait pas aux yeux dans le code.
Le problème de Catalyst existait déjà
Le travail sur iOS 27 a révélé le bug, mais le bug est antérieur à iOS 27. Sur Catalyst, UIScreen.main a toujours mesuré l'écran. Toute app qui utilisait UIScreen.main.bounds pour dimensionner quelque chose, puis tournait sur Catalyst, mesurait en silence la mauvaise chose. Les vues étaient juste légèrement décalées, d'une façon qui pouvait passer pour normale.
Si vous prenez en charge Catalyst et que vous n'avez pas audité vos lectures de UIScreen.main, vous avez le bug aujourd'hui.
Ce que je ferais différemment
J'aurais introduit un type DeviceCapability dès la première app, au lieu de laisser les questions de capacité s'accumuler sous forme de tests d'idiome éparpillés dans les contrôleurs de vue. Au moment où je me penchais sur quatre bases de code, la logique de visibilité du bouton d'appel avait été copiée à deux endroits dans Billing et écrite séparément dans Coach. Les deux testaient userInterfaceIdiom. Les deux devaient changer.
Une propriété canPlaceCalls adossée à canOpenURL(tel:), c'est trois lignes. Copier un test d'idiome, c'est aussi trois lignes, mais ça répond à une autre question. Vous ne le remarquez que quand l'idiome cesse d'être une approximation fiable.
Pour vous, lecteur
Cherchez userInterfaceIdiom dans votre code. Séparez les résultats entre décisions de mise en page et tests de capacité. Les décisions de mise en page doivent passer aux size classes et à registerForTraitChanges. Les tests de capacité sont probablement corrects — mais vérifiez que chacun répond bien à une question matérielle, et pas à une question de mise en page déguisée.
Cherchez ensuite UIScreen.main.bounds. Si vous prenez en charge Catalyst, ces lectures sont déjà fausses. iOS 27 fera apparaître le même problème sur iPhone.
La garde de registerForTraitChanges — ne reconstruire que quand la size class change réellement — est la partie facile à rater. Sans elle, un glissement déclenche le callback à chaque point, et vous pouvez perdre l'état de navigation à chaque pixel de redimensionnement. Le système ne fait pas le debounce à votre place.