Todos os artigos
Mobile · 14 min de leitura

iPhone Duo: o que muda no app e como levar isso para o React Native

O que o primeiro iPhone dobrável muda no desenvolvimento de um app e o passo a passo para adaptar um app React Native.

Neste artigo18 seções
  1. O app precisa abrir: ciclo de vida de cenas
  2. Expo
  3. React Native puro
  4. O que quebra depois de migrar
  5. Compile com o SDK certo
  6. O tamanho muda no meio da sessão
  7. A safe area deixa de ser simétrica
  8. Orientação e tela cheia não seguram mais o layout
  9. AppState em Split View
  10. Entregue as barras para o sistema
  11. Tab bar
  12. Header e botões
  13. A dobra: quando o app precisa de código nativo
  14. A API da Apple
  15. O módulo com Expo Modules
  16. O que mais cabe no mesmo módulo
  17. Testando
  18. Resumo

Observação: o que acontece com quem não adaptar. Segundo a Apple, apps compilados com o iOS 26 SDK ou anterior rodam no iPhone Duo, mas não se adaptam aos layouts. Com o aparelho aberto, o app aparece no centro da tela interna, cercado de espaço vazio. Fechado, o conteúdo fica à esquerda da barra de status e da câmera na tela externa. Recompilar com o iOS 27 SDK já faz o app ocupar quase toda a tela interna, mas ele ainda desvia da barra de status na borda direita. A experiência completa só vem com o Xcode 27.1, e o iPhone Duo chega às lojas em 23 de outubro de 2026.

App sem adaptar no iPhone Duo fechado e aberto, comparado com o mesmo app recompilado com o iOS 27 SDK e com o Xcode 27.1

O app precisa abrir: ciclo de vida de cenas

Antes de qualquer ajuste de layout existe um pré-requisito que não é do Duo, é do iOS 27 SDK. Na sessão Modernize your UIKit app da WWDC26 a Apple é direta: "UIScene lifecycle is now required when building with the latest SDKs. Without it, your application will no longer launch."

Um app React Native que cria a janela no AppDelegate, que é como todos os templates fizeram até hoje, abre com tela preta ou nem abre quando compilado com o Xcode 27. A situação de cada stack:

Stack Suporte a cenas Fonte
Expo SDK 58 Padrão, sem configuração expo/fyi: iOS scene lifecycle
Expo SDK 57 Opcional, a partir do expo@57.0.23 expo/fyi: iOS scene lifecycle
React Native 0.88 Suporte no core e SceneDelegate no template commit 7bfe32f
React Native 0.87 Sem suporte; backport pedido e ainda em aberto react-native-releases#1439

No momento em que escrevo, o React Native 0.88 ainda está em release candidate, e o Expo SDK 58 está em beta justamente esperando ele sair.

Expo

No SDK 58 não há nada a fazer. No SDK 57, a opção fica no expo-build-properties:

app.json

{
  "expo": {
    "plugins": [
      ["expo-build-properties", { "ios": { "enableSceneSupport": true } }]
    ]
  }
}

Depois rode npx expo install --fix e npx expo prebuild --clean. O plugin só sabe editar o AppDelegate do template; se o seu foi customizado, as mudanças precisam ser feitas à mão, usando EXExpoAppSceneDelegate como delegate da cena (expo/fyi).

React Native puro

No 0.88 o template divide as responsabilidades: o AppDelegate continua sendo o ponto de entrada do processo, mas não cria mais janela nem sobe o React Native. Esse trabalho vai para um SceneDelegate novo. Este é o arquivo do template, no commit que adicionou o suporte:

SceneDelegate.swift

import React
import ReactAppDependencyProvider
import React_RCTAppDelegate
import UIKit

class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate {
  var window: UIWindow?
  var reactNativeFactory: RCTReactNativeFactory?

  func scene(
    _ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions
  ) {
    guard let windowScene = scene as? UIWindowScene else {
      return
    }

    dependencyProvider = RCTAppDependencyProvider()
    reactNativeFactory = RCTReactNativeFactory(delegate: self)
    window = UIWindow(windowScene: windowScene)

    reactNativeFactory?.startReactNative(
      withModuleName: "HelloWorld",
      in: window,
      connectionOptions: connectionOptions
    )
  }

  func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    RCTLinkingManager.scene(scene, openURLContexts: URLContexts)
  }

  func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    RCTLinkingManager.scene(scene, continue: userActivity)
  }

  override func bundleURL() -> URL? {
    #if DEBUG
    RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
    #else
    Bundle.main.url(forResource: "main", withExtension: "jsbundle")
    #endif
  }
}

