Dans cet article
L'iPhone Duo réunit la surface d'écran de deux téléphones sur une seule charnière. Fermé, son écran extérieur est un iPhone étroit. Ouvert, l'écran intérieur se rapproche d'un petit iPad en mode paysage. À moitié ouvert, il peut se tenir comme un livre entre les mains ou comme un ordinateur portable posé sur une table.
OdoKeep est construit avec React Native et Expo, et il tournait déjà sur iPhone, iPad et tablettes Android. Je pensais que le Duo fonctionnerait à peu près tout seul. L'application se lançait, ce qui n'est pas la même chose. Voici ce qu'il a fallu faire pour qu'elle paraisse à sa place sur l'appareil, et quelle recommandation d'Apple a décidé chaque changement.
Ce que demande Apple
Apple a publié une page « Designing for iPhone Duo » dans les Human Interface Guidelines en même temps que l'appareil. Les passages qui ont orienté ce travail sont courts :
- Concevoir pour deux classes de taille : largeur compacte sur l'écran extérieur, largeur normale sur l'écran intérieur. Chaque posture correspond à l'une des deux, donc une application qui s'adapte aux classes de taille a déjà fait l'essentiel.
- Rester librement redimensionnable. Pas de largeur fixe, pas de point de rupture figé, aucune mesure liée à un écran en particulier.
- Ne pas construire une disposition distincte pour chaque posture. Ajuster celle qui existe déjà.
- Le pli est une région réservée, comme les logements des caméras. Le contenu important doit en rester éloigné tant que l'appareil est plié.
- L'exemple d'Apple est Mail : fermé, on voit la liste ou un message ; ouvert, on voit les deux côte à côte.
Les deux derniers points paraissent simples. Pour y arriver, il a fallu un correctif sur une dépendance native, un petit module natif et une règle sur les dispositions autorisées à exister.
Le Duo se présente comme un iPad
La première surprise est venue de la barre d'onglets. Ouvert, le Duo déclare l'idiome iPad avec une fenêtre de largeur normale. En React Native, Platform.isPad y vaut true. UIKit traite alors le contrôleur d'onglets comme celui d'un iPad et, avec la configuration qu'OdoKeep utilise sur téléphone, il ne dessinait aucun onglet : seulement un glyphe flottant pour l'ajout rapide, qui ne répondait pas.
L'idiome ne distingue pas un Duo d'un iPad, mais le modèle de l'appareil, si. L'application utilise react-native-screens pour les onglets natifs, et un petit correctif donne à son contrôleur d'onglets une classe de taille horizontale compacte sur iPhone, via traitOverrides, en se fondant sur UIDevice.model. Avec cette surcharge, iOS dessine la barre d'onglets du téléphone sous la forme du rail latéral du Duo, avec tous les onglets et un bouton d'ajout rapide qui fonctionne. Les iPad sont volontairement laissés de côté : ils gardent la barre supérieure du système, que l'utilisateur peut transformer en barre latérale.
JavaScript a besoin de la même distinction, donc le module natif expose isPhone, et le isIPad de l'application vaut Platform.isPad à condition que l'appareil ne soit pas un téléphone. Tout ce qui demandait « est-ce un iPad ? » pose désormais la question qu'il voulait vraiment poser.
Un lancement à froid qui perdait les onglets
La surcharge a réparé la barre partout sauf dans un cas : lancer l'application avec le Duo déjà ouvert. Le rail apparaissait avec le seul onglet mis en avant, le crayon, et aucun des autres. Faire pivoter l'appareil les faisait revenir.
C'était l'indice. UIKit ne construit le plateau qui contient les onglets ordinaires que pendant une véritable transition de classe de taille. Une barre créée compacte dans une fenêtre de largeur normale n'en traverse jamais, donc le plateau n'est jamais construit.
Le correctif fait maintenant passer la barre par cette transition une fois, la première fois qu'elle apparaît dans une fenêtre de largeur normale : Regular, une passe de mise en page, puis Compact et une autre passe de mise en page, le tout de façon synchrone dans viewIsAppearing: avant que la première image soit dessinée. J'ai d'abord essayé les options plus douces. Une passe de mise en page seule, la réinstallation des onglets, le réglage explicite du mode de la barre, la surcharge des traits sur la fenêtre et le découpage de l'aller-retour sur plusieurs tours de boucle d'exécution laissaient tous le rail avec un seul onglet.
Le correctif est épinglé à une version exacte de la bibliothèque, et un test maintient ensemble l'épinglage, le correctif et la copie installée. Une plage de versions plus large permettrait à une installation de mettre le paquet à jour et de perdre le correctif sans bruit. Le README à côté du correctif dit quand le supprimer : dès que le Duo garde ses onglets sans lui.
Lire le pli
Pour garder le contenu hors de la pliure, l'application doit savoir où elle se trouve. iOS 27.1 la signale comme une région réservée de division, reservedRegions(kind: .division). Elle n'est active que lorsque l'écran intérieur est plié. Un Duo à plat ou fermé, tous les autres iPhone et tous les iPad ne signalent rien.
La région n'a pas de notification propre. En revanche, UIKit invalide la mise en page d'une vue qui l'a lue, comme il le fait pour les configurations de coins. Le module natif installe donc une vue transparente qui couvre la fenêtre, lit la région dans layoutSubviews et signale les changements :
func read() {
let next = reservedRegions(kind: .division)
.first(where: \.isActive)?.frame
if hasReported && next == fold { return }
hasReported = true
fold = next
onChange?(next)
}
Côté JavaScript, useFold() transforme ce rectangle en posture. Un pli plus haut que large signifie que l'appareil est tenu comme un livre ; un pli plus large que haut signifie la posture sur table. La bande fait 40 points, marges du système comprises.
J'ai d'abord regardé les paquets existants pour les charnières. Ceux que j'ai trouvés indiquent l'angle de la charnière, ce qui n'est pas la bonne question. L'angle dit à quel point l'appareil est ouvert ; la région réservée dit où le contenu ne doit pas aller, et c'est ce dont une mise en page a besoin.
Une seule règle pour toutes les dispositions
Le pli aurait pu servir de prétexte à une série d'écrans spéciaux. Apple demande l'inverse, et la maintenance aussi. OdoKeep a deux écrans où une liste ouvre quelque chose à côté d'elle : le garage, où une carte de véhicule ouvre la page de ce véhicule, et les prix des carburants, où la liste des stations se trouve à côté de la carte. Les deux passent par un seul hook, useSplitLayout, qui renvoie l'une de trois dispositions :
if (fold?.posture === "tabletop") {
return { kind: "stacked", topHeight: fold.fold.y, gap: fold.fold.height };
}
if (layoutWidth < SPLIT_MIN_WIDTH) return { kind: "single" };
if (fold?.posture === "book") {
return { kind: "columns", listWidth: fold.fold.x + fold.fold.width / 2 };
}
return { kind: "columns", listWidth: splitListPaneWidth(layoutWidth) };
Cet extrait est simplifié ; la vraie version vérifie aussi que le pli se trouve dans la fenêtre et tient compte de la zone sûre du bord de début.
- Fermé, la fenêtre est étroite et chaque écran est celui du téléphone.
- Ouvert et à plat, la liste garde, au bord de début, un panneau de la largeur d'un téléphone, 40 % de la largeur maintenus entre 360 et 440 points, et le détail prend le reste. C'est la même règle que sur iPad.
- En livre, la liste s'élargit jusqu'au pli, de sorte que les deux panneaux se rejoignent sur la charnière et que rien ne se trouve dans la pliure.
- Sur table, les prix des carburants placent la carte dans la moitié supérieure, dressée comme un écran, et la liste dans la moitié inférieure, sous les pouces. La bande du pli entre les deux ne contient rien. Le garage garde sa liste ordinaire, parce qu'une colonne de cartes ne gagne rien à être coupée en deux.
C'est la disposition à plat que j'ai ratée en premier. J'avais construit une division centrée sur le pli, deux moitiés égales, parce que le matériel est symétrique. À l'écran, on aurait dit deux téléphones collés ensemble, et une liste de lignes courtes sur la moitié de la largeur du Duo était surtout de l'espace vide. L'exemple de Mail chez Apple utilise une liste étroite et un détail large, et c'est à cela que je suis revenu. La symétrie appartient à la posture en livre, où la charnière est physiquement là, entre vos mains.
Plier ne doit rien réinitialiser
Ouvrir, fermer et plier le Duo changent la fenêtre et le pli, et le garage passe en conséquence de la vue divisée à la vue simple. La première version du garage divisé rendait un arbre de composants différent pour chaque cas, si bien que chaque pliage reconstruisait l'écran. Les cartes rejouaient leur animation d'entrée, la position de défilement revenait en haut et une recherche à moitié tapée disparaissait.
La solution est un seul arbre dans les deux états. Le panneau de liste est le même composant dans un cas comme dans l'autre, et seule sa largeur change. Le panneau de détail apparaît à côté quand il y a la place. Chaque carte de véhicule garde son lien vers l'écran propre du véhicule. En vue divisée, le gestionnaire d'appui empêche cette navigation et sélectionne le véhicule dans le panneau de détail. Sur le Duo fermé, la même carte ouvre l'écran du véhicule, comme sur n'importe quel téléphone.
Petits détails sur le rail
Quand le Duo est ouvert, le rail latéral, au bord de fin, porte aussi la caméra intérieure et l'horloge. La plus grande partie de l'application était déjà disposée dans la zone sûre, mais les toasts calculaient leurs propres marges horizontales et passaient sous le rail. Ils ajoutent désormais eux aussi les marges latérales de la zone sûre.
Les tests ont réservé leur propre surprise. xcrun simctl io screenshot renvoie une image noire pour le simulateur du Duo, donc les captures pour la relecture ont été prises directement depuis la fenêtre du simulateur. L'App Store a un emplacement de captures propre au Duo, en 2853 par 2007 pixels : l'écran intérieur en paysage. Les captures d'OdoKeep pour l'App Store sont dessinées par le code plutôt que capturées. Le Duo a eu son propre storyboard avec l'appareil dans chaque posture, et les écrans dessinés suivent la même règle de disposition que l'application : en posture livre, les panneaux d'une capture se rejoignent sur le pli comme sur l'appareil réel.
Ce qui n'est pas fait
Il n'y a pas de disposition sur mesure pour chaque posture, par choix. Les dispositions sont celles décrites plus haut et rien d'autre. Les écrans sans liste ni vue associée reçoivent simplement une fenêtre plus large et s'adaptent comme ils le faisaient déjà sur iPad.
La correction de la barre d'onglets est un correctif appliqué à une bibliothèque native tierce. Il est épinglé, testé et documenté, mais il reste un correctif, et chaque nouvelle version de la bibliothèque et d'iOS demande de vérifier s'il est toujours nécessaire. La détection du pli exige iOS 27.1 ; sur les versions antérieures, le Duo fonctionne toujours, il ne signale simplement jamais de posture pliée, donc les dispositions en livre et sur table n'apparaissent pas.
S'il ne fallait garder qu'une leçon, ce serait de demander à la plateforme ce qu'est l'espace, pas ce qu'est l'appareil. L'idiome disait iPad, l'angle de la charnière disait 90 degrés, et aucun des deux ne répondait à la question que se posait la mise en page. La région réservée et la classe de taille, si.
La prise en charge de l'iPhone Duo décrite ici fait partie de la prochaine mise à jour d'OdoKeep pour iOS. Les recommandations d'Apple se trouvent sur la page Designing for iPhone Duo des Human Interface Guidelines.