Saltar al contenido principal
Desarrollo Móvil iOS

Soporte para iPhone Duo en apps Capacitor

Prepara las apps WebView de Capacitor para iPhone Duo: viewport-fit=cover, env(safe-area-inset-*), plugins System Bars y Status Bar, pruebas de plegado y redimensionado, y @capgo/capacitor-native-navigation en la barra lateral.

iPhone Duo abierto en horizontal con las barras nativas en el lateral, y en vertical con la tab bar en la parte inferior

El iPhone Duo plegable de Apple cambia cómo funcionan el system chrome y las safe areas. Expo publicó una guía práctica para apps de React Native en SDK 58. Este post es el equivalente en Capacitor: un WebView, insets de CSS env, plugins core de Capacitor y barras nativas opcionales de @capgo/capacitor-native-navigation.

Qué cambia en el iPhone Duo

En muchos casos, iOS desplaza la barra de estado, la barra de navegación y la barra de pestañas hacia un barra lateral vertical en el borde leading o trailing. Las barras horizontales siguen apareciendo en portrait en la pantalla interior abierta y en los layouts clásicos de teléfono.

La imagen de arriba muestra la pantalla abierta. En landscape, la barra de herramientas y la barra de pestañas se apilan en la barra lateral. En portrait, la barra de herramientas vuelve arriba y la barra de pestañas abajo.

Para contenido web dentro de un WebView de Capacitor, los cambios clave son:

  • env(safe-area-inset-top) pueden ser 0 cuando la barra de estado está en la barra lateral, no debajo del notch.
  • Los insets leading y trailing aumentan (Apple documenta del orden de ~84pt para la barra lateral en landscape y posturas plegadas). Izquierda y derecha no son espejo la una de la otra.
  • Cambios en el tamaño de la ventana cuando el usuario pliega, despliega, rota o usa Split View en la pantalla interna. Los valores de layout en caché quedan obsoletos rápido.
  • Las tab bars nativas siguen el rail cuando usas UITabBar (o Capgo Native Navigation con la tab bar del sistema). Las tab bars HTML fijas personalizadas no lo hacen.

Trata la pantalla externa plegada como ancho compacto y la pantalla interna abierta como ancho regular. Amplía los layouts en vez de cambiar de app shell.

Una app de correo en iPhone Duo en tres posiciones. Plegada, la toolbar y la tab bar se apilan en un rail vertical a la derecha. En Split View, cada ventana mantiene un rail en su borde exterior. Abierta en portrait, la toolbar va por la parte superior y la tab bar queda como una píldora en la parte inferior.

Dónde van las barras del sistema: un rail a la derecha cuando el dispositivo está plegado, en el borde exterior de cada ventana en Split View, y barras horizontales de nuevo en la pantalla abierta en portrait.

Requisitos

  • Capacitor 8 en iOS 27 (Xcode 27.1 con el runtime del simulador iPhone Duo).
  • Device Hub (Xcode > Open Developer Tool > Device Hub) para plegar, bisagrar y rotar el simulador Duo.
  • Una viewport que opta por las safe areas (ver abajo).

viewport-fit=cover y safe areas de CSS

Las variables de entorno de safe area solo se aplican cuando el viewport cubre toda la pantalla:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

Pad interactivo contenido por borde. Deja que los fondos lleguen hasta el borde para lograr mayor inmersión.

Elige una responsable del inset para toda la app. Los dos fragmentos de abajo son alternativas, no un par que se combine.

Opción A: shell por página

.page {
  min-height: 100dvh;
  padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
  padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
  padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
  padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}

Omite la Opción B cuando uses la Opción A.

En scroll views, aplica el inset al contenido, no solo el contenedor de scroll externo, para que los fondos se desplacen a ancho completo mientras el texto y los botones se mantienen alejados del rail.

Usa cuatro valores independientes. No asumas padding-left es igual a padding-right. En RTL, el rail se mantiene en el lado de la cámara; lee ambos left y right valores de env.

Hay dos puntos fáciles de pasar por alto:

  • Un scroller horizontal dentro de una página ya con padding necesita su propio padding final. Si no, la última tarjeta se desliza bajo el rail.
  • Un botón flotante configurado en right: 16px ignora la barra. Añade el inset izquierdo o derecho en vivo y recalculalo cuando la ventana cambie de tamaño.

