圍繞 iPhone Duo 的摺痕佈局
保留區域、ArrangementView 和轉軸角度 API。如何讓控制元件避開摺痕,以及什麼時候才真正能用上第二塊螢幕或第二個視窗。
摺痕不是你把螢幕一分為二憑空造出來的第二欄。系統已經知道轉軸在哪裡、它此刻是否礙事,以及一個主檢視和一個次檢視應該如何圍繞它擺放。你要做的是去問系統,並且只挪動那些否則會落在摺痕上的控制元件。
Apple 的姿態講座把這叫作“位移”。把元素挪動一小段距離,讓它保持可見、可點。相關的控制元件一起挪。可滾動的內容留在原處:資訊流、文章和列表在使用者滾動時本來就會自己適應。傳送按鈕、進度條或浮動操作按鈕則不會。
什麼時候需要做這些,見設計指南。介面框架上的欄見豎向欄指南。本文講的是轉軸。
要點
- 系統把摺痕報告為一塊 40 點寬的保留區域,只在手機半折時生效。讀取它,永遠不要把它算成寬度的一半。
- 只挪動你自己擺放的控制元件,而且只挪一小段。系統容器本來就會避開摺痕。
- 應該分處摺痕兩側的兩部分內容,放進一個
ArrangementView,導航放在它外面。 - 轉軸角度用於隨轉軸而動的東西,而不是用來決定按鈕放在哪裡。
- 手機展開時,外螢幕是留給相機 App 的。
什麼工具做什麼事:
| 你想要 | 使用 |
|---|---|
| 讓按鈕、進度條或傳送控制元件避開摺痕 | 保留區域,見下文 |
| 讓列表與詳情、或主檢視上的控制面板在摺痕處分開 | ArrangementView |
| 讓某樣東西隨著轉軸的移動而彎曲、轉動或演奏 | onHingeChange,見轉軸指南 |
| 手機展開時在外螢幕上顯示內容 | CameraCaptureAccessory,僅限相機 App,見相機指南 |
| 知道手機是合上、展開還是半折 | 先看尺寸類別,再看轉軸的 status |
向系統要區域
安全區域描述的是邊緣。它不描述轉軸,也不描述每一個鏡頭開孔。這兩者都會以保留區域的形式出現在 GeometryProxy 上,在 UIKit 裡則是 UIView 上。
GeometryReader { proxy in
let folds = proxy.reservedRegions(kind: .division)
let cameras = proxy.reservedRegions(kind: .occlusion, options: [.includeInactive])
// region.frame, region.margins, region.isActive, region.id
}
.division 是轉軸:一條把螢幕分開的帶狀區域。.occlusion 是遮住玻璃的東西,比如鏡頭。每個區域都有一個以該檢視座標系表示的 frame、一圈需要在 frame 周圍保持空出的邊距、一個 isActive 標誌,以及一個在不同座標空間中保持不變的 id。Bleeping Swift 的文章指出,你可以把在一個視圖裡看到的 id 與在另一個視圖裡讀到的 id 對應起來;它還補充了一個引數:在從右到左書寫的語言中,frame 預設是映象的,所以當你需要玻璃的物理位置時,傳入 layoutDirectionBehavior: .fixed。
分隔區域只有在手機半折時才是生效的佈局輸入。手機平放時,控制元件留在原處即可。Kashif Mehmood 對 Bunny Lau 那條 Tweed 貼文的糾正是簡短版:姿態告訴你的是模式,而不是接縫在哪裡。凡是你手動擺放的東西,都要讀取這塊區域。系統容器本來就會避開它。
Artem Novichkov 基於 iOS 27.1 模擬器做了一套示例 App,並記錄下了觀察到的現象。他標明這些是觀察結果,不是文件保證的行為:
- 分隔區域只在裝置半折時生效。平放時,它是未啟用的。兩種狀態下它的 frame 相同:寬 40 點,折線兩側各有 20 點邊距,折線本身沒有寬度。
- 區域在第一次佈局之後才會出現。在
GeometryReader的 body 裡讀取它們,這樣檢視會隨之更新。不要快取它們。Blake Crosley 的探測 App 每次啟動時,body 的前六到九次計算都讀不到任何東西,之後才每次都能讀到所有區域。 - 除非傳入
.includeInactive,大多數區域都是未啟用的。在完全展開的手機上,摺痕是一塊未啟用的分隔區域,鏡頭是一塊未啟用的遮擋區域。 - 豎向欄中的狀態列區域是一塊生效的遮擋區域。
- 在合上的外螢幕上,Artem 完全沒有看到保留區域。Blake 的探測 App 在 10 月 2 日重新執行時,在那裡看到了兩塊生效的遮擋區域:鏡頭,以及欄上方的狀態列。無論你用的是哪個版本,都要讀取區域,而不是假定其中任何一種答案。
不要把任何東西與區域開始生效時的角度掛鉤。Ihor Malovanyi 把模擬器的轉軸滑塊來回掃了四次,看到 isActive 分別在 98°、132.5°、132.5° 和 172.6° 時開啟。把這個標誌當作當下的事實,在它變化時重新佈局。
用生效的區域來挪動控制元件。用未啟用的區域做更粗略的決定,比如優先選擇偶數個網格列,這樣當手機真的彎折時,欄間距可以正好落在摺痕上。Artem 的桌面示例把圖片放在生效分隔區域的一側、控制元件放在另一側:筆電式姿態下上下堆疊,書本姿態下左右並排。偶數列示例用的是未啟用的區域,讓欄間距提前就位。
不要把轉軸算成“寬度的一半”。分割畫面是另一個功能,它確實是一半對一半。硬體摺痕在哪裡,以區域報告的為準。
在 UIKit 中,狀態變化時要讓佈局失效。當摺痕變為生效或未啟用時,UIHingeInteraction 的處理程序會觸發,那正是重新佈局的時機。
ArrangementView:當你有兩部分內容時
ArrangementView 接收一個主檢視和一個次檢視,並根據現有空間擺放它們。它是一個佈局容器,不是導航容器。把 NavigationStack 放在它外面。不要在它裡面放 NavigationSplitView,也不要把 ArrangementView 放進 List 或 ScrollView。Khoa Pham 從姿態講座中總結出了這條規則,它也與這個 API 的設計相符:ArrangementView 不提供導航,放在可滾動的東西里會表現異常。