E o Info.plist ganha o manifesto que aponta para essa classe:

Info.plist

<key>UIApplicationSceneManifest</key>
<dict>
  <key>UISceneConfigurations</key>
  <dict>
    <key>UIWindowSceneSessionRoleApplication</key>
    <array>
      <dict>
        <key>UISceneConfigurationName</key>
        <string>Default Configuration</string>
        <key>UISceneDelegateClassName</key>
        <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
      </dict>
    </array>
  </dict>
</dict>

Repare em duas coisas. O startReactNative agora recebe connectionOptions em vez de launchOptions; essa sobrecarga só existe a partir do 0.88, então não adianta copiar esse arquivo para um projeto 0.87. E os deep links passam pelos métodos scene(_:openURLContexts:) e scene(_:continue:), porque com cenas o UIKit para de chamar os equivalentes do AppDelegate.

O que quebra depois de migrar

O app abrir não significa que tudo funciona. O que costuma quebrar é o que se pendurava no AppDelegate:

  • Deep links na abertura a frio. Confira se Linking.getInitialURL() ainda devolve a URL e se o link não chega duplicado (expo/fyi).
  • Bibliotecas que leem AppDelegate.window. No template novo essa propriedade não existe mais, e quem a acessa quebra. Foi o caso do formulário de consentimento do react-native-google-mobile-ads.
  • SDKs que escutam eventos do UIApplicationDelegate: push, analytics, atribuição, crash reporting. Atualize cada um para a versão que suporta cenas antes de subir o build.

Compile com o SDK certo

Com o app abrindo, o próximo ponto é o SDK de build. O quanto o app aproveita a tela interna do Duo depende do SDK com que ele foi compilado, não da versão do iOS no aparelho. A tabela é do checklist de preparação da Apple:

SDK de build Comportamento no iPhone Duo
iOS 26 ou anterior Roda, mas não se adapta: aberto, fica centralizado com espaço vazio em volta
iOS 27 Ocupa quase toda a tela interna, mas ainda desvia da barra de status
iOS 27.1 ou posterior Borda a borda nas duas telas, com toolbar e tab bar na vertical

O mesmo checklist avisa: antes de publicar com o 27.1 SDK, garanta que o conteúdo se adapta às barras verticais. Tela cheia e barras laterais vêm juntas.

No EAS, o SDK vem do Xcode da imagem de build, não da versão do Expo. O Expo adicionou imagens com Xcode 27.0 e 27.1, com o 27.1 ainda marcado como beta, e o alias sdk-58 resolve para o Xcode 27.0. Para ter o comportamento completo é preciso fixar a imagem com Xcode 27.1 no campo ios.image de cada perfil do eas.json.

O tamanho muda no meio da sessão

Esta é a mudança que mais afeta código JavaScript. Num iPhone comum, o tamanho da janela só mudava na rotação. No Duo ele muda ao abrir, fechar, e quando outro app divide a tela interna.

O padrão que mais encontramos em projetos React Native é este:

const { width } = Dimensions.get("window");

const styles = StyleSheet.create({
  card: { width: width / 2 - 24 },
});

O width é lido quando o módulo carrega e nunca mais muda. Na tela externa os cards ficam certos; ao abrir o aparelho, continuam com metade da largura da tela externa. A documentação do Dimensions cita esse caso nominalmente: os valores "may change (e.g due to device rotation, foldable devices etc)", e o recomendado é ler a cada render em vez de guardar num StyleSheet. A ferramenta para isso é o useWindowDimensions, que "updates as the window's dimensions update".

O mesmo componente, corrigido:

function CardGrid({ items }: { items: Item[] }) {
  const { width } = useWindowDimensions();
  const insets = useSafeAreaInsets();
  const contentWidth = width - insets.left - insets.right;
  const columns = contentWidth >= 1000 ? 4 : contentWidth >= 600 ? 2 : 1;

  return (
    <FlatList
      key={columns}
      data={items}
      numColumns={columns}
      renderItem={({ item }) => <Card item={item} />}
    />
  );
}

Cada detalhe desse trecho tem um motivo:

  • "window", nunca "screen". Pelo Dimensions, window é o "size of the visible application window" e screen é o "size of the device's screen". Com outro app na tela interna, os dois são diferentes. A Apple faz o mesmo pedido no nativo: trocar UIScreen.main.bounds pelo tamanho da view ou da janela.
  • key={columns}. A FlatList não aceita trocar numColumns com a lista montada. O próprio código-fonte lança o erro "Changing numColumns on the fly is not supported. Change the key prop on FlatList when changing the number of columns".
  • Colunas em número par. 1, 2 ou 4, nunca 3. A HIG do iPhone Duo recomenda número par de colunas em grades para o conteúdo dividir certinho na dobra.
  • Largura descontando os insets. As barras laterais e a câmera entram na safe area, então a largura útil é a janela menos as bordas.

