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
- O app precisa abrir: ciclo de vida de cenas
- Expo
- React Native puro
- O que quebra depois de migrar
- Compile com o SDK certo
- O tamanho muda no meio da sessão
- A safe area deixa de ser simétrica
- Orientação e tela cheia não seguram mais o layout
- AppState em Split View
- Entregue as barras para o sistema
- Tab bar
- Header e botões
- A dobra: quando o app precisa de código nativo
- A API da Apple
- O módulo com Expo Modules
- O que mais cabe no mesmo módulo
- Testando
- 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.

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". PeloDimensions,windowé o "size of the visible application window" escreené 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: trocarUIScreen.main.boundspelo tamanho da view ou da janela.key={columns}. AFlatListnão aceita trocarnumColumnscom 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"eScreenOrientation.lockAsync. O changelog do SDK 58 avisa que olockAsync"may have no effect while the app is resizable".ios.requireFullScreen. Ele viraUIRequiresFullScreen, 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:

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.

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.

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

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 devolveleading,trailingouunspecified. Ú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:
- Abrir e fechar o aparelho em cada tela principal, conferindo se o estado (item selecionado, scroll, texto digitado) sobrevive.
- Girar o aparelho aberto, mesmo com o app travado em retrato.
- Dobrar parcialmente e conferir se nada tocável fica na dobra.
- Abrir outro app em Split View na tela interna, dos dois lados.
- 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.