Para apps con Tailwind, @capgo/tailwind-capacitor expone pt-safe, px-safe, y utilidades relacionadas en lugar de repetir env() por todas partes.

Capacitor System Bars (safe area en Android y config en iOS)

Capacitor 8 incluye Barras del sistema en @capacitor/core. Reemplaza el antiguo exclusivo de Android adjustMarginsForEdgeToEdge flag. Configúralo en capacitor.config.ts:

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  plugins: {
    SystemBars: {
      insetsHandling: 'css',
    },
  },
};

export default config;
  • insetsHandling: 'css' (predeterminado en Android): inyecta --safe-area-inset-* variables CSS cuando los valores del entorno WebView son incorrectos (versiones antiguas de WebView en Android).
  • La opción B (shell para todo el sitio) utiliza patrón de fallback del documentación de System Bars. Omite la opción A si usas la opción B.
html {
  padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
  padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
  padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
  padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
}

En tiempo de ejecución puedes estilizar u ocultar barras con SystemBars.setStyle, SystemBars.show, y SystemBars.hide de los @capacitor/core. There is no separate @capacitor/safe-area paquete: áreas seguras para apps de Capacitor env de CSS, Variables de barras del sistema en Android, y tus propios listeners en resize / visualViewport.

@capacitor/status-bar

El el plugin Status Bar sigue controlando el overlay y el estilo en iOS:

import { StatusBar, Style } from '@capacitor/status-bar';

await StatusBar.setOverlaysWebView({ overlay: true });
await StatusBar.setStyle({ style: Style.Dark });

setOverlaysWebView({ overlay: true }) permite que el HTML se dibuje bajo la región de la status bar. Combínalo con env padding o recortarás el contenido. En iPhone Duo, el modo overlay sigue aplicándose; la geometría de los insets (cuál es superior y cuál lateral) proviene del safe area del sistema, no solo de las APIs de Status Bar.

No acumules márgenes automáticos del WebView, env padding de página completa, y variables de insets de la barra nativa sin una estrategia explícita. Ese patrón provoca espaciado doble en cada iPhone, y empeora cuando el rail mueve los insets a los lados.

contentInset de la WebView de iOS

En capacitor.config.ts, ios.contentInset se mapea al scroll view de la WebView contentInsetAdjustmentBehavior. Muchas apps usan 'never' cuando gestionan insets en CSS. Si confías en el ajuste automático de UIKit y el padding env de CSS, elige una única fuente de verdad.

Ver configuración de iOS de Capacitor.

Redimensionamiento del WebView al plegar y desplegar

Capacitor mantiene un único WebView a pantalla completa. Cuando el Duo cambia de postura:

  1. UIKit actualiza los safe area insets en el view controller.
  2. El layout pass del WebView se ejecuta con un nuevo ancho y alto.
  3. CSS env(safe-area-inset-*) y 100dvh / visualViewport se actualizan en el siguiente style flush.

Escucha cambios de geometría en JavaScript cuando cacheas posiciones dependientes de los insets (botones flotantes, toasts, canvas overlays):

function onGeometryChange() {
  const vv = window.visualViewport;
  const width = vv?.width ?? window.innerWidth;
  const height = vv?.height ?? window.innerHeight;
  const offsetLeft = vv?.offsetLeft ?? 0;
  const offsetTop = vv?.offsetTop ?? 0;
  document.documentElement.style.setProperty('--app-vv-width', `${width}px`);
  document.documentElement.style.setProperty('--app-vv-height', `${height}px`);
  document.documentElement.style.setProperty('--app-vv-offset-left', `${offsetLeft}px`);
  document.documentElement.style.setProperty('--app-vv-offset-top', `${offsetTop}px`);
}

window.addEventListener('resize', onGeometryChange);
window.visualViewport?.addEventListener('resize', onGeometryChange);
window.visualViewport?.addEventListener('scroll', onGeometryChange);
onGeometryChange();

El scroll listener cubre los desplazamientos del visual-viewport que cambian offsetLeft / offsetTop sin redimensionar el ancho o el alto (por ejemplo, cambios de viewport por el teclado en iOS).

En Android, incluye density en android:configChanges en tu actividad principal para que los plegables y los cambios de tamaño de pantalla no rompan el WebView. Capacitor 8 plantillas lo documentan en el guía de actualización de Capacitor 8.