Procure também por decisões de layout baseadas em tipo de aparelho, como DeviceInfo.isTablet() ou Platform.isPad. A Apple pede para não escolher layout por idiom nem tratar largura regular como iPad: no Duo, um app de iPhone recebe largura de tablet. A decisão sai do espaço disponível, como no exemplo acima.

A safe area deixa de ser simétrica

Na tela externa, e na interna em paisagem, as barras ficam numa coluna lateral de um lado só. A HIG chama isso de asymmetry e pede para usar a safe area de cada borda, inclusive quando outro app está do outro lado da tela.

Este código, comum em headers e listas, aplica o inset esquerdo nos dois lados:

<View style={{ paddingHorizontal: insets.left }} />

Do lado das barras, o conteúdo fica embaixo delas. Cada borda precisa do seu inset:

<View style={{ paddingLeft: insets.left, paddingRight: insets.right }} />

Ou, mais simples, o SafeAreaView do react-native-safe-area-context com as bordas escolhidas em edges:

<SafeAreaView edges={["left", "right"]} style={{ flex: 1 }}>
  <ChannelList />
</SafeAreaView>

O SafeAreaView do próprio React Native está depreciado e a documentação manda usar o react-native-safe-area-context no lugar.

Elementos com posição absoluta (toasts, botões flutuantes, banners de offline) não herdam padding de ninguém e precisam somar o inset ao left e ao right. Um bom exemplo real é a PR de preparação para o iPhone Duo do Stream Chat: boa parte das mudanças foi exatamente separar os insets horizontais e somá-los aos elementos absolutos.

Orientação e tela cheia não seguram mais o layout

Muitos apps React Native travam em retrato e assumem que o layout nunca vai ficar largo. Com o iOS 27 SDK essa premissa acaba. Na mesma sessão da WWDC26: "an app's supported interface orientation is a preference provided to the system. It will be ignored when your app is running in a resizable environment. You should not consider interface orientation for any layout calculations."

Na prática, para quem usa Expo:

  • "orientation": "portrait" e ScreenOrientation.lockAsync. O changelog do SDK 58 avisa que o lockAsync "may have no effect while the app is resizable".
  • ios.requireFullScreen. Ele vira UIRequiresFullScreen, que no iOS 27 "no longer opts your app fully out of resizing. Instead, it enables discrete resizing that honors your supported interface orientations" (WWDC26). É uma saída pensada para jogos, não para fugir do layout responsivo.

O layout precisa funcionar largo e estreito, independente da orientação.

AppState em Split View

Um efeito colateral menos óbvio. Com dois apps na tela interna, o app passa por inactive com frequência sem ir para o fundo. A documentação do AppState define inactive no iOS como o estado de transição, inclusive ao "entering the multitasking view". Código que trata inactive como background derruba conexão à toa.

A mesma PR do Stream Chat corrigiu isso: só uma transição real para background fecha a conexão.

useEffect(() => {
  const subscription = AppState.addEventListener("change", (state) => {
    if (state === "background") closeConnection();
    if (state === "active") openConnection();
  });
  return () => subscription.remove();
}, []);

Entregue as barras para o sistema

Esta é a parte mais visível da adaptação. No Duo, a barra de navegação, a toolbar e a tab bar vão para uma coluna lateral, na tela externa e na interna em paisagem. A ordem na coluna é definida pela Apple na seção Vertical controls da HIG:

Comparação entre as barras de um iPhone comum e a coluna vertical do iPhone Duo, com a ordem dos itens numerada

O ponto que decide tudo para o React Native está no documento de preparação: para a barra ir para a lateral, ela precisa ser do container de navegação. Barra montada à mão fica onde está. E sobre os itens: "If your item uses a custom view rather than a title or icon, the system doesn't present it vertically."

Traduzindo para o nosso mundo: tudo que é desenhado em JavaScript é, para o iOS, só conteúdo do app.

Tab bar

O @react-navigation/bottom-tabs desenha a tab bar com views comuns. O sistema não sabe que aquilo é uma tab bar, e na tela externa ela fica embaixo, gastando a altura que a tela não tem.

Tela externa com tab bar desenhada em JS embaixo, comparada com tab bar nativa na coluna lateral

