Neste artigo
O iPhone Duo é o vidro de dois telemóveis numa só dobradiça. Fechado, o ecrã exterior é um iPhone estreito. Aberto, o ecrã interior aproxima-se de um iPad pequeno na horizontal. Meio aberto, pode ficar de pé como um livro nas tuas mãos ou como um portátil em cima da mesa.
O OdoKeep é feito com React Native e Expo e já corria em iPhones, iPads e tablets Android. Assumi que o Duo funcionaria quase sem mudanças. A app abria, o que não é a mesma coisa. Este artigo explica o que foi preciso para que ela parecesse feita para o dispositivo, e que diretrizes da Apple decidiram cada alteração.
O que a Apple pede
A Apple publicou, juntamente com o dispositivo, uma página «Designing for iPhone Duo» nas Human Interface Guidelines. As partes que orientaram este trabalho são curtas:
- Desenhar para duas classes de tamanho: largura compacta no ecrã exterior, largura regular no interior. Todas as posições correspondem a uma dessas duas, por isso uma app que se adapta às classes de tamanho já tem a maior parte do caminho feito.
- Manter o redimensionamento livre. Nada de larguras fixas, de breakpoints rígidos nem de medidas ligadas a um ecrã em particular.
- Não criar um layout separado para cada posição. Ajustar o que já existe.
- A dobra é uma região reservada, como os módulos da câmara. Manter o conteúdo importante fora dela enquanto o dispositivo está dobrado.
- O exemplo da própria Apple é o Mail: fechado, vês a lista ou uma mensagem; aberto, vês as duas lado a lado.
Os dois últimos pontos parecem simples. Chegar lá exigiu um patch a uma dependência nativa, um pequeno módulo nativo e uma regra sobre que layouts podem existir.
O Duo diz que é um iPad
A primeira surpresa foi a barra de separadores. Aberto, o Duo apresenta o idiom de iPad com uma janela de largura regular. Em React Native, Platform.isPad é verdadeiro nesse caso. O UIKit passa então a tratar o controlador de separadores como o de um iPad e, com a configuração que o OdoKeep usa nos telemóveis, não desenhava separador nenhum: apenas um glifo flutuante para o registo rápido, que não respondia.
O idiom não distingue um Duo de um iPad, mas o modelo do dispositivo distingue. A app usa react-native-screens para os separadores nativos, e um pequeno patch atribui ao controlador de separadores uma classe de tamanho horizontal compacta nos iPhones, através de traitOverrides, com a decisão tomada a partir de UIDevice.model. Com essa substituição, o iOS desenha a barra de separadores do telemóvel como o rail lateral do Duo, com todos os separadores e um botão de registo rápido que funciona. Os iPads ficam de fora de propósito: mantêm a barra superior do sistema, que o utilizador pode transformar numa barra lateral.
O JavaScript precisa da mesma distinção, por isso o módulo nativo expõe isPhone, e o isIPad da app é Platform.isPad sem ser um telemóvel. Tudo o que antes perguntava «isto é um iPad?» faz agora a pergunta que realmente queria fazer.
Um arranque a frio que perdia os separadores
A substituição corrigiu a barra em todo o lado, exceto num caso: abrir a app com o Duo já aberto. O rail aparecia apenas com o separador em destaque, o lápis, e sem nenhum dos outros. Rodar o dispositivo trazia-os de volta.
Foi essa a pista. O UIKit só constrói o contentor que agrupa os separadores comuns durante uma verdadeira transição de classe de tamanho. Uma barra criada compacta dentro de uma janela de largura regular nunca passa por nenhuma, por isso esse contentor nunca é construído.
O patch faz agora a barra passar por essa transição uma vez, na primeira vez que aparece numa janela de largura regular: Regular, uma passagem de layout, depois Compact e outra passagem de layout, tudo de forma síncrona em viewIsAppearing:, antes de o primeiro frame ser desenhado. Experimentei primeiro as opções mais suaves. Uma passagem de layout isolada, reinstalar os separadores, definir explicitamente o modo da barra, substituir traits na janela e dividir a ida e volta por várias voltas do run loop: todas deixaram o rail com um só separador.
O patch está fixado numa versão exata da biblioteca, e um teste mantém alinhados a versão fixada, o patch e a cópia instalada. Um intervalo de versões mais permissivo permitiria que uma instalação atualizasse o pacote e perdesse a correção sem aviso. O README ao lado do patch diz quando o apagar: assim que o Duo mantiver os separadores sem ele.
Ler a dobra
Para manter o conteúdo fora da curvatura, a app tem de saber onde ela está. O iOS 27.1 apresenta-a como uma região reservada de divisão, reservedRegions(kind: .division). Só está ativa enquanto o ecrã interior está dobrado. Um Duo plano ou fechado, todos os outros iPhones e todos os iPads não indicam nada.
A região não tem uma notificação própria. O que o UIKit faz é invalidar o layout de uma vista que a tenha lido, tal como faz com as configurações dos cantos. Por isso, o módulo nativo instala uma vista transparente que ocupa toda a janela, lê a região em layoutSubviews e comunica as alterações:
func read() {
let next = reservedRegions(kind: .division)
.first(where: \.isActive)?.frame
if hasReported && next == fold { return }
hasReported = true
fold = next
onChange?(next)
}
Do lado do JavaScript, useFold() transforma esse retângulo numa postura. Uma dobra mais alta do que larga significa que o dispositivo está a ser segurado como um livro; uma dobra mais larga do que alta significa posição de mesa. A faixa tem 40 pontos, incluindo as margens do sistema.
Comecei por ver os pacotes de dobradiça que já existiam. Os que encontrei indicam o ângulo da dobradiça, que é a pergunta errada. O ângulo diz quanto o dispositivo está aberto; a região reservada diz onde o conteúdo não pode ficar, e é isso que um layout precisa de saber.
Uma regra para todos os layouts
A dobra podia ter servido de pretexto para um conjunto de ecrãs especiais. A Apple pede o contrário, e a manutenção também. O OdoKeep tem dois ecrãs em que uma lista abre algo ao seu lado: a garagem, onde o cartão de um veículo abre a página desse veículo, e os preços dos combustíveis, onde a lista de postos fica ao lado do mapa. Ambos passam por um único hook, useSplitLayout, que devolve uma de três disposições:
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) };
Esse excerto está simplificado; a versão real também verifica se a dobra está dentro da janela e tem em conta a área segura do lado inicial.
- Fechado, a janela é estreita e todos os ecrãs são os do telemóvel.
- Aberto e plano, a lista mantém um painel inicial com a largura de um telemóvel, 40% da largura, entre 360 e 440 pontos, e o detalhe ocupa o resto. É a mesma regra que o iPad usa.
- Livro, a lista alarga até à dobra, de modo que os dois painéis se encontram na dobradiça e nada fica na curvatura.
- Mesa, os preços dos combustíveis põem o mapa na metade superior, de pé como um ecrã, e a lista na metade inferior, debaixo dos polegares. A faixa da dobra entre os dois fica vazia. A garagem mantém a lista habitual, porque uma coluna de cartões não fica melhor por ser cortada ao meio.
O layout plano foi o primeiro que errei. Construí uma divisão centrada na dobra, com duas metades iguais, porque o hardware é simétrico. No ecrã parecia dois telemóveis colados, e uma lista de linhas curtas a metade da largura do Duo era sobretudo espaço vazio. O exemplo do Mail da Apple usa uma lista estreita e um detalhe largo, e foi a isso que voltei. A simetria pertence à posição de livro, em que a dobradiça está fisicamente ali, nas tuas mãos.
Dobrar não pode repor nada
Abrir, fechar e dobrar o Duo mudam a janela e a dobra, e a garagem alterna entre a vista dividida e a vista única à medida que isso acontece. A primeira versão da garagem dividida renderizava uma árvore de componentes diferente para cada caso, por isso cada dobra reconstruía o ecrã. Os cartões repetiam a animação de entrada, a posição do scroll saltava para o topo e uma pesquisa escrita a meio desaparecia.
A correção é uma só árvore nos dois estados. O painel da lista é o mesmo componente em ambos os casos, e só a largura muda. O painel de detalhe aparece ao lado quando há espaço. Cada cartão de veículo mantém a ligação para o ecrã próprio do veículo. Na vista dividida, o handler do toque impede essa navegação e, em vez disso, seleciona o veículo no painel de detalhe. No Duo fechado, o mesmo cartão abre o ecrã do veículo, como em qualquer telemóvel.
Pequenos detalhes no rail
Com o Duo aberto, o rail lateral do lado final também acolhe a câmara interior e o relógio. Quase toda a app já estava disposta dentro da área segura, mas os toasts calculavam as suas próprias margens horizontais e ficavam por baixo do rail. Agora também somam as margens laterais da área segura.
Os testes trouxeram uma surpresa própria. xcrun simctl io screenshot devolve uma imagem preta no simulador do Duo, por isso as capturas de ecrã para revisão vieram da própria janela do simulador. A App Store tem um espaço próprio para capturas do Duo, com 2853 por 2007 píxeis: o ecrã interior na horizontal. As imagens do OdoKeep na loja são desenhadas a partir de código, não capturadas do ecrã. O Duo ganhou um storyboard próprio com o dispositivo em cada posição, e os ecrãs desenhados seguem a mesma regra de layout da app, por isso a posição de livro numa captura encontra-se na dobra, tal como no dispositivo real.
O que não está feito
Não há um layout próprio para cada posição, por opção. As disposições são as descritas acima e mais nenhuma. Os ecrãs sem uma lista e uma vista associada recebem simplesmente uma janela mais larga e adaptam-se como já faziam no iPad.
A correção da barra de separadores é um patch a uma biblioteca nativa de terceiros. Está fixada, testada e documentada, mas continua a ser um patch, e cada nova versão da biblioteca e do iOS exige confirmar se ainda é necessária. A deteção da dobra requer o iOS 27.1; em versões anteriores o Duo continua a funcionar, apenas nunca indica uma postura dobrada, por isso as disposições de livro e de mesa não aparecem.
Se tivesse de ficar com uma só lição, seria perguntar à plataforma o que é o espaço, não o que é o dispositivo. O idiom dizia iPad, o ângulo da dobradiça dizia 90 graus, e nenhum dos dois respondia à pergunta que o layout tinha. A região reservada e a classe de tamanho responderam.
O suporte para o iPhone Duo descrito aqui faz parte da próxima atualização do OdoKeep para iOS. As orientações da Apple estão na página Designing for iPhone Duo das Human Interface Guidelines.