為 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 下載這些筆記分享個人健康工具的產品思考,不構成醫療建議。