A troca é pelos Native Tabs do Expo Router, que usam a tab bar do sistema e ficam estáveis no SDK 58 (expo-router/native-tabs; do SDK 55 ao 57 o import é expo-router/unstable-native-tabs):

app/(tabs)/_layout.tsx

import { NativeTabs } from "expo-router/native-tabs";

export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Icon sf="house.fill" md="home" />
        <NativeTabs.Trigger.Label>Início</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Icon sf="gear" md="settings" />
        <NativeTabs.Trigger.Label>Ajustes</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}

Header e botões

O native stack do React Navigation "uses the native APIs UINavigationController on iOS" (docs), então o header em si vai para a lateral. O cuidado é com os botões.

O headerRight recebe um elemento React, e o react-native-screens embrulha esse elemento num UIBarButtonItem(customView:), justamente o tipo de item que a Apple diz que não vai para a vertical:

<Stack.Screen
  name="Note"
  options={{
    headerRight: () => (
      <Pressable onPress={share}>
        <Ionicons name="share-outline" size={22} />
      </Pressable>
    ),
  }}
/>

A alternativa são os itens nativos, unstable_headerRightItems e unstable_headerLeftItems, que viram botões de verdade do UIKit com ícone em SF Symbol e título (docs):

<Stack.Screen
  name="Note"
  options={{
    unstable_headerRightItems: () => [
      {
        type: "button",
        label: "Compartilhar",
        icon: { type: "sfSymbol", name: "square.and.arrow.up" },
        onPress: share,
      },
    ],
  }}
/>

Dar ícone e título não é capricho. O documento da Apple explica que, na vertical, o sistema usa só o ícone; no menu de overflow, ícone e título; e item que só tem título não vai para a vertical. Com os dois, o sistema escolhe a forma certa para cada pose.

No Expo Router, o mesmo resultado sai do Stack.Toolbar, disponível no iOS desde o SDK 55:

<Stack.Toolbar placement="right">
  <Stack.Toolbar.Button icon="square.and.arrow.up" onPress={share} />
</Stack.Toolbar>

Os dois ainda são marcados como instáveis, e as opções de ordem de overflow que a Apple criou para o Duo (visibilityPriority, ToolbarOverflowMenu) não aparecem nas documentações deles. Na prática, vale manter poucos botões por tela e colocar o mais importante primeiro.

Sheets seguem a mesma lógica: um sheet desenhado em JS não sabe desviar da dobra, e um sheet do sistema sabe. Prefira presentation: "formSheet" no native stack.

A dobra: quando o app precisa de código nativo

Até aqui, nada exigiu código nativo além do SceneDelegate. Para a maioria dos apps, os passos acima bastam: listas, formulários e telas com scroll lidam bem com a dobra, e os componentes do sistema (alertas, menus, sheets) desviam dela sozinhos.

Ler a dobra só vale a pena quando o app tem conteúdo que não pode cair nela: um layout de duas colunas customizado, um player, um mapa com controles, um jogo. E aqui o React Native não ajuda: nenhuma informação nova do sistema chega ao JS sozinha.

Diagrama do que chega ao JavaScript sozinho e do que precisa de módulo nativo

A API da Apple

A dobra e as câmeras são regiões reservadas, de dois tipos: division, "a region where content splits into two separate regions", e occlusion, "a region where an element, such as a camera, occludes content".

Tela interna parcialmente dobrada com a dobra no meio e a câmera interna, e tela externa com a câmera no canto

No UIKit elas são lidas da própria view, com esta assinatura:

func reservedRegions(
  kind: UIView.ReservedRegion.Kind,
  options: UIView.ReservedRegion.QueryOptions = []
) -> [UIView.ReservedRegion]

Cada região tem frame ("in the view's coordinate space, including the margins"), isActive e margins. Por padrão só vêm as regiões ativas; as inativas exigem a opção includeInactive. Isso importa porque a dobra só é ativa com o aparelho parcialmente aberto: aberto e plano, ela existe, mas não divide nada.

O detalhe que define o desenho do módulo: o frame vem no sistema de coordenadas da view que perguntou. Então, em vez de um módulo sem view, o melhor é uma view nativa que envolve o conteúdo: os frames chegam ao JS já nas coordenadas certas para o layout.

O módulo com Expo Modules

Com a API de módulos do Expo, a view declara um evento e o dispara a cada layout:

modules/fold/ios/FoldModule.swift

import ExpoModulesCore

public class FoldModule: Module {
  public func definition() -> ModuleDefinition {
    Name("Fold")

    View(FoldView.self) {
      Events("onFoldChange")
    }
  }
}

class FoldView: ExpoView {
  let onFoldChange = EventDispatcher()

