跳转至正文
开发 移动端 iOS

在 Capacitor 应用中支持 iPhone Duo

为 iPhone Duo 准备 Capacitor WebView 应用:viewport-fit=cover、env(safe-area-inset-*)、System Bars 和 Status Bar 插件、折叠与尺寸调整测试,以及侧边栏上的 @capgo/capacitor-native-navigation。

iPhone Duo 横屏打开时原生栏位于侧边栏,竖屏打开时标签栏位于底部

Apple 的折叠屏 iPhone Duo 改变了系统界面元素和安全区域的工作方式。 Expo 发布了一份实用指南 面向 SDK 58 上的 React Native 应用。本文是 Capacitor 的对应版本:一个 WebView、CSS env insets、Capacitor 核心插件,以及可选的来自……的原生栏 @capgo/capacitor-native-navigation.

iPhone Duo 有哪些变化

在许多形态下,iOS 会将状态栏、导航栏和标签栏移至 垂直侧边栏 前缘或后缘。在竖屏模式下的展开内屏以及经典手机布局中,水平栏仍然会出现。

上图为展开的内屏。横屏时,工具栏和标签栏会堆叠在侧边栏中。竖屏时,工具栏回到顶部,标签栏回到底部。

对于 Capacitor WebView 中的 Web 内容,关键变化是:

  • env(safe-area-inset-top) 可以 0 当状态栏位于侧边栏而非刘海下方时。
  • 前缘和后缘的 insets 会增大 (Apple 文档提到,横屏和折叠形态下侧边栏约为 84pt)。左侧和右侧并不互为镜像。
  • 窗口尺寸变化 当用户在内屏上折叠、展开、旋转或使用 Split View 时,缓存的布局值会很快失效。
  • 原生标签栏会跟随侧边栏(rail) 当你使用 UITabBar (或使用 Capgo Native Navigation 搭配系统标签栏时)。自定义固定 HTML 标签栏则不会。

将折叠后的外屏视为 紧凑宽度(compact width) 并将展开后的内屏视为 常规宽度(regular width)。加宽布局,而不是切换到不同的 App Shell。

邮件应用在 iPhone Duo 上的三种形态。折叠时,工具栏和标签栏在右侧堆叠成垂直侧边栏。在 Split View 中,每个窗口的外侧边缘都保留一条侧边栏。展开竖屏时,工具栏沿顶部排列,标签栏则以胶囊形位于底部。

系统栏的位置:设备折叠时在右侧形成侧边栏;Split View 时位于每个窗口的外侧边缘;展开竖屏时则恢复为水平栏。

要求

  • Capacitor 8 开启 iOS 27 (使用 Xcode 27.1 和 iPhone Duo 模拟器运行时)。
  • 设备中心 (Xcode > Open Developer Tool > Device Hub) 来折叠、调整铰链和旋转 Duo 模拟器。
  • 一套 viewport 并启用 safe areas(见下文)。

viewport-fit=cover 和 CSS safe areas

Safe area 环境变量仅在 viewport 覆盖整个屏幕时生效:

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

平板 交互式 按边设置内容。想要沉浸感时,让背景铺满边缘。

选择 一个 整个应用的 inset 所有者。以下两个代码片段是 替代方案,而非组合使用。

方案 A:逐页 Shell

.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));
}

使用方案 A 时,请跳过方案 B。

在滚动视图中,请为 内容设置内边距,而不仅仅是外层滚动容器,这样背景可以全宽滚动,同时文本和按钮能避开侧边栏。

使用 四个独立值。不要假设 padding-left 等于 padding-right。在 RTL 布局中,侧边栏仍位于摄像头一侧;请读取这两个 left 以及 right env 值。

有两个地方容易遗漏:

  • 即使页面已有内边距,水平滚动容器仍需设置自身的末端内边距。否则,最后一张卡片会滑到侧边栏下面。
  • 设置为 right: 16px 的浮动按钮会忽略侧边栏。请添加实时的左侧或右侧内边距,并在窗口大小改变时重新计算。

对于 Tailwind 应用, @capgo/tailwind-capacitor 提供 pt-safe, px-safe,以及相关工具类,而不是到处重复 env()。

Capacitor 系统栏(Android 安全区域与 iOS 配置)

