围绕 iPhone Duo 的折痕布局
保留区域、ArrangementView 和铰链角度 API。如何让控件避开折痕,以及什么时候才真正能用上第二块屏幕或第二个窗口。
折痕不是你把屏幕一分为二凭空造出来的第二栏。系统已经知道铰链在哪里、它此刻是否碍事,以及一个主视图和一个次视图应该如何围绕它摆放。你要做的是去问系统,并且只挪动那些否则会落在折痕上的控件。
Apple 的姿态讲座把这叫作“位移”。把元素挪动一小段距离,让它保持可见、可点。相关的控件一起挪。可滚动的内容留在原处:信息流、文章和列表在用户滚动时本来就会自己适应。发送按钮、进度条或浮动操作按钮则不会。
什么时候需要做这些,见设计指南。界面框架上的栏见竖向栏指南。本文讲的是铰链。
要点
- 系统把折痕报告为一块 40 点宽的保留区域,只在手机半折时生效。读取它,永远不要把它算成宽度的一半。
- 只挪动你自己摆放的控件,而且只挪一小段。系统容器本来就会避开折痕。
- 应该分处折痕两侧的两部分内容,放进一个
ArrangementView,导航放在它外面。 - 铰链角度用于随铰链而动的东西,而不是用来决定按钮放在哪里。
- 手机展开时,外屏是留给相机 App 的。
什么工具做什么事:
| 你想要 | 使用 |
|---|---|
| 让按钮、进度条或发送控件避开折痕 | 保留区域,见下文 |
| 让列表与详情、或主视图上的控制面板在折痕处分开 | ArrangementView |
| 让某样东西随着铰链的移动而弯曲、转动或演奏 | onHingeChange,见铰链指南 |
| 手机展开时在外屏上显示内容 | CameraCaptureAccessory,仅限相机 App,见相机指南 |
| 知道手机是合上、展开还是半折 | 先看尺寸类别,再看铰链的 status |
向系统要区域
安全区域描述的是边缘。它不描述铰链,也不描述每一个摄像头开孔。这两者都会以保留区域的形式出现在 GeometryProxy 上,在 UIKit 里则是 UIView 上。
GeometryReader { proxy in
let folds = proxy.reservedRegions(kind: .division)
let cameras = proxy.reservedRegions(kind: .occlusion, options: [.includeInactive])
// region.frame, region.margins, region.isActive, region.id
}
.division 是铰链:一条把屏幕分开的带状区域。.occlusion 是遮住玻璃的东西,比如摄像头。每个区域都有一个以该视图坐标系表示的 frame、一圈需要在 frame 周围保持空出的边距、一个 isActive 标志,以及一个在不同坐标空间中保持不变的 id。Bleeping Swift 的文章指出,你可以把在一个视图里看到的 id 与在另一个视图里读到的 id 对应起来;它还补充了一个参数:在从右到左书写的语言中,frame 默认是镜像的,所以当你需要玻璃的物理位置时,传入 layoutDirectionBehavior: .fixed。
分隔区域只有在手机半折时才是生效的布局输入。手机平放时,控件留在原处即可。Kashif Mehmood 对 Bunny Lau 那条 Tweed 帖子的纠正是简短版:姿态告诉你的是模式,而不是接缝在哪里。凡是你手动摆放的东西,都要读取这块区域。系统容器本来就会避开它。
Artem Novichkov 基于 iOS 27.1 模拟器做了一套示例 App,并记录下了观察到的现象。他标明这些是观察结果,不是文档保证的行为:
- 分隔区域只在设备半折时生效。平放时,它是未激活的。两种状态下它的 frame 相同:宽 40 点,折线两侧各有 20 点边距,折线本身没有宽度。
- 区域在第一次布局之后才会出现。在
GeometryReader的 body 里读取它们,这样视图会随之更新。不要缓存它们。Blake Crosley 的探测 App 每次启动时,body 的前六到九次计算都读不到任何东西,之后才每次都能读到所有区域。 - 除非传入
.includeInactive,大多数区域都是未激活的。在完全展开的手机上,折痕是一块未激活的分隔区域,摄像头是一块未激活的遮挡区域。 - 竖向栏中的状态栏区域是一块生效的遮挡区域。
- 在合上的外屏上,Artem 完全没有看到保留区域。Blake 的探测 App 在 10 月 2 日重新运行时,在那里看到了两块生效的遮挡区域:摄像头,以及栏上方的状态列。无论你用的是哪个版本,都要读取区域,而不是假定其中任何一种答案。
不要把任何东西与区域开始生效时的角度挂钩。Ihor Malovanyi 把模拟器的铰链滑块来回扫了四次,看到 isActive 分别在 98°、132.5°、132.5° 和 172.6° 时开启。把这个标志当作当下的事实,在它变化时重新布局。
用生效的区域来挪动控件。用未激活的区域做更粗略的决定,比如优先选择偶数个网格列,这样当手机真的弯折时,栏间距可以正好落在折痕上。Artem 的桌面示例把图片放在生效分隔区域的一侧、控件放在另一侧:笔记本式姿态下上下堆叠,书本姿态下左右并排。偶数列示例用的是未激活的区域,让栏间距提前就位。
不要把铰链算成“宽度的一半”。分屏浏览是另一个功能,它确实是一半对一半。硬件折痕在哪里,以区域报告的为准。
在 UIKit 中,状态变化时要让布局失效。当折痕变为生效或未激活时,UIHingeInteraction 的处理程序会触发,那正是重新布局的时机。
ArrangementView:当你有两部分内容时
ArrangementView 接收一个主视图和一个次视图,并根据现有空间摆放它们。它是一个布局容器,不是导航容器。把 NavigationStack 放在它外面。不要在它里面放 NavigationSplitView,也不要把 ArrangementView 放进 List 或 ScrollView。Khoa Pham 从姿态讲座中总结出了这条规则,它也与这个 API 的设计相符:ArrangementView 不提供导航,放在可滚动的东西里会表现异常。

