# 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.

- Autor: Marcelo Silva
- Publicado em: 2026-10-12
- Tema: Mobile
- URL: https://marcelxsilva.dev/artigos/iphone-duo-no-react-native/

> **Observação: o que acontece com quem não adaptar.** Segundo a [Apple](https://developer.apple.com/iphone-duo/prepare/), 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](https://developer.apple.com/news/?id=kkphp5qo), 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](https://marcelxsilva.dev/articles/images/iphone-duo-no-react-native/01.png)

## 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](https://developer.apple.com/videos/play/wwdc2026/278/) 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](https://github.com/expo/fyi/blob/main/ios-scene-lifecycle.md) |
| Expo SDK 57 | Opcional, a partir do `expo@57.0.23` | [expo/fyi: iOS scene lifecycle](https://github.com/expo/fyi/blob/main/ios-scene-lifecycle.md) |
| React Native 0.88 | Suporte no core e `SceneDelegate` no template | [commit 7bfe32f](https://github.com/react/react-native/commit/7bfe32fd331449270eafc5f0d71bade125097d59) |
| React Native 0.87 | Sem suporte; backport pedido e ainda em aberto | [react-native-releases#1439](https://github.com/reactwg/react-native-releases/issues/1439) |

No momento em que escrevo, o React Native 0.88 ainda está em release candidate, e o [Expo SDK 58 está em beta](https://expo.dev/changelog/sdk-58-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*

```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](https://github.com/expo/fyi/blob/main/ios-scene-lifecycle.md)).

### 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](https://github.com/react/react-native/commit/7bfe32fd331449270eafc5f0d71bade125097d59):

*SceneDelegate.swift*

```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*

```xml
<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](https://github.com/expo/fyi/blob/main/ios-scene-lifecycle.md)).
- **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](https://github.com/invertase/react-native-google-mobile-ads/issues/908).
- **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](https://developer.apple.com/iphone-duo/prepare/):

| 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](https://github.com/expo/expo/pull/50243), 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](https://docs.expo.dev/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:

```tsx
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`](https://reactnative.dev/docs/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:

```tsx
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](https://developer.apple.com/iphone-duo/prepare/).
- **`key={columns}`.** A `FlatList` não aceita trocar `numColumns` com a lista montada. O próprio [código-fonte](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Lists/FlatList.js) 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](https://developer.apple.com/design/human-interface-guidelines/designing-for-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](https://developer.apple.com/iphone-duo/prepare/): 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](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo#Vertical-controls) 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:

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

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

```tsx
<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`](https://appandflow.github.io/react-native-safe-area-context/api/safe-area-view):

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

O `SafeAreaView` do próprio React Native [está depreciado](https://reactnative.dev/docs/safeareaview) 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](https://github.com/GetStream/stream-chat-react-native/pull/3818): 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](https://developer.apple.com/videos/play/wwdc2026/278/): "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](https://expo.dev/changelog/sdk-58-beta) 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](https://developer.apple.com/videos/play/wwdc2026/278/)). É 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`](https://reactnative.dev/docs/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](https://github.com/GetStream/stream-chat-react-native/pull/3818) corrigiu isso: só uma transição real para `background` fecha a conexão.

```tsx
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](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo#Vertical-controls):

![Comparação entre as barras de um iPhone comum e a coluna vertical do iPhone Duo, com a ordem dos itens numerada](https://marcelxsilva.dev/articles/images/iphone-duo-no-react-native/02.png)

O ponto que decide tudo para o React Native está no [documento de preparação](https://developer.apple.com/documentation/technologyoverviews/preparing-your-app-for-iphone-duo): 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](https://marcelxsilva.dev/articles/images/iphone-duo-no-react-native/05.png)

A troca é pelos [Native Tabs do Expo Router](https://docs.expo.dev/router/advanced/native-tabs/), 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*

```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](https://reactnavigation.org/docs/native-stack-navigator/)), 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:)`](https://github.com/software-mansion/react-native-screens/blob/main/ios/legacy/RNSScreenStackHeaderSubview.mm), justamente o tipo de item que a Apple diz que não vai para a vertical:

```tsx
<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](https://reactnavigation.org/docs/native-stack-navigator/)):

```tsx
<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`](https://docs.expo.dev/router/advanced/stack-toolbar/), disponível no iOS desde o SDK 55:

```tsx
<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](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo#Reserved-regions).

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](https://marcelxsilva.dev/articles/images/iphone-duo-no-react-native/04.png)

### A API da Apple

A dobra e as câmeras são [regiões reservadas](https://developer.apple.com/documentation/uikit/uiview/reservedregion), 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](https://marcelxsilva.dev/articles/images/iphone-duo-no-react-native/03.png)

No UIKit elas são lidas da própria view, com [esta assinatura](https://developer.apple.com/documentation/uikit/uiview/reservedregions(kind:options:)):

```swift
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](https://docs.expo.dev/modules/module-api/), a view declara um evento e o dispara a cada layout:

*modules/fold/ios/FoldModule.swift*

```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*

```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:

```tsx
<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`](https://developer.apple.com/documentation/uikit/uitraitcollection/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`](https://developer.apple.com/documentation/uikit/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`](https://github.com/garrettmac/react-native-duo) e um [`expo-display-features`](https://github.com/expo/expo/pull/50343) 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](https://expo.dev/changelog/sdk-58-beta) 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](https://developer.apple.com/iphone-duo/) traz o simulador do iPhone Duo, que roda no [Device Hub](https://developer.apple.com/documentation/xcode/device-hub), o substituto do Simulator.app no Xcode 27. A Apple aponta ele como [a melhor forma de testar a experiência completa](https://developer.apple.com/iphone-duo/prepare/). 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](https://developer.apple.com/videos/play/wwdc2026/278/), [expo/fyi](https://github.com/expo/fyi/blob/main/ios-scene-lifecycle.md) |
| App não ocupa a tela toda | Build com o iOS 27.1 SDK, imagem do EAS fixada | [Prepare](https://developer.apple.com/iphone-duo/prepare/) |
| Layout não acompanha o aparelho abrindo | `useWindowDimensions` no render, `key` na `FlatList` | [Dimensions](https://reactnative.dev/docs/dimensions) |
| Conteúdo embaixo das barras laterais | Insets por borda, `edges` no `SafeAreaView` | [HIG](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo#Vertical-controls) |
| Tab bar e botões fora da coluna lateral | Native Tabs e itens nativos com SF Symbol e título | [Preparing your app](https://developer.apple.com/documentation/technologyoverviews/preparing-your-app-for-iphone-duo) |
| Botões em cima da dobra | Colunas pares; view nativa com `reservedRegions` | [ReservedRegion](https://developer.apple.com/documentation/uikit/uiview/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.