Orientación y clases de tamaño de ventana

Usa en vivo ancho para ramificar el layout, no screen.width capturado una vez al inicio:

const compactQuery = window.matchMedia('(max-width: 600px)');

function readCompactWidth() {
  return compactQuery.matches;
}

compactQuery.addEventListener('change', () => {
  document.documentElement.dataset.widthClass = readCompactWidth() ? 'compact' : 'regular';
});
document.documentElement.dataset.widthClass = readCompactWidth() ? 'compact' : 'regular';

En CSS, prefiere flex y grid con minmax() en lugar de 100vw anchos fijos. 100vw a menudo ignora las barras de desplazamiento y la asimetría del rail, y provoca desbordamiento horizontal.

Para Split View en la pantalla interna, cada ventana puede ser estrecha (Apple cita unos 469pt de ancho). Prueba tu app anclada a la izquierda y a la derecha: el rail se une a la Ventana borde, así que los insets horizontales pueden cambiar de lado entre ventanas.

Interfaz HTML personalizada vs barras nativas

La guía de Expo recomienda las nativas Stack y NativeTabs así iOS controla la posición del rail. La misma idea se aplica en Capacitor:

Enfoque Comportamiento de iPhone Duo
Header HTML fijo + tabs inferiores Se mantiene en los rectángulos seguros clásicos superior/inferior. No entra en el rail.
@capgo/capacitor-native-navigation con el sistema UITabBar / UINavigationBar Las barras siguen la posición del sistema, incluyendo el rail vertical.

Si debes mantener el chrome de HTML, audita cada pantalla en busca de izquierda y derecha padding de env, no solo arriba y abajo.

La misma pantalla con un encabezado HTML fijo y pestañas inferiores. Plegado, el encabezado y las pestañas permanecen en los rectángulos clásicos y solo evitan el riel. Abierto en vertical, se mantienen arriba y abajo.

El chrome de HTML fijo no sigue el riel. Permanece en los rectángulos seguros superior e inferior. Las barras nativas, dibujadas por UIKit o por @capgo/capacitor-native-navigation, se mueven con el sistema.

@capgo/capacitor-native-navigation en iPhone Duo

Native Navigation dibuja barras reales UINavigationBar y UITabBar (o barras Material en Android) sobre un único WebView de Capacitor. En iOS 27 con iPhone Duo, system tab bars move into the side rail en posturas plegadas y muchos paisajes. El plugin curva mantiene la forma de la barra de pestañas en el borde inferior. La acción central conserva un soporte circular mientras la ventana se ensancha.

Dos diseños abiertos en horizontal de @capgo/capacitor-native-navigation. A la izquierda, la barra de pestañas del sistema está en el riel lateral. A la derecha, la barra de pestañas curva se mantiene en la parte inferior y la acción central está en un soporte circular.

Izquierda, la barra de pestañas del sistema está en el riel. Derecha, la barra de pestañas curva se mantiene abajo y la acción central conserva su soporte.

Configura una vez

import { NativeNavigation } from '@capgo/capacitor-native-navigation';

await NativeNavigation.configure({
  contentInsetMode: 'css',
});

contentInsetMode: 'css' writes bar and safe overlap sizes to the document root:

  • --cap-native-navigation-top
  • --cap-native-navigation-right
  • --cap-native-navigation-bottom
  • --cap-native-navigation-left
  • --cap-native-navbar-height
  • --cap-native-tabbar-height
  • --cap-native-system-tabbar-height (altura nominal de la barra de pestañas cuando tabbarHeight es 0 para barras del sistema alojadas)

Cuando la Navegación Nativa controla las barras, usa el relleno con las variables del plugin a continuación. Ya incluyen la superposición de la barra nativa y los bordes de área segura de UIKit cuando una barra está activa. Usa max() with env fallbacks so edges stay correct when a variable is 0 (por ejemplo, barras de pestañas del sistema alojadas) sin añadir padding de plugin y env a la vez.

main {
  padding-top: max(var(--cap-native-navigation-top, 0px), env(safe-area-inset-top, 0px));
  padding-right: max(var(--cap-native-navigation-right, 0px), env(safe-area-inset-right, 0px));
  padding-bottom: max(var(--cap-native-navigation-bottom, 0px), env(safe-area-inset-bottom, 0px));
  padding-left: max(var(--cap-native-navigation-left, 0px), env(safe-area-inset-left, 0px));
}

