為 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 下載

這些筆記分享個人健康工具的產品思考,不構成醫療建議。