NavigationStack {
ArrangementView {
TranscriptView()
.splitArrangementLayoutRatio(0.4)
} secondary: {
PlayerView()
}
.arrangementViewStyle(.split.axes([.horizontal, .vertical]))
}
.split 會劃分空間:字幕文稿在播放器旁邊,列表在詳情旁邊,或者像上圖的 Zoom 那樣,一邊是共享螢幕,一邊是通話中的人。你可以限制方向軸。如果兩個檢視沿允許的軸放不下,Artem 看到次檢視會消失,只剩下主檢視。比如在寬螢幕上只允許豎向分割時就會這樣。在書本姿態下,即使會覆蓋 splitArrangementLayoutRatio,模擬器也會把分割線放在摺痕上,而 Blake 看到分割佈局讓那條 40 點寬的帶狀區域空著。做好摺痕優先的準備。
.overlay 是另一種樣式。次檢視鋪滿容器,主檢視浮在上面;手機半折時,兩者左右並排。
ArrangementView {
ResultsPanel()
.overlayArrangementEdge(.leading)
} secondary: {
MapView()
}
.arrangementViewStyle(.overlay)
overlayArrangementEdge 是佈局不再浮動時面板所佔的那一側。要知道當前是否處於浮動狀態,從面板的子檢視中讀取 overlayArrangementZIndex。Artem 測得主內容的根檢視讀到的永遠是 0。Joshua Cleetus 也踩過同樣的坑:從自己根檢視讀取這個值的面板永遠不會收起。大於 0 的值表示你正蓋在另一個檢視上,應該使用緊湊形式。
struct ResultsBody: View {
@Environment(\.overlayArrangementZIndex) private var zIndex
var body: some View {
if zIndex > 0 {
ResultsSummary()
} else {
ResultsList()
}
}
}
UIKit 的對應容器是 UIArrangementViewController,用 state(for:) 代替環境值。你不必把執行良好的 UISplitViewController 換成 UIArrangementViewController。當你已經有兩塊應該移到摺痕兩側的區域,或者主檢視上有一個控制面板時,再用它。
角度是用來玩的,不是用來佈局的
onHingeChange 報告一個狀態(.closed、.partiallyOpen、.fullyOpen)和一個連續的角度。UIKit 的版本是 UIHingeInteraction。沒有轉軸時返回 nil,在任何不能摺疊的手機上都是這樣。
.onHingeChange { _, context in
if let hinge = context.hinge, hinge.status == .partiallyOpen {
bend = amount(for: hinge.angle)
} else {
bend = 0
}
}
這種寫法適合彎音、翻頁,或者靠摺疊來玩的遊戲,不適合“把按鈕放到這裡”。區域和 arrangement 本來就會跟隨轉軸,包括轉軸正在移動的時候。Artem 的另一條記錄是:不要依賴角度更新的頻率,也不要依賴度數的精度,這個頻率由系統決定。如果只需要姿態,讀取 status 即可。傳入 isEnabled: false 可以暫停推送,而不必移除修飾符。如何把角度變成一種控制元件,以及開發者為轉軸遊戲做的檢測器,見轉軸指南。
在 Anton 記錄的小組實驗室筆記中,Apple 的工程師把同樣的意思表述為一種偏好:使用最貼合需求的最高層級 API,不要從原始角度入手。那次交流中沒有任何 API 能檢測哪一面朝下放在桌上。手機兩種放法都可能,所以半折佈局要設計得兩種放法都說得通。
同時使用兩塊螢幕,以及第二個視窗
大多數 App 從不同時在兩塊螢幕上繪製內容。外螢幕和內螢幕是同一個 App 的兩個時刻。例外是相機 App。有活躍的相機會話、並且只在內螢幕上全螢幕時,它可以在外螢幕上為被拍攝的人顯示一個 CameraCaptureAccessory,比如 Apple 的提詞器示例。程式碼和規則見相機指南。
為自己的 App 開第二個視窗是內螢幕專屬的功能,外螢幕無法建立視窗。當新場景會建立失敗時,UIWindowScene.ActivationAction 會把自己隱藏起來;在外螢幕上就是這種情況,而在 iPad 上則不會。兩個例項共享 App 儲存,包括 AppStorage,它們是同一個 App。
如果你的介面完全是自定義的,上面這些容器都不會替你做這些事,需要檢查的佈局數量會迅速增加。檢查清單會帶你逐一過一遍:先在模擬器上試遍每種姿態,再檢查落在摺痕上的控制元件。
速查表
| SwiftUI | UIKit | |
|---|---|---|
| 摺痕和鏡頭在哪裡 | GeometryProxy 上的 proxy.reservedRegions(kind:options:) |
view.reservedRegions(kind:options:) |
| 圍繞摺痕的兩部分內容 | 使用 .split 或 .overlay 的 ArrangementView |
UIArrangementViewController |
| 我是否浮在另一個檢視上? | @Environment(\.overlayArrangementZIndex),從子檢視讀取 |
state(for:) |
| 摺痕變為生效或未啟用 | GeometryReader 的 body 會重新執行 |
UIHingeInteraction |
| 姿態和角度 | onHingeChange |
UIHingeInteraction |
| 展開時的外螢幕(相機 App) | sceneAccessory 配合 CameraCaptureAccessory |
registerSceneAccessory 配合 UISceneAccessory.cameraCapture() |
| 你的 App 的第二個視窗 | UIWindowScene.ActivationAction,在會失敗的地方隱藏 |
同左 |
以上都需要 iOS 27.1 SDK。如果針對 27.0 建置,同樣的程式碼拿不到區域,沒有 arrangement 行為,欄也是橫向的。
繼續閱讀

iPhone Duo App 創意:開發者正在用摺疊螢幕做的 6 件事
300 多個 iPhone Duo 示範、概念和原型,歸納為反覆出現的六種模式,從半折的筆記型電腦形態到把轉軸當控制器。如果你正在考慮做點什麼,可以從這裡開始。

iPhone Duo 能執行 iPad App 嗎?iPhone Duo 與 iPad mini
iPhone Duo 的內螢幕幾乎有 iPad mini 那麼大,但它執行的是 iOS,不是 iPadOS。這對僅限 iPad 的 App 意味著什麼、為什麼很多 Duo App 看起來會像它們的 iPad 版,以及 Duo 從 iPad 借來了什麼。

iPhone Duo 遊戲:即將到來的作品,以及摺疊螢幕給遊戲帶來了什麼
哪些遊戲已在 iPhone Duo 上亮相、未更新的遊戲在展開的螢幕上是什麼樣子,以及開發者正在用轉軸、半折和兩塊螢幕做出哪些新玩法。