Capacitor 8 发布 系统栏 中的 @capacitor/core。它取代了原先仅限 Android 的 adjustMarginsForEdgeToEdge 标志。在以下位置配置: capacitor.config.ts:

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

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

export default config;
  • insetsHandling: 'css' (Android 默认开启):当 WebView 的 env 值不正确时(旧版 Android WebView 构建),注入 --safe-area-inset-* CSS 变量。
  • 选项 B(全站 shell)使用 回退模式 与 System Bars 文档。使用选项 B 时,请跳过选项 A。
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));
}

在运行时,你可以通过以下方式设置或隐藏系统栏: SystemBars.setStyle, SystemBars.show,以及 SystemBars.hide 来自 @capacitor/core。没有单独的 @capacitor/safe-area 包:Capacitor 应用的安全区域使用 CSS env, Android 上的 System Bars 变量以及你自己的监听器 resize / visualViewport.

@capacitor/status-bar

本指南中介绍的 Status Bar 插件 在 iOS 上仍控制 overlay 和样式:

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

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

setOverlaysWebView({ overlay: true }) 允许 HTML 在状态栏区域下方绘制。请配合 env padding 使用,否则内容会被裁剪。在 iPhone Duo 上,overlay 模式仍然适用; 几何结构 哪个 inset 是顶部、哪个是侧边,取决于系统 safe area,而非仅靠 Status Bar API。

不要叠加 WebView 自动边距, 整页 env padding,以及 原生栏 inset 变量 如果不采用显式策略,这种模式会让每台 iPhone 都出现双倍间距;当 rail 把 inset 移到两侧时,情况还会更糟。

iOS WebView contentInset

在 capacitor.config.ts, ios.contentInset 映射到 WebView 的 scrollView 的 contentInsetAdjustmentBehavior。许多应用会使用 'never' 来处理 CSS 中的 inset。如果你依赖 UIKit 的自动调整 以及 CSS env padding,请选定单一事实来源。

查看 Capacitor iOS 配置.

WebView 在折叠和展开时的尺寸调整

Capacitor 只保留一个全屏 WebView。当 Duo 改变姿态时:

  1. UIKit 会更新视图控制器上的安全区 inset。
  2. WebView 会以新的宽度和高度执行布局。
  3. CSS env(safe-area-inset-*) 以及 100dvh / visualViewport 值会在下一次样式刷新时更新。

在 JavaScript 中监听几何尺寸变化 当你缓存依赖 inset 的位置时(悬浮按钮、toast、canvas 叠加层):

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();

滚动监听器能覆盖 visual viewport 的平移变化, offsetLeft / offsetTop 这类变化不会改变宽高(例如 iOS 上由键盘引起的视口偏移)。

在 Android 上,请包含 density 中的 android:configChanges 在主 Activity 中设置,以防止折叠屏和屏幕尺寸变化导致 WebView 被销毁。Capacitor 的 8 个模板均对此进行了说明 Capacitor 8 升级指南.

屏幕方向与窗口大小类

使用 实时 宽度来分支布局,而不是 screen.width 仅在启动时捕获一次:

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';

在 CSS 中,优先使用 flex 和 grid 以及 minmax() 而非固定 100vw 宽度。 100vw 通常会忽略滚动条和侧边栏的不对称性,导致水平溢出。

对于内屏的分屏模式,每个窗口可能很窄(Apple 指出宽度约为 469pt)。请测试应用停靠左侧和右侧的情况:侧边栏会附着在 窗口 边缘,因此不同窗口之间的水平内边距可能会翻转方向。

自定义 HTML 界面外壳与原生栏对比

Expo 的指南建议使用原生 Stack 以及 NativeTabs 让 iOS 控制侧边栏位置。同样的理念也适用于 Capacitor:

方案 iPhone Duo 行为
固定 HTML 头部 + 底部标签栏 保持在经典的上/下安全矩形区域内,不会延伸到侧边栏。
@capgo/capacitor-native-navigation 跟随系统 UITabBar / UINavigationBar 状态栏跟随系统布局,包括垂直侧边栏。

如果你必须保留 HTML 外壳,请检查每个屏幕的 左右 env padding,而不只是顶部和底部。