  override func layoutSubviews() {
    super.layoutSubviews()
    let folds = reservedRegions(kind: .division).map(\.frame)

    onFoldChange([
      "folds": folds.map { ["x": $0.minX, "y": $0.minY, "width": $0.width, "height": $0.height] }
    ])
  }
}

No JS, um componente que entrega a posição da dobra para quem estiver dentro dele:

modules/fold/index.tsx

import { requireNativeViewManager } from "expo-modules-core";

type Rect = { x: number; y: number; width: number; height: number };
type FoldEvent = { nativeEvent: { folds: Rect[] } };

const NativeFoldView = requireNativeViewManager("Fold");

export function FoldAware({ children }: { children: (folds: Rect[]) => ReactNode }) {
  const [folds, setFolds] = useState<Rect[]>([]);

  return (
    <NativeFoldView style={{ flex: 1 }} onFoldChange={(e: FoldEvent) => setFolds(e.nativeEvent.folds)}>
      {children(folds)}
    </NativeFoldView>
  );
}

E o uso, encostando cada coluna numa metade da tela:

<FoldAware>
  {(folds) => {
    const fold = folds[0];
    if (!fold) return <SingleColumn />;

    return (
      <View style={{ flex: 1, flexDirection: "row" }}>
        <View style={{ width: fold.x }}>
          <List />
        </View>
        <View style={{ width: fold.width }} />
        <View style={{ flex: 1 }}>
          <Detail />
        </View>
      </View>
    );
  }}
</FoldAware>

Dois pontos para validar no simulador antes de confiar nesse módulo. A documentação da Apple não diz se a dobra ficar ativa dispara um novo layoutSubviews, já que o tamanho da view não muda; se não disparar, o evento precisa vir também de uma mudança de traits (registerForTraitChanges). E, como o frame inclui as margens para conteúdo interativo, ele é a área que botões devem evitar, não a largura física da dobra.

O que mais cabe no mesmo módulo

A mesma view é o lugar natural para expor:

  • O lado das barras, pelo trait verticalBarEdge, que devolve leading, trailing ou unspecified. Útil quando o app tem algum controle próprio que não pode ficar do mesmo lado da coluna do sistema.
  • O estado da dobradiça, pelo UIHingeInteraction, "an interaction for observing the hinge state". Para layout, o que vale continua sendo tamanho e regiões reservadas.

Existem tentativas da comunidade de empacotar isso, como o @garrettmacmac/react-native-duo e um expo-display-features ainda em rascunho no repositório do Expo. O próprio autor do primeiro avisa que o código nativo ainda não rodou num iPhone Duo, e o Expo prometeu integração com o Duo durante o beta do SDK 58. Vale acompanhar; para produção hoje, um módulo pequeno e testado é mais seguro.

Testando

O Xcode 27.1 traz o simulador do iPhone Duo, que roda no Device Hub, o substituto do Simulator.app no Xcode 27. A Apple aponta ele como a melhor forma de testar a experiência completa. Para os problemas de redimensionamento, dá para adiantar com o iPhone Mirroring no macOS 27, redimensionando a janela para os extremos.

O roteiro que cobre o que vimos aqui:

  1. Abrir e fechar o aparelho em cada tela principal, conferindo se o estado (item selecionado, scroll, texto digitado) sobrevive.
  2. Girar o aparelho aberto, mesmo com o app travado em retrato.
  3. Dobrar parcialmente e conferir se nada tocável fica na dobra.
  4. Abrir outro app em Split View na tela interna, dos dois lados.
  5. Abrir o teclado em cada pose.

Resumo

Problema Correção Fonte
App não abre com Xcode 27 Ciclo de vida de cenas: Expo SDK 58, flag no SDK 57, ou RN 0.88 WWDC26, expo/fyi
App não ocupa a tela toda Build com o iOS 27.1 SDK, imagem do EAS fixada Prepare
Layout não acompanha o aparelho abrindo useWindowDimensions no render, key na FlatList Dimensions
Conteúdo embaixo das barras laterais Insets por borda, edges no SafeAreaView HIG
Tab bar e botões fora da coluna lateral Native Tabs e itens nativos com SF Symbol e título Preparing your app
Botões em cima da dobra Colunas pares; view nativa com reservedRegions ReservedRegion

Quase todo o trabalho de levar um app React Native para o iPhone Duo é revisar JavaScript e trocar componentes desenhados em JS pelos nativos. Código nativo de verdade só entra no SceneDelegate e, se o app precisar, na leitura da dobra.

Espero que seja útil para você também, grande abraço.

Leia também