No apliques también el padding de la Opción A o la Opción B en el mismo main elemento.

En iOS 26+ con una barra de pestañas tab bar, tabbarHeight los insets del plugin pueden ser 0 para que el contenido web no se eleve dos veces. Usa --cap-native-system-tabbar-height cuando necesites espaciado para acciones flotantes sobre la barra.

safeAreaChanged

Suscríbete a cambios de visibilidad del pliegue, rail lateral o barra:

await NativeNavigation.addListener('safeAreaChanged', ({ insets }) => {
  console.log(insets.top, insets.right, insets.bottom, insets.left);
  console.log(insets.navbarHeight, insets.tabbarHeight, insets.systemTabbarHeight);
});

El plugin también emite capNativeNavigation:safeAreaChanged en window con el mismo insets payload.

Native code aplica un pequeño espacio superior cuando safeInsets.top es cero (la barra de estado en el rail) para que los botones de la navbar no toquen la esquina redondeada de la pantalla. Tu padding web debe seguir usando env left/right para el rail.

setNavbar, setTabbar, y configure todos devuelven { insets } cuando necesitas una lectura única tras actualizar chrome.

Referencia completa de API: Docs de Native Navigation.

Lista de verificación de pruebas

En Device Hub con el simulador iPhone Duo:

  1. Plegado: los controles no están bajo la barra lateral; los toques se registran; los insets horizontales de env son no nulos donde se espera.
  2. Abrir en vertical: las barras superior/inferior vuelven a donde iOS las coloca; el layout no salta a una arquitectura de información diferente.
  3. Abrir en horizontal / barra lateral: el contenido evita la barra lateral; las acciones flotantes usan insets.right o insets.left, no hard-coded 16px.
  4. Plegado parcial: no hay UI crítica en la zona de bisagra; los modales y sheets siguen siendo utilizables.
  5. Vista dividida: ejecuta la app en las ventanas izquierda y derecha; el borde del rail sigue a cada ventana.
  6. Plegar con una pantalla abierta: el estado, la posición de scroll y la entrada de formularios sobreviven a la transición.
  7. Simulador clásico de iPhone: sin regresiones.

Prompt para el agente (copia y pega)

Usa este bloque con Claude Code, Cursor, Codex u otro agente de código trabajando en un repo de Capacitor:

Task: Make this Capacitor app safe for iPhone Duo (iOS 27 foldable).

Context:
- Read https://capgo.app/blog/capacitor-iphone-duo-safe-area-guide/ and Expo's https://expo.dev/blog/supporting-iphone-duo for platform behavior.
- One WebView. Use viewport-fit=cover and per-edge env(safe-area-inset-*) padding for interactive content; backgrounds may ignore safe areas.
- Capacitor 8: configure plugins.SystemBars (insetsHandling css). Set viewport-fit=cover in index.html. Use var(--safe-area-inset-*, env(...)) fallbacks on Android.
- @capacitor/status-bar: only if the app already uses it; setOverlaysWebView must match CSS padding strategy.
- Do not double-apply Capacitor margin adjustment, full-page env padding, and native navigation CSS variables.
- On resize and visualViewport resize, recompute any cached inset or FAB positions.
- If the app uses @capgo/capacitor-native-navigation: contentInsetMode css, listen for safeAreaChanged, pad with max(var(--cap-native-navigation-*, 0px), env(safe-area-inset-*, 0px)) per edge (not sum); verify system tab bar rail poses in the iOS 27 Duo simulator.
- If the app uses custom HTML headers or tab bars, add left/right safe padding on every screen and document that bars will not move into the rail.

Deliverables:
1. List files changed and the inset strategy (single source of truth).
2. Replace fixed top-only safe padding with four-edge padding where needed.
3. Add or update capacitor.config and index.html viewport meta if missing.
4. Note manual test poses: folded, open portrait, open landscape rail, Split View left/right.

Constraints:
- Only use APIs that exist in @capacitor/core, @capacitor/status-bar, and @capgo/capacitor-native-navigation definitions.
- No em dashes or en dashes in user-facing strings you add.

Instalar Skills de Capgo para que los agentes sepan cómo configurar Native Navigation: npx skills add Cap-go/capgo-skills --skill capacitor-plugins.

Lecturas recomendadas

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.