NavigationStack {
ArrangementView {
TranscriptView()
.splitArrangementLayoutRatio(0.4)
} secondary: {
PlayerView()
}
.arrangementViewStyle(.split.axes([.horizontal, .vertical]))
}
.split 会划分空间:字幕文稿在播放器旁边,列表在详情旁边,或者像上图的 Zoom 那样,一边是共享屏幕,一边是通话中的人。你可以限制方向轴。如果两个视图沿允许的轴放不下,Artem 看到次视图会消失,只剩下主视图。比如在宽屏上只允许竖向分割时就会这样。在书本姿态下,即使会覆盖 splitArrangementLayoutRatio,模拟器也会把分割线放在折痕上,而 Blake 看到分割布局让那条 40 点宽的带状区域空着。做好折痕优先的准备。
.overlay 是另一种样式。次视图铺满容器,主视图浮在上面;手机半折时,两者左右并排。
ArrangementView {
ResultsPanel()
.overlayArrangementEdge(.leading)
} secondary: {
MapView()
}
.arrangementViewStyle(.overlay)
overlayArrangementEdge 是布局不再浮动时面板所占的那一侧。要知道当前是否处于浮动状态,从面板的子视图中读取 overlayArrangementZIndex。Artem 测得主内容的根视图读到的永远是 0。Joshua Cleetus 也踩过同样的坑:从自己根视图读取这个值的面板永远不会收起。大于 0 的值表示你正盖在另一个视图上,应该使用紧凑形式。
struct ResultsBody: View {
@Environment(\.overlayArrangementZIndex) private var zIndex
var body: some View {
if zIndex > 0 {
ResultsSummary()
} else {
ResultsList()
}
}
}
UIKit 的对应容器是 UIArrangementViewController,用 state(for:) 代替环境值。你不必把运行良好的 UISplitViewController 换成 UIArrangementViewController。当你已经有两块应该移到折痕两侧的区域,或者主视图上有一个控制面板时,再用它。
角度是用来玩的,不是用来布局的
onHingeChange 报告一个状态(.closed、.partiallyOpen、.fullyOpen)和一个连续的角度。UIKit 的版本是 UIHingeInteraction。没有铰链时返回 nil,在任何不能折叠的手机上都是这样。
.onHingeChange { _, context in
if let hinge = context.hinge, hinge.status == .partiallyOpen {
bend = amount(for: hinge.angle)
} else {
bend = 0
}
}
这种写法适合弯音、翻页,或者靠折叠来玩的游戏,不适合“把按钮放到这里”。区域和 arrangement 本来就会跟随铰链,包括铰链正在移动的时候。Artem 的另一条记录是:不要依赖角度更新的频率,也不要依赖度数的精度,这个频率由系统决定。如果只需要姿态,读取 status 即可。传入 isEnabled: false 可以暂停推送,而不必移除修饰符。如何把角度变成一种控件,以及开发者为铰链游戏做的检测器,见铰链指南。
在 Anton 记录的小组实验室笔记中,Apple 的工程师把同样的意思表述为一种偏好:使用最贴合需求的最高层级 API,不要从原始角度入手。那次交流中没有任何 API 能检测哪一面朝下放在桌上。手机两种放法都可能,所以半折布局要设计得两种放法都说得通。
同时使用两块屏幕,以及第二个窗口
大多数 App 从不同时在两块屏幕上绘制内容。外屏和内屏是同一个 App 的两个时刻。例外是相机 App。有活跃的相机会话、并且只在内屏上全屏时,它可以在外屏上为被拍摄的人显示一个 CameraCaptureAccessory,比如 Apple 的提词器示例。代码和规则见相机指南。
为自己的 App 开第二个窗口是内屏专属的功能,外屏无法创建窗口。当新场景会创建失败时,UIWindowScene.ActivationAction 会把自己隐藏起来;在外屏上就是这种情况,而在 iPad 上则不会。两个实例共享 App 存储,包括 AppStorage,它们是同一个 App。
如果你的界面完全是自定义的,上面这些容器都不会替你做这些事,需要检查的布局数量会迅速增加。检查清单会带你逐一过一遍:先在模拟器上试遍每种姿态,再检查落在折痕上的控件。
速查表
| SwiftUI | UIKit | |
|---|---|---|
| 折痕和摄像头在哪里 | GeometryProxy 上的 proxy.reservedRegions(kind:options:) |
view.reservedRegions(kind:options:) |
| 围绕折痕的两部分内容 | 使用 .split 或 .overlay 的 ArrangementView |
UIArrangementViewController |
| 我是否浮在另一个视图上? | @Environment(\.overlayArrangementZIndex),从子视图读取 |
state(for:) |
| 折痕变为生效或未激活 | GeometryReader 的 body 会重新运行 |
UIHingeInteraction |
| 姿态和角度 | onHingeChange |
UIHingeInteraction |
| 展开时的外屏(相机 App) | sceneAccessory 配合 CameraCaptureAccessory |
registerSceneAccessory 配合 UISceneAccessory.cameraCapture() |
| 你的 App 的第二个窗口 | UIWindowScene.ActivationAction,在会失败的地方隐藏 |
同左 |
以上都需要 iOS 27.1 SDK。如果针对 27.0 构建,同样的代码拿不到区域,没有 arrangement 行为,栏也是横向的。
继续阅读

iPhone Duo App 创意:开发者正在用折叠屏做的 6 件事
300 多个 iPhone Duo 演示、概念和原型,归纳为反复出现的六种模式,从半折的笔记本电脑形态到把铰链当控制器。如果你正在考虑做点什么,可以从这里开始。

iPhone Duo 能运行 iPad App 吗?iPhone Duo 与 iPad mini
iPhone Duo 的内屏几乎有 iPad mini 那么大,但它运行的是 iOS,不是 iPadOS。这对仅限 iPad 的 App 意味着什么、为什么很多 Duo App 看起来会像它们的 iPad 版,以及 Duo 从 iPad 借来了什么。

iPhone Duo 游戏:即将到来的作品,以及折叠屏给游戏带来了什么
哪些游戏已在 iPhone Duo 上亮相、未更新的游戏在展开的屏幕上是什么样子,以及开发者正在用铰链、半折和两块屏幕做出哪些新玩法。