同一屏幕带有固定 HTML header 和底部 tabs。折叠时,header 和 tabs 仍留在经典矩形区域内,只避开侧边栏;竖屏展开时,它们仍在顶部和底部。

固定的 HTML 外壳不跟随侧边栏,而是留在顶部和底部的安全矩形区域内。由 UIKit 或 @capgo/capacitor-native-navigation 绘制的原生栏则随系统移动。

iPhone Duo 上的 @capgo/capacitor-native-navigation

Native Navigation 绘制真实的 UINavigationBar 以及 UITabBar (或 Android 上的 Material 栏),覆盖在单个 Capacitor WebView 之上。在运行 iOS 27 的 iPhone Duo 上, 系统 tab bars 会移入侧边栏 ,在折叠和多种横屏姿态下。该插件的 曲线 标签栏形状保持在底部边缘。窗口变宽时,中心操作仍保留圆形底座。

来自 @capgo/capacitor-native-navigation. 的两种展开的横屏布局。左侧,系统标签栏位于侧边栏;右侧,曲线标签栏保持在底部,中心操作位于圆形底座中。

左侧,系统标签栏位于侧边栏。右侧,曲线标签栏保持在底部,中心操作保留其底座。

一次性配置

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

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

contentInsetMode: 'css' 将 bar 和 safe 的重叠尺寸写入文档根元素:

  • --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 (当标称标签栏高度 tabbarHeight 为 0 适用于托管的系统栏)

当 Native Navigation 接管栏时,请用下方的插件变量进行填充。当栏处于激活状态时,这些变量已包含原生栏重叠和 UIKit 安全区边缘。使用 max() 配合 env 回退,确保变量缺失时边缘仍正确 0 (例如托管的系统标签栏)而无需 添加 插件内边距和 env 内边距一起使用。

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));
}

请勿在同一个 main 元素。

在 iOS 26+ 上,使用 系统托管的 标签栏时, tabbarHeight 插件内边距可以为 0 以免 Web 内容被重复上移。当 --cap-native-system-tabbar-height 需要在轨道上方为悬浮操作留出间距时,请使用。

safeAreaChanged

在折叠状态、轨道侧边或栏可见性变化时订阅:

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

插件还会派发 capNativeNavigation:safeAreaChanged 开启 window 使用相同的 insets payload。

Native code 会应用一个小的 顶部间距,当 safeInsets.top 为零时 (侧边栏中的状态栏),这样导航栏按钮就不会碰到屏幕圆角。你的 web padding 仍应使用 env left/right 来适配侧边栏。

setNavbar, setTabbar,以及 configure 全部返回 { insets } 当你需要在更新 chrome 后做一次性读取时。

完整 API 参考: Native Navigation 文档.

测试清单

在 Device Hub 的 iPhone Duo 模拟器中:

  1. 折叠:控件不在侧边栏下方;点击能正常响应;水平 env insets 在预期位置不为零。
  2. 展开竖屏:顶部/底部栏回到 iOS 放置的位置;布局不会跳到另一套信息架构。
  3. 展开横屏 / 侧边栏:内容避开侧边栏;悬浮操作使用 insets.right 或 insets.left,而非硬编码的 16px.
  4. 部分折叠:铰链区域没有关键 UI;模态框和 sheet 仍可正常使用。
  5. 分屏模式:在左右窗口中运行应用;侧边栏边缘会跟随每个窗口。
  6. 屏幕打开时折叠:路由状态、滚动位置和表单输入都会在切换中保留。
  7. 经典 iPhone 模拟器:无回归问题。

Agent prompt(复制粘贴)

将此代码块与 Claude Code、Cursor、Codex 或其他正在处理 Capacitor 仓库的编程 Agent 一起使用:

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.

安装 Capgo 技能 以便 Agent 了解如何配置 Native Navigation: npx skills add Cap-go/capgo-skills --skill capacitor-plugins.

延伸阅读

为 Capacitor 应用提供 Live Updates

当 Web 层出现线上 Bug 时,通过 Capgo 发布修复,而不用等 App Store 审核好几天。用户会在后台收到更新,原生变更则仍走正常审核流程。

博客最新动态

Capgo 为你提供打造真正专业的移动应用所需的最有价值的洞察。