Suportar o iPhone Duo em React Native: ler a dobra, não o dispositivo

Corrigir uma barra de abas que sumia, ler a dobra como uma região reservada do UIKit e manter uma única regra de layout em todas as posições do iPhone Duo.

Atualizado em

Todos os artigos
Neste artigo

O iPhone Duo é o vidro de dois celulares em uma só dobradiça. Fechado, a tela externa é um iPhone estreito. Aberto, a tela interna se aproxima de um iPad pequeno na horizontal. Meio aberto, ele pode ficar de pé como um livro nas suas mãos ou como um notebook em cima da mesa.

O OdoKeep é feito com React Native e Expo e já rodava em iPhones, iPads e tablets Android. Imaginei que o Duo funcionaria quase sem mudanças. O app abria, o que não é a mesma coisa. Este artigo explica o que foi preciso para que ele parecesse feito para o dispositivo, e quais diretrizes da Apple decidiram cada mudança.

O que a Apple pede

A Apple publicou, junto com o dispositivo, uma página “Designing for iPhone Duo” nas Human Interface Guidelines. As partes que orientaram este trabalho são curtas:

  • Projetar para duas classes de tamanho: largura compacta na tela externa, largura regular na interna. Todas as posições correspondem a uma dessas duas, então um app que se adapta às classes de tamanho já tem a maior parte do caminho andado.
  • Manter o redimensionamento livre. Nada de larguras fixas, de breakpoints rígidos nem de medidas ligadas a uma tela específica.
  • 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âmera. Manter o conteúdo importante fora dela enquanto o dispositivo está dobrado.
  • O exemplo da própria Apple é o Mail: fechado, você vê a lista ou uma mensagem; aberto, vê as duas lado a lado.

Os dois últimos pontos parecem simples. Chegar lá exigiu um patch em uma dependência nativa, um pequeno módulo nativo e uma regra sobre quais layouts podem existir.

O Duo diz que é um iPad

A primeira surpresa foi a barra de abas. Aberto, o Duo informa 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 abas como o de um iPad e, com a configuração que o OdoKeep usa nos celulares, não desenhava aba nenhuma: só um glifo flutuante para o registro rápido, que não respondia.

O idiom não distingue um Duo de um iPad, mas o modelo do dispositivo distingue. O app usa react-native-screens para as abas nativas, e um pequeno patch dá ao controlador de abas uma classe de tamanho horizontal compacta nos iPhones, por meio de traitOverrides, com a decisão tomada a partir de UIDevice.model. Com essa substituição, o iOS desenha a barra de abas do celular como o rail lateral do Duo, com todas as abas e um botão de registro rápido que funciona. Os iPads ficam de fora de propósito: mantêm a barra superior do sistema, que o usuário pode transformar em uma barra lateral.

O JavaScript precisa da mesma distinção, então o módulo nativo expõe isPhone, e o isIPad do app é Platform.isPad sem ser um celular. Tudo o que antes perguntava “isto é um iPad?” agora faz a pergunta que realmente queria fazer.

Uma inicialização a frio que perdia as abas

A substituição corrigiu a barra em todos os casos, menos um: abrir o app com o Duo já aberto. O rail aparecia só com a aba em destaque, o lápis, e sem nenhuma das outras. Girar o dispositivo trazia as abas de volta.

Essa foi a pista. O UIKit só constrói o container que agrupa as abas comuns durante uma transição real de classe de tamanho. Uma barra criada compacta dentro de uma janela de largura regular nunca passa por nenhuma, então esse container nunca é construído.

Agora o patch faz a barra passar por essa transição uma vez, na primeira vez que ela aparece em uma 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. Tentei primeiro as opções mais suaves. Uma passagem de layout isolada, reinstalar as abas, definir explicitamente o modo da barra, substituir traits na janela e dividir a ida e volta em várias voltas do run loop: todas deixaram o rail com uma única aba.

O patch está fixado em uma 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 apagá-lo: assim que o Duo mantiver as abas sem ele.

Ler a dobra

Para manter o conteúdo fora da curvatura, o app precisa saber onde ela está. O iOS 27.1 a informa como uma região reservada de divisão, reservedRegions(kind: .division). Ela só está ativa enquanto a tela interna está dobrada. Um Duo plano ou fechado, todos os outros iPhones e todos os iPads não informam nada.

A região não tem uma notificação própria. O que o UIKit faz é invalidar o layout de uma view que a tenha lido, do mesmo jeito que faz com as configurações dos cantos. Por isso, o módulo nativo instala uma view transparente que ocupa a janela inteira, lê a região em layoutSubviews e comunica as mudanças:

func read() {
  let next = reservedRegions(kind: .division)
    .first(where: \.isActive)?.frame
  if hasReported && next == fold { return }
  hasReported = true
  fold = next
  onChange?(next)
}

No lado do JavaScript, useFold() transforma esse retângulo em uma postura. Uma dobra mais alta do que larga significa que o dispositivo está sendo 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 olhando os pacotes de dobradiça que já existiam. Os que encontrei informam 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 saber.

Uma regra para todos os layouts

A dobra poderia ter virado pretexto para um conjunto de telas especiais. A Apple pede o contrário, e a manutenção também. O OdoKeep tem duas telas em que uma lista abre algo ao lado: a garagem, onde o card de um veículo abre a página desse veículo, e os preços de combustível, onde a lista de postos fica ao lado do mapa. As duas passam por um único hook, useSplitLayout, que retorna 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 trecho está simplificado; a versão real também verifica se a dobra está dentro da janela e leva em conta a área segura do lado inicial.

  • Fechado, a janela é estreita e todas as telas são as do celular.
  • Aberto e plano, a lista mantém um painel inicial com a largura de um celular, 40% da largura, entre 360 e 440 pontos, e o detalhe ocupa o resto. É a mesma regra que o iPad usa.
  • Livro, a lista se alarga até a dobra, de modo que os dois painéis se encontram na dobradiça e nada fica na curvatura.
  • Mesa, os preços de combustível colocam o mapa na metade de cima, de pé como uma tela, e a lista na metade de baixo, sob os polegares. A faixa da dobra entre os dois fica vazia. A garagem mantém a lista de sempre, porque uma coluna de cards não melhora ao 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. Na tela parecia dois celulares colados, e uma lista de linhas curtas com metade da largura do Duo era quase só espaço vazio. O exemplo do Mail da Apple usa uma lista estreita e um detalhe largo, e foi para isso que voltei. A simetria pertence à posição de livro, em que a dobradiça está fisicamente ali, nas suas mãos.

Dobrar não pode redefinir nada

Abrir, fechar e dobrar o Duo mudam a janela e a dobra, e a garagem alterna entre a visualização dividida e a única conforme isso acontece. A primeira versão da garagem dividida renderizava uma árvore de componentes diferente para cada caso, então cada dobra reconstruía a tela. Os cards repetiam a animação de entrada, a posição do scroll pulava para o topo e uma busca digitada pela metade sumia.

A correção é uma única árvore nos dois estados. O painel da lista é o mesmo componente nos dois casos, e só a largura muda. O painel de detalhe aparece ao lado quando há espaço. Cada card de veículo mantém o link para a tela própria do veículo. Na visualização 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 card abre a tela do veículo, como em qualquer celular.

Pequenos detalhes no rail

Com o Duo aberto, o rail lateral do lado final também abriga a câmera interna e o relógio. Quase todo o app já estava disposto dentro da área segura, mas os toasts calculavam as próprias margens horizontais e ficavam embaixo do rail. Agora eles também somam as margens laterais da área segura.

Os testes trouxeram uma surpresa própria. xcrun simctl io screenshot retorna uma imagem preta no simulador do Duo, então as capturas de tela 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 pixels: a tela interna na horizontal. As imagens do OdoKeep na loja são desenhadas a partir de código, não capturadas da tela. O Duo ganhou um storyboard próprio com o dispositivo em cada posição, e as telas desenhadas seguem a mesma regra de layout do app, então a posição de livro em uma captura se encontra na dobra, assim como no dispositivo real.

O que não está feito

Não há um layout próprio para cada posição, por escolha. As disposições são as descritas acima e nenhuma outra. As telas sem uma lista e uma view associada simplesmente recebem uma janela mais larga e se adaptam como já faziam no iPad.

A correção da barra de abas é um patch em uma biblioteca nativa de terceiros. Ela está fixada, testada e documentada, mas continua sendo um patch, e cada nova versão da biblioteca e do iOS exige conferir se ela ainda é necessária. A detecção da dobra requer o iOS 27.1; em versões anteriores o Duo continua funcionando, só nunca informa uma postura dobrada, então as disposições de livro e de mesa não aparecem.

Se eu tivesse que 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 ao 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.

Comece no próximo abastecimento.

O OdoKeep está na App Store. Comece de graça, privado para sempre. O Android está a caminho.

Código QR para baixar o OdoKeep na App Store

Aponte a câmera do celular para cá