Expo on iPhone Duo
来源:https://expo.dev/blog/supporting-iphone-duo (Expo SDK 58)。设计规则位于 apple-design-duo 中;此技能是 Expo 和 React Native 的实现层。CI 管道背景:https://docs.expo.dev/tutorial/cicd/introduction/ 以及 expo:eas-workflows 技能。
要求
- Expo SDK 58(目标 iOS 27)和 Xcode 27.1(iOS 27.1 运行时及 iPhone Duo 模拟器)。
- EAS Build 镜像:
sdk-58或latest。 - Xcode 27 用 Device Hub 取代了 Simulator.app(Xcode > Open Developer Tool > Device Hub)。它拥有一个可折叠、旋转并设置铰链角度的 3D Duo。
思维模型
折叠态 = 紧凑宽度,展开态 = 常规宽度。让布局变宽,而不要切换设计方案。两侧的功能和状态保持一致。在书本状姿态下,iOS 会将状态栏、标签栏和工具栏移至一侧边缘的垂直导轨中。
四条规定
- 让 iOS 绘制导航。 使用 Expo Router 的
Stack和NativeTabs。自定义 JS 头部和<TabList>无法感知导轨(SDK 58 仅添加了最小兼容性),且<TabList>无法放入导轨。 - 每个屏幕上都使用
SafeAreaView(来自react-native-safe-area-context),包括水平内边距。 - 测试每一种姿态。 折叠、展开、旋转、Split View。
- 仅在需要时进行调整:使用
ArrangementView、preferredPlacement、预留区域、铰链 API 或禁用导轨。
安全区域(大多数 bug 的地方)
- 导轨需要在左侧或右侧有 84pt 的内边距。
- 将滚动内容包裹在
SafeAreaView edges={['left','right']}中,而不是ScrollView中,这样背景可以边缘到边缘。在 ScrollView 上设置contentInsetAdjustmentBehavior="automatic"(RN patch #59194 应使此操作足够)。 - 水平轮播:自行添加末端内边距。
- 浮动按钮和 toast:
right: insets.right + 16。在 RTL 下,同时读取left和right。 - 在
NativeTabs内部,只有当标签栏沿着底部运行时,insets.bottom才包含标签栏。永远不要添加固定的标签栏高度。 - 使用内边距填充内容容器,而不是根视图。
- 在渲染时读取
useWindowDimensions。左侧 Split View 窗口宽度为 469pt。
Expo Router
- 导轨中的头部按钮按一列堆叠,Back 或 Close 按钮在前。纯文本项保持水平。自定义视图被排除在外。
- 灵动岛中的 Live Activity 可以将工具栏缩小至溢出。不要与之对抗。
<SplitView>包装了系统分屏视图:折叠时一列,展开时两列,可感知折叠。SDK 58 中为实验性功能。NativeTabs的preferredPlacement:'sidebar' | 'tabBar' | 'automatic'。应用于横屏状态下打开的显示屏。
额外 API
| 需求 | API |
|---|---|
| 双面板内容(仅 iOS 27.1,旧版本将子项渲染为无容器) | 来自 @expo/ui/swift-ui 的 ArrangementView,位于 Host 内部,搭配 ArrangementView.Primary 和 .Secondary。使用 arrangementViewStyle('split' | 'overlay')、splitArrangementLayoutSize、overlayArrangementEdge 进行样式设置。来自 @expo/ui/swift-ui/modifiers 的修饰符。 |
| 折叠和遮挡帧 | npx expo install react-native-reserved-regions,然后 useReservedRegions() 返回 { kind: 'division' | 'occlusion', frame }(以点为单位)。适用于自定义游戏控件和等列网格。不要在折叠处拆分内容,请使用 ArrangementView。 |
| 铰链角度和状态 | npx expo install expo-hinge。useHinge()、getHinge()、addHingeListener()。状态:closed、partiallyOpen、fullyOpen、unknown。SwiftUI 修饰符 onHingeChange 可以通过 useWorkletCallback 在 UI 线程上运行。适用于 Reanimated 和 Android 11+ 折叠屏设备。 |
| 导轨边缘 | useVerticalBarEdgeIOS() 返回 'leading'、'trailing' 或 null。不是 Duo 检测器(竖屏展开状态返回 null)。 |
| 禁用导轨 | <StatusBar verticalBarBehaviorIOS="disabled" />(映射到 preferredVerticalBarBehavior)。仅用于视频播放器或计算器类屏幕,并保持每个屏幕的选择稳定。 |
方向
在 iOS 27 上,方向锁定是可变大小上下文中的偏好设置。lockAsync 可能被拒绝,并且 requireFullScreen 不再选择退出。在 Duo 上锁定为竖屏或横屏仅当应用通过姿态变化填满屏幕时才有效。
已知限制
- StandBy 在 Duo 模拟器中不可用,并且大多数应用扩展在 Xcode 27.1 的模拟器中也不可用。请在设备上测试 widget 和扩展。
expo-app-intents(alpha 版本,也属于 SDK 58)向 Siri、Shortcuts 和 Spotlight 暴露操作。某些 schema 需要的 iOS 版本高于其域(.mail.openDraft需要 iOS 27)。与布局无关。
测试清单
- 折叠态: 导轨下没有重要内容,按钮可响应。
- 展开态: 布局不会切换设计方案,导轨下没有内容,竖屏状态栏返回到顶部和底部。
- 部分展开态: 折叠处没有关键内容,sheet 和 popover 位于一侧。
- Split View: 内容远离导轨(左侧窗口的左边缘)。
- 屏幕中间折叠: 滚动位置、状态和操作保持不变。
- 标准 iPhone: 没有任何变化。
审计工作流
- 确认 SDK 58 并重新构建。
- 搜索自定义头部或标签栏(
headerShown: false加上手工构建的条、TabList),并迁移到Stack和NativeTabs。 - 搜索硬编码的
top、bottom、left、right偏移量、固定的标签栏高度以及根级内边距。替换为内边距。 - 根据安全区域列表检查轮播、FAB 和 toast。
- 选择双面板屏幕:
SplitView或ArrangementView。 - 如果自定义布局真正需要,再决定每个屏幕是否使用预留区域或铰链。
- 在 Device Hub 中验证,然后在 CI 中验证(如下)。
CI:在 Duo 模拟器上运行
使用 expo:eas-workflows 技能(先获取实时 schema,不要猜测 job 参数)。从 CI/CD 教程中得出一个合理的形态:基于 sdk-58 或 latest 镜像的 PR 触发的模拟器构建,可选的 Maestro E2E,然后基于指纹在原生构建和生产环境的 EAS Update 之间做出选择。使用 npx -y eas-cli@latest workflow:validate .eas/workflows/<file>.yml --non-interactive 进行验证。Workflow 消耗付费的 EAS 分钟数。
常见错误
| 错误 | 修复 |
|---|---|
| 使用内边距填充 ScrollView | 在其外部使用 SafeAreaView,设置 contentInsetAdjustmentBehavior="automatic" |
| 在内边距中使用固定标签栏高度 | 使用来自安全区域的 insets.bottom |
使用 useVerticalBarEdgeIOS() 检测 Duo | 它仅为导轨边缘,在竖屏展开时为 null |
| 使用预留区域在折叠处拆分内容 | 使用 ArrangementView 或 SplitView |
| 手工构建的 JS 头部或标签栏 | 使用 Stack 和 NativeTabs |
浮动按钮设置为 right: 16 | 设置为 right: insets.right + 16 |
假设 lockAsync 总是成功 | 视为偏好设置并填满屏幕 |
| 仅在标准 iPhone 上测试 | 使用 Device Hub,测试每一种姿态 |