为 iPhone Duo 做界面:从布局到代码
十二章,同一场景的 SwiftUI 和 UIKit。每章只建立一个能验证的判断,最后对照这个 App 为什么把标签留在底部。
先让界面在任何宽度都好看,再用折叠硬件加分。iPhone Duo 有外屏、内屏和铰链。它不是新的 idiom,也不该长出第二套 App。下面按学习曲线写:每一章只交付一个能验证的判断。先看会坏的写法,再讲为什么要这样设计,然后给同一场景的 SwiftUI 与 UIKit。
签名对照 Xcode 27.1 的 iOS 27.1 SDK。行为来自 Designing for iPhone Duo、Preparing your app for iPhone Duo 和 Three steps。正文是转述,不是原文照抄。
读法:每一章先有「学完你能」,再有会坏的例子、正确写法和「何时用 / 何时不用」。检查点先自己答,答案在文末。第 0–6 章不查询折痕、不使用 Arrangement、不读铰链角。那些是后面的事。
第 0 章 · 六种姿态是验收,不是六套界面
学完你能。 看见一台 Duo 合上、打开、半折和旋转时,说出界面该按哪一个宽度思考,而不是按姿态各写一版。
会坏的例子
不要这样组织产品:合上竖屏、合上横屏、打开竖屏、打开横屏、半折像书、半折立在桌上,各做一棵视图树。开合一次就换一套导航、换一套按钮、丢掉滚动位置。
SwiftUI 按姿态分叉:
enum Pose { case closed, open, half }
struct Root: View {
let pose: Pose
var body: some View {
switch pose {
case .closed: PhoneHome()
case .open: TabletHome()
case .half: BookHome()
}
}
}
UIKit 按姿态分叉:
func installRoot(for pose: String) {
let root: UIViewController
switch pose {
case "closed": root = PhoneHomeController()
case "open": root = TabletHomeController()
default: root = BookHomeController()
}
// 三套控制器,三份选中项
}
苹果为什么只给两个宽度
Duo 有外屏和内屏,铰链可以合上、完全打开,也可以停在中间。人会竖着拿、横着拿、像书一样打开,或立在桌上。这些都是同一次使用里的连续变化。HIG 的要求是:外屏用 compact 宽度,内屏用 regular 宽度,让已有布局跟着可用空间长大。不要为每种拿法重做 App。你仍然在设计 iPhone,iOS 的布局、栏和安全区规则继续有效。
| 你看到的 | 它是什么 | 设计时怎么想 |
|---|---|---|
| 合上 | 外屏。比常见 iPhone 更宽、更矮 | compact 宽。竖屏时高度仍是 regular |
| 完全打开 | 大内屏,regular × regular | 像 iPad 布局的自然延伸,但仍是 iPhone App |
| 半折 | 内屏被折痕分成两侧 | 还是同一套层级。这一章先不处理折痕 |
| 旋转、分屏 | 同一台设备上的不同容器 | 宽度会经过中间值,不是只有「外屏 / 全内屏」两档 |
栏的方向先记住一句:内屏竖屏是唯一保留上下水平栏的姿态。 外屏、以及内屏横屏,系统栏会挪到侧边。分屏时每一侧的栏贴着自己的外侧。这些细节到第 5 章再做成代码。
正确的组织方式
一套界面。状态放在布局外面。宽度变了,只换排列,不换数据。
struct Root: View {
@State private var selection: Item.ID?
var body: some View {
Home(selection: $selection)
}
}
final class RootController: UIViewController {
let model = HomeModel()
// 开合时只让同一个控制器重新布局
}
这一章要记住的词
还没有 Duo API。先把词用对,后面才不会接错。
外屏 / 内屏。 合上看外屏,打开看内屏。主场景会自己换屏。何时用:描述硬件,以及验收时说清你测的是哪一面。何时不用:不要把两面当成两块你自己管理的画布。
姿态。 合上、打开、半折,再乘上方向。何时用:Device Hub 里逐个点开,看同一套界面是否还在。何时不用:不要做
switch pose来选择布局。compact / regular。 外屏按 compact 宽来设计,内屏按 regular 宽来设计。何时用:决定「一次只看一层」还是「两层同时看见」。何时不用:不要把 regular 理解成「这是 iPad」。读取方式在第 3 章。
水平栏 / 垂直栏。 内屏竖屏保持上下栏;其余常见姿态把导航、工具栏和标签栏收到侧边。何时用:画栏的时候就按两种方向都画一遍。何时不用:不要自己画一条假侧栏去模仿它。
检查点
内屏竖屏时,系统栏在上下,还是在侧面?
六种姿态应该做几套界面?
打开 Duo 之后,它变成了 iPad 应用吗?
这一章还不能做。 不要写 if isDuo,不要读铰链角度,不要查询折痕。你现在只需要拒绝「每种姿态一版 UI」。
来源:HIG · Designing for iPhone Duo,Tech Talk 111466。
第 1 章 · 宽了就露出下一层,不要把句子拉满
学完你能。 拿到一块变宽的区域时,决定该加一栏、加一列,还是只是把原来的窄界面撑开。也能说出什么可以跨过折痕、什么不行。
会坏的例子
设置页在外屏上是舒服的一列。打开内屏后,同一列表的每一行拉到接近整屏。标题和开关分居两端,中间是大片空白。另一种坏法:把主按钮、二维码或人脸正好放在屏幕正中,半折时被折痕切开。
四条可以拿去评审的原则
功能和状态不断。 合上时能做的事,打开后还在。滚动、选中、正在编辑的内容不要因为开合而重置。变的是一次能看见几层。
多出来的宽度用来露出层级。 邮件在外屏是「列表,点进去才是信」。内屏可以列表和信并排。这是多看见一层,不是把信的行宽变成 900pt。
折痕像书脊。 背景、渐变、会滚动的文章可以跨过去。按钮、句子、人脸、二维码、拖动手柄不要骑在正中。这一章只建立这条判断;怎么查询折痕是第 7 章。
控件靠近它作用的内容。 管列表的按钮留在列表上方,不要因为侧边有一条垂直栏,就把它们全部赶到远处。
游戏要铺满当前姿态。优先改画面比例,而不是上下或左右留出黑边。文字和按钮的物理尺寸尽量稳住。
同一块设置页,两种宽度
长文和表单给一个可读上限,让多出来的空间留给旁边的层级,而不是留给更长的句子。
Form {
Toggle("提醒", isOn: $reminders)
Text("说明保持在容易扫读的宽度里。")
}
.frame(maxWidth: 700)
.frame(maxWidth: .infinity)
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let readable = view.readableContentGuide.layoutFrame
formWidth.constant = min(readable.width, 700)
}
若这块区域已经宽到能同时放下「列表」和「详情」,下一章会用系统分栏去做。现在先不要手写一个 HStack 侧栏,也不要因为宽了就把字号放大一圈。
拿去评审的问题,还不是 API
加宽之前先问。 持久导航、补充详情、检查器、预览、更多控件、更高的信息密度。何时用:从 compact 走到很宽时,按这个顺序找能同时露出的结构。何时不用:不要先加大间距和字号来填满空白。
书脊测试。 想象正中有一条不能点准的缝。可以跨:摄影背景、纹理、持续滚动的列表和文章。不要跨:按钮、必须读完的短句、人脸、二维码、输入框、小图标。
就近分组。 常用操作留在它影响的那一块旁边。何时用:列表的筛选留在列表上;详情的分享留在详情上。何时不用:不要为了对称,把主操作丢到离内容很远的角。
游戏铺满。 姿态变了就改画面比例。何时用:可以锁竖屏或横屏,但当前姿态要铺满。何时不用:不要用黑边凑合。若实在要留边,用画面而不是纯色填上。
检查点
内屏上把设置表单拉到 900pt 宽,问题出在哪?
半折时,渐变背景和「付款」按钮,哪一个可以留在正中?
邮件列表的「全选」应该跟列表走,还是跟最右侧那条系统栏走?
这一章还不能做。 不要为半折重做整页,也不要现在就写查询折痕的代码。你只需要在稿子上标出「不能骑在正中」的元素。
第 2 章 · 用哪套 SDK 编译,决定它铺多满
学完你能。 区分部署目标和编译 SDK,并判断一个已经能在 Duo 上打开的包,为什么仍会留黑边、栏还在顶部。
会坏的例子
用 iOS 26 SDK 编出来的包可以安装。打开内屏后,窗口居中,四周空着。合上时,内容停在状态栏和摄像头的左侧。有人于是再写一套「如果是 Duo 就把 frame 拉满」的补丁。
三档 SDK,没有单独的 Duo 开关
铺满和垂直栏是编译 SDK 带进来的,不是 Info.plist 里的一个布尔值。部署目标可以继续停在更低的系统上,这样旧设备仍能安装。但若要在 Duo 上用满屏、并让系统栏进入垂直形态,这个包必须用 iOS 27.1 SDK 或更新的 SDK 编译。发出 27.1 包之前,先确认内容在栏变垂直之后仍然点得到。
| 编译所用 SDK | 打开时的内屏 | 栏 |
|---|---|---|
| iOS 26 或更早 | 居中,四周留空。外屏内容在状态栏和摄像头左侧 | 保持你原来的水平栏 |
| iOS 27 | 铺满大部分内屏,但仍避开右侧状态栏 | 工具栏仍在顶部 |
| iOS 27.1 或更新 | 用满显示区域 | 工具栏和标签栏在状态栏下方排成垂直栏 |
部署目标可以更低,调用必须加门
下面两段还不调用任何折叠 API。它们只建立后面每一章都要守的习惯:27.1 的符号包在可用性检查里;Mac Catalyst 上,Xcode 27.1 的已知问题是这些 27.1 符号可能编不过,需要再隔开。
var body: some View {
Home()
.modifier(DuoReadyChrome())
}
struct DuoReadyChrome: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 27.1, *) {
content // 第 5 章才把垂直栏调优放进来
} else {
content
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 27.1, *) {
// 同一个控制器。不要在这里换 root。
}
}
#if !targetEnvironment(macCatalyst)
// 27.1 专有调用放这里,避免 Catalyst 编不过
#endif
和编译有关的事实
部署目标
IPHONEOS_DEPLOYMENT_TARGET。何时用:决定最旧能安装的系统。何时不用:把它升到 27.1 并不是铺满屏幕的前提。铺满看的是编译 SDK。iOS 27.1 SDK。 满屏加垂直栏的开关。何时用:准备在 Duo 上交付完整形态时,用这套 SDK 编译。何时不用:不要在未适配垂直栏之前就把 27.1 包发出去。
#available(iOS 27.1, *)。 部署目标低于 27.1 时,新符号必须包起来。何时用:每一次调用第 5 章之后的 27.1 API。何时不用:不要用它包住普通的 size class。那些在更早系统上就存在。Mac Catalyst
#if !targetEnvironment(macCatalyst)。何时用:27.1 专有 API 让 Catalyst 目标编不过时,把这些调用隔开。何时不用:不要因此删掉 iPhone 上的适配。相机配件本身在 Catalyst 上不可用。
检查点
部署目标仍是 iOS 18,但用 27.1 SDK 编译。垂直栏会出现吗?
包已经能在 Duo 上启动,四周有空。下一步是检测机型拉满,还是换 SDK?
为什么 27.1 的新调用不能直接写在 iOS 18 的代码路径里?
这一章还不能做。 不要为了铺满去读屏幕尺寸。下一章才替换 UIScreen.main。垂直栏的条目规则也还没到。
来源:Three steps to make your app shine on iPhone Duo。Xcode 27.1 需要 macOS Tahoe 26.6 或更新;模拟器在 Device Hub 里开合、旋转、折叠。
第 3 章 · 问这块区域有多宽,不要问这是不是 Duo
学完你能。 把按设备、方向、UIScreen.main 做的布局分支,改成按 size class 和当前容器尺寸重算。
会坏的例子
同一台 Duo 可以在外屏、内屏全屏、内屏分屏里出现,安全区和栏还会变。启动时读一次屏幕,折一下就过期。内屏也不理会你在 Info 里声明的方向。
let tallPhone = UIScreen.main.bounds.height == 844
let scale = UIScreen.main.scale
if UIDevice.current.userInterfaceIdiom == .pad
|| horizontalSizeClass == .regular {
IPadOnlyLayout()
}
let window = UIWindow(frame: UIScreen.main.bounds)
if UIDevice.current.orientation == .landscape { wide() }
if view.window?.windowScene?.effectiveGeometry
.interfaceOrientation.isLandscape == true { wide() }
let width = view.bounds.width
- view.safeAreaInsets.left * 2
两个容易记反的事实
regular 宽度不再等于 iPad。 内屏是 regular 宽、regular 高。过去「regular 就走 iPad 专属页」的代码,现在会在 iPhone 上跑起来。苹果的要求是:内屏看起来像你 iPad 布局的自然延伸,所以把那套布局修好,而不是再加一个 idiom 判断把它关掉。
方向不等于形状。 要知道现在是横长还是竖长,比较你拥有的那块区域的宽和高。外屏竖屏是 compact 宽加 regular 高,外屏横屏两个方向都是 compact。内屏两个方向都是 regular。一台 iPhone 应用从此可以遇到任意 size class 组合。
尺寸会在会话中途变。不要在 viewIsAppearing 里记一笔就再用到底。
按容器重算
struct Stage: View {
@Environment(\.horizontalSizeClass) private var widthClass
@Environment(\.verticalSizeClass) private var heightClass
@Environment(\.displayScale) private var displayScale
@State private var size: CGSize = .zero
var body: some View {
content
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newSize in
size = newSize
}
}
private var isWiderThanTall: Bool { size.width > size.height }
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let bounds = view.bounds
let wider = bounds.width > bounds.height
let scale = traitCollection.displayScale
let usable = bounds.inset(by: view.safeAreaInsets)
layout(for: usable.size, wider: wider, scale: scale)
}
override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate { _ in self.view.setNeedsLayout() }
}
let window = UIWindow(windowScene: scene)
真的需要 UIScreen 时,用的那一刻再从 view.window?.windowScene?.screen 拿,不要存成属性。窗口从 scene 创建,不要用主屏 bounds 当 frame。
API
horizontalSizeClass。 SwiftUI:@Environment(\.horizontalSizeClass)。UIKit:traitCollection.horizontalSizeClass。何时用:区分 compact 与 regular 宽度,并配合垂直 size class。何时不用:不要把.regular翻译成「iPad 页面」。onGeometryChange。 SwiftUI 在尺寸变化时收到容器CGSize。UIKit 对应viewDidLayoutSubviews与viewWillTransition(to:with:)。何时用:比较宽高,或按当前容器做局部测量。何时不用:不要用它去读UIScreen.main.bounds。displayScale。 SwiftUI:@Environment(\.displayScale)。UIKit:traitCollection.displayScale。何时用:需要像素比例时。何时不用:不要再读UIScreen.main.scale。主屏引用会被弃用。UIWindow(windowScene:)。 用 scene 创建窗口。何时用:替换UIWindow(frame: UIScreen.main.bounds)。何时不用:布局计算仍然看 view 或 window 的 bounds,不看整块物理屏。
检查点
horizontalSizeClass == .regular现在还会在 iPhone 上成立吗?内屏横过来了。用
interfaceOrientation判断「现在比较宽」可靠吗?为什么
safeAreaInsets.left * 2会算错可用宽度?
这一章还不能做。 不要用宽度阈值 if width > 700 去切换两棵导航树。下一章用系统容器解决「窄则推进、宽则并排」。
来源:Three steps 的 Step 2,以及 Preparing 里的 resizing considerations。Tech Talk 111461。
第 4 章 · 让系统容器决定并排还是推进
学完你能。 用一个分栏容器保住选中项,用最小项宽决定网格列数,并用「哪一种排得下」而不是「我在哪台设备」做局部重排。
会坏的例子
compact 用一个 NavigationStack,regular 用一个手写 HStack 侧栏。选中项各存各的。网格写成「Duo 三列,否则一列」。按钮组用 if width > 700 在横向和纵向之间拆成两个完全不同的视图。
优先顺序
能用系统导航容器就用容器;重复卡片用自适应网格;局部「横的放不下就竖着」用能试探剩余空间的布局;size class 次之;精确几何留到最后。分栏的信息架构是「集合、选中、详情」。窄的时候列表推进详情,宽的时候并排。选中项要活过这次变化。多出来的宽度用来露出这一层,长文仍然限宽。
列表和详情
NavigationSplitView {
List(items, selection: $selection) { item in
Text(item.title)
}
} detail: {
if let selection {
Detail(id: selection)
.frame(maxWidth: 700)
} else {
Text("选择一项")
}
}
let split = UISplitViewController(style: .doubleColumn)
split.setViewController(list, for: .primary)
split.setViewController(detail, for: .secondary)
// 选中项放在 list 与 detail 共用的 model 上
// preferredPrimaryColumnWidth 用 min / ideal / max
// 不要写死侧栏点数
卡片网格:最小宽度,不是列数
private let columns = [
GridItem(.adaptive(minimum: 220))
]
LazyVGrid(columns: columns, spacing: 12) {
ForEach(cards) { Card($0) }
}
func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { _, env in
let width = env.container.effectiveContentSize.width
let count = max(1, Int(width / 220))
let item = NSCollectionLayoutItem(
layoutSize: .init(
widthDimension: .fractionalWidth(1 / CGFloat(count)),
heightDimension: .estimated(160)))
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(
widthDimension: .fractionalWidth(1),
heightDimension: .estimated(160)),
subitem: item, count: count)
return NSCollectionLayoutSection(group: group)
}
}
分屏把你切到中间宽度时,列数会自己落到 2,而不是从 3 突然跳回 1。半折时若希望列数是偶数,那是第 7 章的事,现在不要写。
同一组视图换排列,不要换身份
ViewThatFits(in: .horizontal) {
HStack { Preview(); Inspector() }
VStack { Preview(); Inspector() }
}
let layout = isWiderThanTall
? AnyLayout(HStackLayout())
: AnyLayout(VStackLayout())
layout {
Chart()
Legend()
}
UIKit 没有 ViewThatFits。对应的想法是:在布局时问「横向还放得下吗」,然后改同一个 UIStackView 的轴。不要销毁再创建子控制器,否则编辑中的文字会丢。
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let horizontal = stack.bounds.width
> preview.intrinsicContentSize.width
+ inspector.intrinsicContentSize.width
stack.axis = horizontal ? .horizontal : .vertical
}
API
NavigationSplitView。 UIKit:UISplitViewController。何时用:集合、选中、详情。窄则推进,宽则并排。何时不用:不要用手写HStack侧栏代替它。它不是第 8 章的双内容排列。GridItem(.adaptive(minimum:))。 UIKit:用容器宽度除以最小项宽,得到列数。何时用:卡片、相册、仪表盘这类重复内容。何时不用:不要写成isDuo ? 3 : 1。ViewThatFits。 按给定轴向试探哪一个子树放得下。何时用:按钮组、元数据、预览加检查器这种局部选择。何时不用:不要用它做整个 App 的导航切换。AnyLayout。 换布局容器,保持子视图身份。何时用:图表和图例、预览和属性,排列变了但还是那两个视图。何时不用:不要在两个if分支里各写一遍子视图。
检查点
从并排折回单列时,选中项应该存在哪里?
分屏后宽度只够两张 220pt 的卡片。列数该怎么来?
ViewThatFits回答的是「哪台设备」,还是「哪种排列放得下」?
这一章还不能做。 标签栏要不要变成侧边导航,留到下一章。两块内容要躲开折痕,留到第 8 章。现在不要引入 Arrangement。
第 5 章 · 系统栏会自己站到侧面,假栏不会
学完你能。 让导航、工具栏和标签栏在水平与垂直两种形态里都认得出,并把次要动作交给系统溢出菜单。
会坏的例子
页面顶部是一个自绘的 HStack,或单独 new 出来的 UINavigationBar、UIToolbar、UITabBar。系统不会把这些内容收进垂直栏。另一个常见问题:分享按钮只有标题 "Share"。垂直栏只显示图标,没有图标的项根本不会竖过来。自己再画一个「…」菜单,和系统溢出菜单抢位置。
栏什么时候垂直,谁有资格进去
用 27.1 SDK 编译之后,外屏会把导航栏、工具栏和标签栏合成侧面一条;内屏横屏、以及处在 leading / trailing 位置的一些视图也会这样。内屏竖屏仍用水平栏。分栏里只有详情列参与垂直栏,侧栏和内容列保持水平。检查器保持水平。
栏跟硬件边对齐,从右到左的语言也不会把它换到另一边。分屏时,左边的 App 栏在左,右边的 App 栏在右。
条目必须同时有标题和图标。垂直形态用图标;水平形态优先图标,也可以用标题;溢出菜单里图标和标题一起出现。自定义视图默认留在水平栏,除非你声明它适合垂直放置。纯文字按钮留在水平栏;系统的 Edit 按钮就是这样在符号和文字之间切换的。
垂直栏顶部留给返回或关闭,然后是 Done 这类醒目动作。其余项保持原来的分组。溢出默认从下往上收。导航型页面优先保住标签栏;做事的页面优先保住工具项。
同一组动作,两种栏都能用
SwiftUI 挂在 NavigationStack 里:
NavigationStack {
DetailView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") { dismiss() }
}
ToolbarItem(placement: .topBarPinnedTrailing) {
Button("Done", systemImage: "checkmark") { save() }
}
ToolbarItem(placement: .bottomBar) {
Button("Share", systemImage: "square.and.arrow.up") { share() }
}
.visibilityPriority(.high)
ToolbarOverflowMenu {
Button("Duplicate", systemImage: "plus.square.on.square") {
duplicate()
}
}
}
.toolbarVerticalCompressionBehavior(.automatic)
}
UIKit 挂在导航控制器里的子控制器上:
let close = UIBarButtonItem(
title: "Close", image: UIImage(systemName: "xmark"),
target: self, action: #selector(closeTapped))
let done = UIBarButtonItem(
title: "Done", image: UIImage(systemName: "checkmark"),
target: self, action: #selector(doneTapped))
let share = UIBarButtonItem(
title: "Share", image: UIImage(systemName: "square.and.arrow.up"),
target: self, action: #selector(shareTapped))
share.visibilityPriority = .high
share.axisBehavior = .automatic
navigationItem.leadingItemGroups = [
UIBarButtonItemGroup.fixedGroup(items: [close])
]
navigationItem.pinnedTrailingGroup = UIBarButtonItemGroup.fixedGroup(items: [done])
toolbarItems = [share]
navigationItem.verticalBarCompressionBehavior = .automatic
返回按钮由导航控制器自己放上。自定义关闭放进 leading 一侧,Done 放进 pinnedTrailingGroup。搜索占满空间时,钉住的项才最晚进入溢出。additionalOverflowItems 用来把你自己的「更多」收进系统菜单。省略号留给溢出,其他菜单换一个符号。
符号和文字来回变的项,设 .horizontalOnly:没有水平栏时它会隐藏,而不是挤进垂直栏。自定义视图若确实有垂直画法,用 .verticalPreferred。弹性空白在垂直轴上尺寸是 0,分组用 ToolbarItemGroup / UIBarButtonItemGroup,不要手插固定间距。角标用 badge,不要把数字写进标题,否则这项很难变成纯符号。
退出垂直栏,是稳定的例外
NavigationStack {
CalculatorView()
.toolbarVerticalBehavior(.disabled)
}
override var preferredVerticalBarBehavior: UIVerticalBarBehavior {
.disabled
}
默认是 .automatic。只在这些界面退出:全屏视频、像计算器这样不滚动的整宽布局、只有一个关闭按钮的 sheet。这是稳定选择。不要在 viewWillAppear 里按页面状态来回切。若只是某一页要藏栏,用可见性 API,而不是关掉垂直行为。UIKit 若偏好真的变了,调用 setNeedsUpdateOfVerticalBarConfiguration()。导航控制器默认把决定交给栈顶,标签栏交给当前选中页。
知道栏在哪一边,以及背景怎么伸过去
@Environment(\.toolbarVerticalEdge) private var verticalEdge
// HorizontalEdge?;这个上下文从不用垂直栏时为 nil
Image("hero")
.backgroundExtensionEffect()
let edge = traitCollection.verticalBarEdge
// .leading / .trailing / .unspecified
// 表示系统偏好的边,不表示栏此刻是否可见
// 从不用垂直栏的上下文返回 .unspecified
let backdrop = UIBackgroundExtensionView()
backdrop.contentView = hero
监听 UIKit 这条边:向 registerForTraitChanges 传入 UITraitCollection.systemTraitsAffectingVerticalBarEdge。trait 变了,边仍可能相同。
一级入口:内屏可以变成侧边导航
在 iPhone 上,只写 .sidebarAdaptable 仍是底栏。要在支持的地方改成侧边导航,再加 iOS 27 的 defaultTabBarPlacement(.sidebar)。不要用 iOS 18 的 defaultAdaptableTabBarPlacement,那是给 iPad 的。侧边栏相关 UI 看 isTabViewSidebarAvailable,不要再去猜 size class。
TabView {
Tab("Home", systemImage: "house") { HomeView() }
Tab("Library", systemImage: "books.vertical") { LibraryView() }
}
.tabViewStyle(.sidebarAdaptable)
.defaultTabBarPlacement(.sidebar)
tabBarController.sidebar.preferredPlacement = .sidebar
产品也可以明确保持底栏,把加宽的空间留给内容里的分栏。两种都成立。不要自己按宽度画一条假侧栏。
API
toolbarVerticalBehavior(iOS 27.1)。.automatic/.disabled。UIKit:覆写preferredVerticalBarBehavior,类型UIVerticalBarBehavior。何时用:计算器、全屏视频、单关闭按钮的 sheet。何时不用:不要随导航或某个视图的状态来回切换。axisBehavior(iOS 27.1)。 SwiftUI:.automatic/.horizontalOnly/.verticalPreferred。UIKit:UIBarButtonItem.axisBehavior,同样三个值。何时用:符号与文字切换的项用 horizontalOnly;确有垂直画法的自定义视图用 verticalPreferred。何时不用:有标题和图标的普通按钮保持 automatic。visibilityPriority(iOS 27.0)。 SwiftUI:.automatic/.low/.high,以及init(lowerThan:)/init(higherThan:)。UIKit:.standard/.low/.high。何时用:先给整组优先级,再给组内单项。常用和带角标的留到最后再溢出。何时不用:不要自己做第二个「…」菜单。ToolbarOverflowMenu与additionalOverflowItems才是系统菜单。UIKit 的additionalOverflowItems从 iOS 16 就能用。压缩行为(iOS 27.1)。 SwiftUI:
.automatic/.prefersToolbarItems/.prefersTabBar。UIKit:navigationItem.verticalBarCompressionBehavior的.automatic/.prefersBarItems/.prefersTabBar。何时用:导航型用默认(先收工具项,保住标签)。任务型改为先收标签栏。何时不用:不要两套栏各写一份来模拟压缩。topBarPinnedTrailing(iOS 27.0)。 UIKit 对应属性pinnedTrailingGroup从 iOS 16 就能用。关闭用.cancellationAction,UIKit 放进leadingItemGroups。何时用:Done 这类必须靠近返回的主动作。何时不用:次要动作不要钉在这里。它们应该可以溢出。toolbarVerticalEdge(iOS 27.1)。 SwiftUI:HorizontalEdge?,从不用垂直栏时为 nil。UIKit:traitCollection.verticalBarEdge,值为 leading、trailing 或 unspecified。它反映的是偏好边,即使栏此刻不可见。何时用:自定义内容要对齐这条栏的时候。何时不用:不要把 unspecified 理解成「栏刚才被藏起来了」。它表示这个上下文不会使用垂直栏。backgroundExtensionEffect()(iOS 26)。 UIKit:UIBackgroundExtensionView,把contentView放进安全区,边缘用延伸填满。何时用:主视觉或背景要伸到垂直栏下面。何时不用:可点的前景不要靠它钻出安全区。defaultTabBarPlacement(iOS 27.0)。.sidebarAdaptable再加.sidebar。UIKit:sidebar.preferredPlacement = .sidebar。环境值isTabViewSidebarAvailable、tabBarPlacement也是 iOS 27。何时用:一级分区在宽布局上适合常驻导航时。何时不用:不要用defaultAdaptableTabBarPlacement做 Duo。那个修饰符从 iOS 18 就有,作用在 iPad。
检查点
只有标题、没有图标的按钮,会进垂直栏吗?
分栏的三列里,哪一列的栏会垂直?
导航型页面空间不够时,默认先收工具项还是先收标签栏?
自绘的
UIToolbar为什么不会站到侧面?
这一章还不能做。 不要查询折痕来决定栏在左边还是右边。栏的边由系统放进 trait / environment。Sheet 放在哪一侧,是下一章。
来源:HIG · Vertical controls;Preparing · Optimize bars。Tech Talk 111462。
第 6 章 · 四条边分开算,Sheet 声明自己贴哪一侧
学完你能。 给 sheet 选择位置,按不对称的安全区计算可用区域,并用与屏幕同心的圆角和焦点保住全幅图片。
会坏的例子
用左边 inset 乘二当作总安全区。分屏或垂直栏会让左右不等,内容就偏了。另一类问题是自己做全屏遮罩当 sheet:系统 sheet、警告、菜单和 popover 会避开折痕,你的遮罩不会。主视觉用 .fill 裁切,外屏能看见的脸,内屏被裁掉。
Sheet 的栏跟着放置走
外屏上,带工具栏的 sheet 默认垂直栏。内屏上,sheet 默认居中且栏是水平的。leading 一侧没有垂直栏,trailing 一侧有。只有 sheet 理会放置;不要为此再做一套自定义转场。只有一个关闭按钮的 sheet,是上一章允许关掉垂直栏的情形。设置了 sourceView 时,UIKit 会忽略 preferredPlacement。
.sheet(item: $selection) { item in
ItemDetail(item: item)
.presentationPlacement(.trailing)
}
let sheet = detail.sheetPresentationController
sheet?.preferredPlacement = .trailing
// .automatic / .leading / .center / .trailing
// sourceView 非 nil 时,这个属性被忽略
安全区四边独立
content
.safeAreaPadding(.horizontal)
background
.ignoresSafeArea()
let usable = view.bounds.inset(by: view.safeAreaInsets)
// 分别使用 left、right、top、bottom
// 折痕不是 safe area inset
装饰可以伸出安全区。可点内容和必须读的文字留在里面。摄像头、垂直栏和分屏都会让左右或上下不等。贴着屏幕圆角的自定义形状,用同心矩形,不要写死半径。
RoundedRectangle(cornerRadius: 12) // 不要写死去贴屏幕角
ConcentricRectangle()
view.cornerConfiguration = .corners(radius: .containerConcentric())
全幅图要留住主体
Image("cover")
.resizable()
.aspectRatio(contentMode: isWide ? .fit : .fill)
.frame(maxWidth: .infinity, maxHeight: isWide ? 420 : 280)
.clipped()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let wide = view.bounds.width > view.bounds.height
hero.contentMode = wide ? .scaleAspectFit : .scaleAspectFill
hero.clipsToBounds = true
}
若主体不在几何中心,给裁切一个焦点,而不是只在 fill 和 fit 之间切换。游戏则铺满并改比例,见第 1 章。
API
presentationPlacement(iOS 27.0)。.automatic/.center/.leading/.trailing。UIKit:UISheetPresentationController.preferredPlacement。何时用:每个 sheet 都要在合上、打开、半折里看过,再决定贴边还是居中。何时不用:popover 和警告不要套这个修饰符。设置了sourceView时 UIKit 会忽略它。不对称 inset。
bounds.inset(by: safeAreaInsets)。何时用:任何手算可用宽度、高度的地方。何时不用:不要把一边的 inset 镜像到对边。折痕也不要塞进 safe area。ConcentricRectangle(iOS 26)。 UIKit:UICornerConfiguration的容器同心半径,UIView.cornerConfiguration存在。何时用:自定义面板要贴住当前屏幕或容器的圆角。何时不用:卡片自己的小圆角仍然可以用固定半径。不要拿它去避折痕。fill / fit。 按当前比例或 size class 选择,或设置焦点。何时用:主视觉、视频封面,从窄外屏到宽内屏都要留住主体。何时不用:不要只在启动时选一次 contentMode。
检查点
内屏上,trailing sheet 的工具栏是水平还是垂直?
为什么不能用
left * 2得到内容宽度?系统 sheet 会躲开折痕。自己画的全屏遮罩会吗?
这一章还不能做。 自定义画布、二维码和拖动控件要躲开折痕时,下一章才查询保留区。现在不要猜折痕的坐标。
第 7 章 · 折痕和摄像头是两块保留区,不是安全区
学完你能。 在自定义布局里找出激活的折痕和摄像头,只把被挡住的那一块挪开,而不是重做整页。
会坏的例子
一半折叠就换成另一棵界面。或者写死「折痕在宽度的 50%」。分屏、旋转之后这个比例不再是你的视图中心。另一端是把文章里的每一段都躲开折痕:滚动内容本来就能滚过那条缝。
两种区域,系统组件大多已经躲开
保留区是别的东西在你的坐标系里占住的矩形。frame 含 margins。每条有种类、是否激活、标识。查询返回的是与当前视图相交的区域。你的视图若只占内屏的一半,就不要假设能看到整块屏的折痕。
division,分割。 半折时,铰链把一大块分成两侧。完全打平后这条区域不激活,宽度为 0。
occlusion,遮挡。 外屏前置摄像头一直在,还会扩成灵动岛。内屏前置摄像头平时藏在屏下,只有使用中才占住一块并让界面让开。
警告、菜单、sheet、分栏会自己适应。自定义画布、二维码、人脸、必须连续的短句、拖动中的物件,才需要你读这些矩形。只挪被挡住的元素,并带着它的相关控件一起走,不要把它送到屏幕另一头。滚动的文章和列表不要整体挪开。
先滤出正在生效的,再摆自定义内容
SwiftUI 的 reservedRegions 还可加 layoutDirectionBehavior:,默认 .mirrors。只有你自己做了镜像,才传 .fixed,否则会镜像两次。摄像头的物理位置不随语言翻转,SwiftUI 默认先帮你镜像几何。onGeometryChange 的 proxy 同样能查。
GeometryReader { proxy in
let folds = proxy.reservedRegions(kind: .division)
.filter(\.isActive)
let cameras = proxy.reservedRegions(kind: .occlusion)
.filter(\.isActive)
CanvasPage(avoiding: folds + cameras)
}
override func layoutSubviews() {
super.layoutSubviews()
let folds = reservedRegions(kind: .division)
.filter(\.isActive)
let cameras = reservedRegions(kind: .occlusion)
.filter(\.isActive)
placeControls(avoiding: folds + cameras)
}
偶数列这种结构决定,要在设备平放、分割区尚未激活时也能看见它。这时才传入 .includeInactive,并且仍然用 isActive 决定要不要立刻挪控件。
let divisions = proxy.reservedRegions(
kind: .division,
options: .includeInactive)
let evenColumns = !divisions.isEmpty
let divisions = reservedRegions(
kind: .division,
options: .includeInactive)
let evenColumns = !divisions.isEmpty
API
reservedRegions(kind:options:)(iOS 27.1)。 SwiftUI 在GeometryProxy上。UIKit:view.reservedRegions(kind:options:),返回[UIView.ReservedRegion]。何时用:系统容器不会帮你挪的自定义内容。何时不用:普通列表、分栏、sheet、警告。不要把结果当成安全区 inset。ReservedRegion(iOS 27.1)。id、kind、frame、margins、isActive。种类是.division与.occlusion。何时用:用frame摆位置。frame已经包含边距。何时不用:未激活的 division 不要拿来推开按钮。平放时它不该改变点击位置。.includeInactive(iOS 27.1)。ReservedRegion.QueryOptions。何时用:「只要存在折痕,网格就用偶数列」这类与当前是否半折无关的决定。何时不用:不要因为查到未激活区域,就把按钮提前挪开。layoutDirectionBehavior。.mirrors或.fixed。何时用:保持默认,让自动布局的子视图和区域用同一套方向。何时不用:只有你手动镜像时才传.fixed。
检查点
完全打平后,division 还激活吗?宽度是多少?
一篇可滚动的文章,要不要把每一段从折痕上挪开?
内屏摄像头平时占着 occlusion 吗?
这一章还不能做。 播放器加播放列表这种成对内容,下一章交给 Arrangement。不要用铰链角度决定要不要躲开折痕。
来源:ReservedRegion;Preparing · Adapt to reserved regions。Tech Talk 111463。
第 8 章 · 两块内容组成一个体验时,让容器替它们换位
学完你能。 判断一对视图该用 split 还是 overlay,并把导航留在这个容器外面。
会坏的例子
用 Arrangement 替换 TabView 或 NavigationSplitView。或者把 Arrangement 放进 List、ScrollView、分栏的某一列,结果一半内容滚不出来、点不到。
已经长得像两种栈,就映射过去
Arrangement 只有主视图和次视图。它看空间、宽高比、size class 和分割区,不看你是不是 Duo。导航容器包在外面。苹果自己的例子就是导航栈里放一个 Arrangement。不要在 Arrangement 里面再套分栏。
split。 对应原来的
HStack/VStack,或谁也不该挡住谁的主次关系。比高更宽时左右分;比宽更高时上下叠。分割区出现时,它会调整两侧,躲开折痕。限制成一条轴之后,若这条轴用不上,就只留下主视图。.split.axes(.horizontal)在水平轴排不下时只显示主内容。overlay。 对应原来的
ZStack。没有激活的分割区时,主视图盖在次视图上(合上或完全打开)。半折时,主视图到折痕的后侧或下侧,次视图到前侧或上侧。
播放器与列表:split
NavigationStack {
ArrangementView {
Player()
} secondary: {
Queue()
}
.arrangementViewStyle(.split)
}
let arrangement = UIArrangementViewController()
arrangement.setViewController(player, for: .primary)
arrangement.setViewController(queue, for: .secondary)
arrangement.updateArrangement(.split)
navigationController.setViewControllers([arrangement], animated: false)
只允许左右、竖着时只留播放器:
.arrangementViewStyle(.split.axes(.horizontal))
arrangement.updateArrangement(.split.axes(.horizontal))
控件盖在画面上:overlay
ArrangementView {
TransportControls()
} secondary: {
Stage()
}
.arrangementViewStyle(.overlay)
arrangement.setViewController(controls, for: .primary)
arrangement.setViewController(stage, for: .secondary)
arrangement.updateArrangement(.overlay)
比例和层级还有这些调节,都是 iOS 27.1:splitArrangementLayoutRatio、splitArrangementLayoutSize、splitArrangementFixedLayoutSize、overlayArrangementEdge。环境值 splitArrangementAxis 与 overlayArrangementZIndex 用来让子视图知道自己现在处于哪种排列。UIKit 用 state(for:) 读 zIndex、splitAxis、isHidden。主次视图的尺寸范围在 UISplitArrangement.ViewProperties 里,遮挡边在 UIOverlayArrangement.ViewProperties.edge。
API
ArrangementView(iOS 27.1)。init收 primary 与 secondary。UIKit:UIArrangementViewController,setViewController(_:for:animated:)的位置是.primary/.secondary。何时用:播放器与队列、编辑器与检查器、画布与属性。两块是同一个体验。何时不用:App 的一级导航。不要放进分栏、列表或滚动视图。.split(iOS 27.1)。arrangementViewStyle(.split),.axes(.horizontal)或.vertical。UIKit:updateArrangement(.split.axes(...))。何时用:两侧都要专属空间,谁也不覆盖谁。何时不用:控件本来就该浮在内容上。那是 overlay。.overlay(iOS 27.1)。 无分割区时主视图在上。半折时主视图到后侧或下侧,次视图到前侧或上侧。何时用:前景 / 背景,允许平时互相遮挡。何时不用:两栏邮件。那是第 4 章的分栏,或这里的 split。updateArrangement(_:animated:)(iOS 27.1)。 换 split / overlay,或只改轴。何时用:产品明确要锁住一条轴,或在两种风格间切换。何时不用:不要在每次旋转时自己重算 frame 再调用它。默认风格已经会跟容器走。
检查点
原来是
ZStack,半折时要分到折痕两侧。用 split 还是 overlay?Arrangement 应该包住导航,还是被导航包住?
把它放进
ScrollView会怎样?
这一章还不能做。 不要读铰链角度来决定 split 还是 overlay。角度是第 10 章的交互输入。
来源:Preparing 里的 SwiftUI / UIKit 示例;HIG · Arrangement views。签名对照 iOS 27.1 SDK 的 ArrangementView 与 UIArrangementViewController。
第 9 章 · 开合是布局变化,不是把 App 重启一遍
学完你能。 把导航、滚动、选中、编辑和播放留在布局之上,并知道分屏与新窗口在 Duo 上的边界。
会坏的例子
合上时的列表选中了第三封信。打开后另一棵视图从第一封开始,草稿也空了。另一种坏法:只测了外屏全屏和内屏全屏,分屏到一半时栏重叠、文字被裁切。还假设「打开了就一定很宽」。
一条连续的宽度,加上两条硬件限制
内屏上所有 App 都参加 Split View,视频也可以和 App 叠在一起。每个 App 的垂直栏贴着自己的外侧,所以你的栏可能在左,也可能在右。两边都要测。
很宽的布局必须能退回中间宽度,再退回 compact。状态——导航路径、滚动、选中、编辑器、播放进度、未保存的表单——放在决定单栏还是双栏的那一层上面。
Duo 是第一台支持多场景的 iPhone。iPad 上会开新窗口的 App,在这里也会。新窗口不能在外屏上创建。请求失败要留在当前窗口。UIWindowScene.ActivationAction 的配置闭包必须返回一个 ActivationConfiguration,不要去改一个请求对象。平台不支持多窗口时走你提供的 alternate,不要假设外屏上点下去一定能开出第二扇。UIRequiresFullScreen 仍然生效,但设备开合时界面还是会改变尺寸。
选中项活在布局外面
struct Mailbox: View {
@State private var path: [Message.ID] = []
@State private var selection: Message.ID?
@State private var draft = ""
var body: some View {
NavigationSplitView {
MessageList(selection: $selection)
} detail: {
Editor(text: $draft, messageID: selection)
}
}
}
let action = UIWindowScene.ActivationAction(
title: "新窗口",
image: UIImage(systemName: "plus.rectangle")
) { _ in
let activity = NSUserActivity(activityType: "mailbox.message")
return UIWindowScene.ActivationConfiguration(userActivity: activity)
}
滚动位置用系统滚动视图自己的保存,或一个放在 model 里的标识,不要在合上时 scrollTo 回顶部。播放用同一个播放器对象,不要在尺寸变化时 replace 掉它。
要检查的事实
状态提升。 选中、路径、草稿、播放器放在自适应容器外面。何时用:任何会在 compact 与 regular 之间切换的页面。何时不用:不要为外屏和内屏各做一个状态对象。
Split View。 栏在该 App 的外侧。左半边栏在左,右半边栏在右。何时用:Device Hub 里把自己分别放到左边和右边。何时不用:不要假设垂直栏永远在 trailing。
UIWindowScene.ActivationAction(iOS 15)。 闭包返回UIWindowScene.ActivationConfiguration(userActivity:)。平台不支持多窗口时使用alternate。何时用:iPad 上已经支持多窗口、希望 Duo 内屏同样可以。何时不用:不要在外屏上强制请求新场景而不处理失败。新窗口不能在外屏创建。UIRequiresFullScreen。 全屏要求仍被遵守。何时用:你本来就需要独占屏幕时,保持原设置。何时不用:不要以为它能阻止开合带来的尺寸变化。布局仍要跟着变。
检查点
打开内屏之后,草稿应该从哪一份状态恢复?
App 在分屏右侧时,垂直栏更可能在哪一侧?
新窗口能在外屏上创建吗?
这一章还不能做。 不要用铰链角度重置导航。开合已经通过尺寸和保留区表达了。角度只留给真正的物理交互。
来源:HIG 对跨屏连续性与分屏外侧栏的说明;Preparing 对多尺寸的测试要求。Tech Talk 111464。
第 10 章 · 角度驱动效果,不驱动栏和列数
学完你能。 在有铰链时把角度变成音高、镜头或玩法,并在没有铰链或交互离开层级时回到安全值。
会坏的例子
if hinge.angle > someRadians { showSidebar = true }
平放、分屏、普通 iPhone 都不会按你预期给角度。侧栏该不该出现,第 3 章和第 4 章已经用空间回答了。
布局看空间,交互才看铰链
适合角度的是:把它当物理输入、游戏机关、乐器、相机姿态、桌面互动,或明确绑在开合上的效果。不适合的是:侧栏、列数、网格个数、导航是否折叠。
SwiftUI 里,DeviceHingeContext.hinge 为 nil 表示这台设备没有铰链,不是「合上了」。合上是 .closed。angle 是 Angle。status 是 .closed、.partiallyOpen、.fullyOpen。
UIKit 的 nil 含义不同:交互离开了能提供铰链更新的层级时,update.hinge 为 nil。角度是弧度。UIHinge.Status 多一个 .unknown。更新频率和精度由系统决定,不要依赖某一帧率。若只关心开、半开、全开,读 status。
半开时把角度变成音高,其余时候归零
.onHingeChange { _, context in
if let hinge = context.hinge, hinge.status == .partiallyOpen {
pitch = bend(angle: hinge.angle)
} else {
pitch = 0
}
}
let interaction = UIHingeInteraction { [weak self] _, update in
guard let self else { return }
guard let hinge = update.hinge, hinge.status == .partiallyOpen else {
self.pitch = 0
return
}
self.pitch = self.bend(radians: hinge.angle)
}
view.addInteraction(interaction)
onHingeChange(isEnabled:_:) 的 isEnabled 默认 true。UIKit 用 interaction.isEnabled:关掉期间不回调、也不把错过的更新排队;重新打开时,若当前层级还有铰链,会马上再交一次当前状态。这个闭包会逃逸,用 [weak self] 避免循环引用。
API
onHingeChange(iOS 27.1)。(isEnabled:_:),参数是旧、新两个DeviceHingeContext。何时用:效果必须跟着铰链连续变化。何时不用:躲避折痕、分栏、网格。那些用保留区和 Arrangement。DeviceHinge(iOS 27.1)。status加angle: Angle。上下文上的hinge可选。何时用:nil 时把效果归零,不要崩溃。何时不用:不要把 nil 理解成「合上了」。合上是.closed。UIHingeInteraction(iOS 27.1)。init { interaction, update in }。update.hinge是可选的UIHinge,angle为弧度,状态含.unknown。何时用:加到要观察铰链的视图上。官方示例是在viewDidLoad里addInteraction。何时不用:nil 在这里表示交互离开了提供更新的层级,不一定是「这台手机没有铰链」。更新频率。 头文件写明:速率和粒度会随系统状态变化。何时用:把角度当成连续输入,并容忍丢帧。何时不用:不要用「每秒必须 N 次」来做校准或布局动画。
检查点
侧栏该不该出现,能不能看
hinge.angle?SwiftUI 里
hinge == nil表示什么?UIKit 的 nil 呢?只想知道是不是半开,该读角度还是 status?
这一章还不能做。 不要把外屏当成第二块由你摆放的窗口。那是下一章,而且只在拍摄时成立。
第 11 章 · 外屏的额外内容,只在拍摄时由系统拿去显示
学完你能。 说明主场景如何在两块屏之间移动,以及什么时候才可以在外屏放一块补充内容。
会坏的例子
打开时把界面拆成两个 UIWindow,一个画内屏,一个画外屏。系统不会把你的第二块画布留在合上的那面。模拟器里也没有相机,这种代码无法在 Device Hub 里验证。
主体验跟着唯一的主场景走
开合时,主场景自己换到另一面。所有关键按钮留在主界面上。外屏上的补充内容可以交互,但要少。
唯一支持的方式是相机拍摄配件:设备打开、App 在前台并且内屏全屏、有一次正在进行的拍摄会话时,系统才可能把它显示到外屏。系统决定何时、何地,也可以随时收回。用 isEnabled 关掉,不要反注册来开关。状态走同一个 model。
模拟器没有相机,这一节要真机。相机朝向在开合和旋转后可能反过来,选摄像头时按「它朝哪一面」,见 Choosing a camera by the direction it faces。
ExternalNonInteractiveAccessory 是外接显示器和 AirPlay,不是 Duo 的外屏。
提词器:主界面照样能拍
CameraCaptureAccessory 在 SDK 里标明不可用于 macOS、tvOS、watchOS、visionOS 和 Mac Catalyst。sceneAccessory 与 registerSceneAccessory(_:) 从 iOS 27.0 起存在;cameraCapture(sceneConfiguration:) 和会话角色 .windowCameraCaptureAccessory 是 iOS 27.1。需要把上下文交给场景委托时,用带 userInfo 的工厂方法,在 UISceneConnectionOptions.sceneAccessoryUserInfo 里取回。
CameraView(model: model)
.sceneAccessory {
CameraCaptureAccessory(isEnabled: $model.prompterEnabled) {
Prompter(model: model)
}
.onAvailabilityChange { model.prompterAvailable = $0 }
}
let configuration = UISceneConfiguration(
name: "Capture Accessory",
sessionRole: .windowCameraCaptureAccessory)
let accessory = UISceneAccessory.cameraCapture(
sceneConfiguration: configuration)
registration = registerSceneAccessory(accessory)
// 配件场景的 delegate 画提词器。
// 快门仍在主场景里。
API
CameraCaptureAccessory(iOS 27.1)。init(isEnabled:content:),挂在sceneAccessory里。onAvailabilityChange报告系统是否还愿意显示它。何时用:提词、倒计时、让被拍的人看见画面。注册在拍摄 UI 同一视图上。何时不用:主路径上的快门、对焦、设置。外屏可以随时消失。registerSceneAccessory(iOS 27.0)。 UIKit:UISceneAccessory.cameraCapture(sceneConfiguration:),角色.windowCameraCaptureAccessory。返回的 registration 用来unregisterSceneAccessory。何时用:拍摄会话存在期间的补充场景。何时不用:不要为了临时隐藏而去 unregister。用启用状态。ExternalNonInteractiveAccessory。 连接到的显示器 / AirPlay。何时用:你本来就要支持外接大屏,且内容不需要交互。何时不用:Duo 合上的那块外屏。模拟器。 Device Hub 能开合、旋转、折叠。没有相机。多数 App 扩展也不能在 Duo 模拟器里跑。何时用:布局、栏、sheet、分屏。何时不用:不要在模拟器里宣布拍摄配件已经验过。
检查点
不拍摄的时候,能把一张仪表盘钉在外屏上吗?
提词器不可用时,快门应该在哪?
为什么这段不能只在模拟器里收尾?
到这里为止。 若产品没有相机,这一章可以不写代码。前面 0–9 章已经覆盖从设计到自适应布局的主路径。
来源:Preparing · Improve your app’s camera handling。Tech Talk 111465。
附录 · 对照、红旗和答案
前面十二章按顺序建立判断。这里把三层 API、不该出现的写法和检查点答案放在一起,方便做完一章之后回来对。
三层,不要跳
| 层 | 什么时候写 | SwiftUI | UIKit |
|---|---|---|---|
| 1 · 任何尺寸 | 第 3–6 章。所有 Apple 窗口都受益 | NavigationSplitView、TabView、GridItem(.adaptive)、ViewThatFits、AnyLayout、size class、onGeometryChange、系统 .toolbar |
UISplitViewController、UINavigationController、UITabBarController、容器宽度、trait、layoutSubviews、viewWillTransition |
| 2 · 知道折痕和垂直栏 | 第 5–8 章里,系统容器不够时 | reservedRegions、ArrangementView、toolbarVerticalBehavior、axisBehavior、visibilityPriority、presentationPlacement、toolbarVerticalEdge |
reservedRegions(kind:options:)、UIArrangementViewController、preferredVerticalBarBehavior、axisBehavior、verticalBarEdge、preferredPlacement |
| 3 · 只有硬件本身 | 第 10–11 章,产品真的需要时 | onHingeChange、CameraCaptureAccessory、sceneAccessory |
UIHingeInteraction、UISceneAccessory.cameraCapture、角色 .windowCameraCaptureAccessory |
垂直栏的条目规则在第 5 章,是因为 27.1 SDK 会把系统栏立起来,不是因为你要查询折痕。折痕查询从第 7 章才开始。
可用性,避免记混
| 系统 | 这一档才有的东西 |
|---|---|
| iOS 26 | ConcentricRectangle、backgroundExtensionEffect()、UIBackgroundExtensionView、UICornerConfiguration |
| iOS 27.0 | visibilityPriority、ToolbarOverflowMenu、topBarPinnedTrailing、presentationPlacement、defaultTabBarPlacement、isTabViewSidebarAvailable、sceneAccessory / registerSceneAccessory |
| iOS 27.1 | 垂直栏行为与边、axisBehavior、压缩行为、保留区、Arrangement、铰链、CameraCaptureAccessory |
UIKit 的 leadingItemGroups、pinnedTrailingGroup、additionalOverflowItems 从 iOS 16 就能用。Duo 上它们负责垂直栏里的顺序和溢出,但属性本身不是 27.1 才出现的。
SwiftUI 的可见性优先级是 .automatic / .low / .high。UIKit 的对应值是 .standard / .low / .high。压缩行为也差一个名字:SwiftUI 的 .prefersToolbarItems,UIKit 的 .prefersBarItems。
打开文件时先搜这些
用来做普通布局,就该改掉:
UIScreen.main.bounds、UIScreen.main.scale、存下来的UIScreenUIWindow(frame: UIScreen.main.bounds)userInterfaceIdiom == .pad或.phoneUIDevice.current.orientation、statusBarOrientation、interfaceOrientation,包括effectiveGeometry.interfaceOrientationhorizontalSizeClass == .regular就进入「仅 iPad」页面bounds.height == 844或一份已知机型高度表safeAreaInsets.left * 2if isDuo、if isFolded、用铰链角决定侧栏或列数写死的大 frame、写死的侧栏宽度、写死的网格列数
自绘顶栏,或单独创建的
UIToolbar/UINavigationBar/UITabBar只有标题、没有图标的工具栏按钮;自己画的「…」菜单
关键内容居中压在折痕上;compact 和展开各存一份状态
.fill的主视觉没有焦点,也没有随比例在 fill / fit 之间选择在文档写明的例外之外使用
toolbarVerticalBehavior(.disabled),或按页面状态来回切换
Device Hub 里至少看过的姿态
| 姿态 | 预期 |
|---|---|
| 合上竖屏 | 外屏,compact 宽 + regular 高,垂直栏 |
| 合上横屏 | 双 compact,垂直栏 |
| 打开竖屏 | 内屏 regular × regular,水平栏。这是唯一保持上下栏的常见姿态 |
| 打开横屏 | regular × regular,垂直栏 |
| 半折 | 同一套层级。精确内容不骑在折痕上。滚动内容不整页改道 |
| 内屏分屏,自己在左、在右 | 栏贴着自己的外侧。状态还在 |
每个 sheet、popover 都要在这些姿态里打开一次。商店截图以 App Store Connect 的 Screenshot specifications 为准。常见整理是外屏 1398×2034、内屏 2007×2853,横屏把两边对调。这组数字不要写进布局。若材料写「2027 年 4 月起提交需要 Duo 截图」,确切日期以苹果当时的公告为准。
检查点答案
第 0 章。 内屏竖屏的系统栏在上下。六种姿态仍然是一套界面。打开之后它还是 iPhone 应用,只是内屏成为 regular × regular。
第 1 章。 900pt 宽的设置表单把句子拉满了,标签和开关被空白拆开。应该限宽,把多余空间留给下一层层级。渐变可以留在正中,付款按钮不行。列表的全选跟着列表,不跟着最右侧的系统栏。
第 2 章。 部署目标可以仍是 iOS 18。用 27.1 SDK 编译之后,垂直栏会出现。四周留空时要换编译 SDK,不要检测机型去拉 frame。新符号在旧系统上不存在,所以必须放进 #available。
第 3 章。 regular 宽度会在 Duo 内屏上成立,所以它不再等于 iPad。内屏不理会方向声明,宽还是高要比较容器的宽和高。左右 inset 可以不相等,left * 2 会把另一边算错。
第 4 章。 选中项放在分栏外面的同一份状态里。列数用容器宽度除以最小项宽,这里会落到 2。ViewThatFits 回答的是哪种排列放得下。
第 5 章。 只有标题的按钮不会进垂直栏。分栏里只有详情列的栏垂直。导航型页面默认先把工具项收进溢出,保住标签栏。单独创建的 UIToolbar 不是导航容器提供的栏,系统不会把它立起来。
第 6 章。 内屏上 trailing sheet 的工具栏是垂直的。左右安全区不对称,不能镜像。自己画的全屏遮罩不会像系统 sheet 那样躲开折痕。
第 7 章。 完全打平后 division 不激活,宽度为 0。可滚动的文章不要逐段挪开。内屏摄像头平时不占 occlusion,只在使用中占住。
第 8 章。 ZStack 映射 overlay。Arrangement 被导航包住,不要反过来。放进 ScrollView 会让其中一块变得够不着。
第 9 章。 草稿从同一份状态恢复,而不是另一棵视图树。分屏右侧的垂直栏在右侧,也就是该 App 的外侧。新窗口不能在外屏上创建。
第 10 章。 侧栏不看铰链角。SwiftUI 的 nil 表示这台设备没有铰链;UIKit 的 nil 表示交互离开了能提供更新的层级。只想知道是不是半开,读 status。
第 11 章。 不拍摄时不能把仪表盘钉在外屏上。快门留在主界面。模拟器没有相机,拍摄配件要真机。
官方入口
ReservedRegion,以及 UIKit 的
UIView.ReservedRegionArrangementView,以及
UIArrangementViewControllerTech Talks:111466 设计、111461 准备、111462 垂直栏、111463 姿态与 Arrangement、111464 多屏与场景、111465 相机
Preparing 页面还列了 WWDC 2026 · 278 和 WWDC 2025 · 282
最佳实践 · 底部入口不动,宽度交给内容
看完你能。 用这个 App 对照前面的规则,分清哪些已经按苹果的方式落地,哪些是产品选择,哪些还不能当成验过。
这个 App 没有 Duo 版。部署目标仍是 iOS 18,用 27.1 SDK 编译才铺满并让系统栏可以垂直。开合不换路由、不换页面模型。多出来的 regular 宽度用在「今天」和「呼吸」内部的左右栏,而不是把三个一级入口改成侧栏。铰链角、相机外屏和多窗口没有产品理由,所以不写。
对照
| 教程里的判断 | 这个 App 的做法 |
|---|---|
| 第 0–2 章:一套界面,SDK 才是开关 | 不按设备名分叉。新 API 用 #available 包住,旧系统留下同一种关系的 HStack / VStack |
| 第 3–4 章:按容器宽度露出下一层 | compact 壳再宽也是单列。regular 在 800pt 仍是单栏,801pt 起才左右并排。放得下最大主列 480pt 加可读详情 720pt(合计 1201pt)才进入更疏的一档。1024pt 仍是并排,不是把句子拉满 |
| 第 5 章:系统栏,但侧栏不是必选项 | .sidebarAdaptable 加上 defaultTabBarPlacement(.tabBar),三个 Tab 留在底部 |
| 第 6 章:Sheet 声明位置;自绘遮罩不会避折 | 系统 sheet 在 regular 上按意图走 trailing 或 center。compact 付费层仍是根上的遮罩,不会自动躲开折痕 |
| 第 7–8 章:只挪成对的两块内容 | 呼吸、付费横栏、等宽主次栏用 ArrangementView 的 split。滚动的首页和详情不因为折痕改道 |
| 第 9–11 章:状态连续;硬件 API 可选 | 选中和路由在布局外面。不做 onHingeChange、拍摄配件和多窗口 |
根导航保持三个入口
内屏可以变成侧栏,这个产品选择不变成。「今天」、趋势、呼吸仍是底部 Tab。设置从「今天」推进。iPad 路径用 defaultAdaptableTabBarPlacement(.tabBar),iOS 27 再明确 defaultTabBarPlacement(.tabBar)。打开内屏之后,多出来的空间出现在页面里面:今天左卡片右详情,呼吸左模式右练习。
并排要同时满足两件事:这一页的壳是 regular,而且当前横向 size class 也是 regular。这样窄的那一棵树不会抢走共享选中项。壳如果是 compact,容器即使很宽,档位仍是单列。
TabView(selection: adaptiveSectionSelection) {
// 三个 Tab
}
.tabViewStyle(.sidebarAdaptable)
.defaultAdaptableTabBarPlacement(.tabBar)
// iOS 27:content.defaultTabBarPlacement(.tabBar)
adaptiveShellRole == .regular && horizontalSizeClass == .regular
Arrangement 只描述一种已有关系
导航留在外面。低版本的布局和 27.1 的 Arrangement 是同一种关系,不是另一套页面。
| 场景 | 原来的关系 | 27.1 |
|---|---|---|
| 呼吸练习 | 主视觉在上,控件在下 | .split。高则上下,宽则左右,并躲开折痕。不锁轴,也不看铰链角 |
| 付费页横向 | 主视觉与购买并排 | .split。旧系统仍是 HStack |
| 等宽主次栏 | 两栏都要专属空间 | .split.axes(.horizontal),比例 0.5。背景可以跨过折痕,可点内容在 Arrangement 里面 |
系统会做的,和还不能算完成的
安全区按四边读。滚动列表和详情里的图不因半折而位移。系统 sheet:compact 走 .automatic,regular 按意图选 trailing 或 center。
明确不做:不用铰链角摆呼吸或侧栏。没有相机,就不注册拍摄配件。不在外屏创建新窗口。不为六种姿态复制页面模型。只有产品真的要把开合当成输入,或出现拍摄界面时,才重新考虑。
还不能算完成:六种姿态还要在 Device Hub 里亲手看过。公开的模拟器命令不能开合或半折。compact 付费遮罩不是系统 sheet。只有一个关闭按钮的说明 sheet,是否关掉垂直栏仍待定。共享元素转场用的固定顶栏还留在缩放层外。编译通过不等于折痕和垂直栏已经看过。
新页面怎么接:先放进现有 Tab 或导航栈,让状态留在外面。再按容器档位决定单栏或并排,长文停在大约 720pt。只有两块内容本来就要换位时,才用 Arrangement,并用 #available(iOS 27.1, *) 留下旧布局。先写系统 toolbar、size class、可读宽度。后写保留区和 Arrangement。铰链与副屏继续不写,直到产品需要。
用这个 App 自测
内屏变宽之后,三个 Tab 应该升成侧栏,还是留在底部?
regular 容器只有 800pt 时,「今天」应该单栏还是并排?
呼吸练习的球体和控件,为什么用
.split而不是onHingeChange?compact 付费遮罩半折时压在折痕上,能不能说「系统 sheet 会躲开」?
答案。 三个 Tab 留在底部。800pt 仍是单栏,801pt 才并排。球体和控件是同一段练习的上下关系,交给 split,角度不决定布局。付费遮罩不是系统 sheet,不会自动避折。
继续阅读
在 Stress HRV 里体验这些想法
App Store 下载这些笔记分享个人健康工具的产品思考,不构成医疗建议。