为 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 章。

  • 水平栏 / 垂直栏。 内屏竖屏保持上下栏;其余常见姿态把导航、工具栏和标签栏收到侧边。何时用:画栏的时候就按两种方向都画一遍。何时不用:不要自己画一条假侧栏去模仿它。

检查点

  1. 内屏竖屏时,系统栏在上下,还是在侧面?

  2. 六种姿态应该做几套界面?

  3. 打开 Duo 之后,它变成了 iPad 应用吗?

这一章还不能做。 不要写 if isDuo,不要读铰链角度,不要查询折痕。你现在只需要拒绝「每种姿态一版 UI」。

来源:HIG · Designing for iPhone Duo,Tech Talk 111466。

第 1 章 · 宽了就露出下一层,不要把句子拉满

学完你能。 拿到一块变宽的区域时,决定该加一栏、加一列,还是只是把原来的窄界面撑开。也能说出什么可以跨过折痕、什么不行。

会坏的例子

设置页在外屏上是舒服的一列。打开内屏后,同一列表的每一行拉到接近整屏。标题和开关分居两端,中间是大片空白。另一种坏法:把主按钮、二维码或人脸正好放在屏幕正中,半折时被折痕切开。

四条可以拿去评审的原则

  1. 功能和状态不断。 合上时能做的事,打开后还在。滚动、选中、正在编辑的内容不要因为开合而重置。变的是一次能看见几层。

  2. 多出来的宽度用来露出层级。 邮件在外屏是「列表,点进去才是信」。内屏可以列表和信并排。这是多看见一层,不是把信的行宽变成 900pt。

  3. 折痕像书脊。 背景、渐变、会滚动的文章可以跨过去。按钮、句子、人脸、二维码、拖动手柄不要骑在正中。这一章只建立这条判断;怎么查询折痕是第 7 章。

  4. 控件靠近它作用的内容。 管列表的按钮留在列表上方,不要因为侧边有一条垂直栏,就把它们全部赶到远处。

游戏要铺满当前姿态。优先改画面比例,而不是上下或左右留出黑边。文字和按钮的物理尺寸尽量稳住。

同一块设置页,两种宽度

长文和表单给一个可读上限,让多出来的空间留给旁边的层级,而不是留给更长的句子。

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 走到很宽时,按这个顺序找能同时露出的结构。何时不用:不要先加大间距和字号来填满空白。

  • 书脊测试。 想象正中有一条不能点准的缝。可以跨:摄影背景、纹理、持续滚动的列表和文章。不要跨:按钮、必须读完的短句、人脸、二维码、输入框、小图标。

  • 就近分组。 常用操作留在它影响的那一块旁边。何时用:列表的筛选留在列表上;详情的分享留在详情上。何时不用:不要为了对称,把主操作丢到离内容很远的角。

  • 游戏铺满。 姿态变了就改画面比例。何时用:可以锁竖屏或横屏,但当前姿态要铺满。何时不用:不要用黑边凑合。若实在要留边,用画面而不是纯色填上。

检查点

  1. 内屏上把设置表单拉到 900pt 宽,问题出在哪?

  2. 半折时,渐变背景和「付款」按钮,哪一个可以留在正中?

  3. 邮件列表的「全选」应该跟列表走,还是跟最右侧那条系统栏走?

这一章还不能做。 不要为半折重做整页,也不要现在就写查询折痕的代码。你只需要在稿子上标出「不能骑在正中」的元素。

第 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 上不可用。

检查点

  1. 部署目标仍是 iOS 18,但用 27.1 SDK 编译。垂直栏会出现吗?

  2. 包已经能在 Duo 上启动,四周有空。下一步是检测机型拉满,还是换 SDK?

  3. 为什么 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,不看整块物理屏。

检查点

  1. horizontalSizeClass == .regular 现在还会在 iPhone 上成立吗?

  2. 内屏横过来了。用 interfaceOrientation 判断「现在比较宽」可靠吗?

  3. 为什么 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 分支里各写一遍子视图。

检查点

  1. 从并排折回单列时,选中项应该存在哪里?

  2. 分屏后宽度只够两张 220pt 的卡片。列数该怎么来?

  3. 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。

检查点

  1. 只有标题、没有图标的按钮,会进垂直栏吗?

  2. 分栏的三列里,哪一列的栏会垂直?

  3. 导航型页面空间不够时,默认先收工具项还是先收标签栏?

  4. 自绘的 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。

检查点

  1. 内屏上,trailing sheet 的工具栏是水平还是垂直?

  2. 为什么不能用 left * 2 得到内容宽度?

  3. 系统 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。

检查点

  1. 完全打平后,division 还激活吗?宽度是多少?

  2. 一篇可滚动的文章,要不要把每一段从折痕上挪开?

  3. 内屏摄像头平时占着 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 再调用它。默认风格已经会跟容器走。

检查点

  1. 原来是 ZStack,半折时要分到折痕两侧。用 split 还是 overlay?

  2. Arrangement 应该包住导航,还是被导航包住?

  3. 把它放进 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。 全屏要求仍被遵守。何时用:你本来就需要独占屏幕时,保持原设置。何时不用:不要以为它能阻止开合带来的尺寸变化。布局仍要跟着变。

检查点

  1. 打开内屏之后,草稿应该从哪一份状态恢复?

  2. App 在分屏右侧时,垂直栏更可能在哪一侧?

  3. 新窗口能在外屏上创建吗?

这一章还不能做。 不要用铰链角度重置导航。开合已经通过尺寸和保留区表达了。角度只留给真正的物理交互。

来源: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 次」来做校准或布局动画。

检查点

  1. 侧栏该不该出现,能不能看 hinge.angle?

  2. SwiftUI 里 hinge == nil 表示什么?UIKit 的 nil 呢?

  3. 只想知道是不是半开,该读角度还是 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、分屏。何时不用:不要在模拟器里宣布拍摄配件已经验过。

检查点

  1. 不拍摄的时候,能把一张仪表盘钉在外屏上吗?

  2. 提词器不可用时,快门应该在哪?

  3. 为什么这段不能只在模拟器里收尾?

到这里为止。 若产品没有相机,这一章可以不写代码。前面 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、存下来的 UIScreen

  • UIWindow(frame: UIScreen.main.bounds)

  • userInterfaceIdiom == .pad 或 .phone

  • UIDevice.current.orientation、statusBarOrientation、interfaceOrientation,包括 effectiveGeometry.interfaceOrientation

  • horizontalSizeClass == .regular 就进入「仅 iPad」页面

  • bounds.height == 844 或一份已知机型高度表

  • safeAreaInsets.left * 2

  • if 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 章。 不拍摄时不能把仪表盘钉在外屏上。快门留在主界面。模拟器没有相机,拍摄配件要真机。

官方入口

最佳实践 · 底部入口不动,宽度交给内容

看完你能。 用这个 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 自测

  1. 内屏变宽之后,三个 Tab 应该升成侧栏,还是留在底部?

  2. regular 容器只有 800pt 时,「今天」应该单栏还是并排?

  3. 呼吸练习的球体和控件,为什么用 .split 而不是 onHingeChange?

  4. compact 付费遮罩半折时压在折痕上,能不能说「系统 sheet 会躲开」?

答案。 三个 Tab 留在底部。800pt 仍是单栏,801pt 才并排。球体和控件是同一段练习的上下关系,交给 split,角度不决定布局。付费遮罩不是系统 sheet,不会自动避折。

继续阅读

在 Stress HRV 里体验这些想法

App Store 下载

这些笔记分享个人健康工具的产品思考,不构成医疗建议。