SwiftUI佈局iPhone Duo

圍繞 iPhone Duo 的摺痕佈局

保留區域、ArrangementView 和轉軸角度 API。如何讓控制元件避開摺痕,以及什麼時候才真正能用上第二塊螢幕或第二個視窗。

2026年10月4日閱讀約 7 分鐘8 個來源

摺痕不是你把螢幕一分為二憑空造出來的第二欄。系統已經知道轉軸在哪裡、它此刻是否礙事,以及一個主檢視和一個次檢視應該如何圍繞它擺放。你要做的是去問系統,並且只挪動那些否則會落在摺痕上的控制元件。

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 不提供導航,放在可滾動的東西里會表現異常。

豎著拿的展開 iPhone Duo 上的 Zoom:摺痕上方是共享文件,下方是通話中的四個人
兩部分內容,摺痕兩側各一部分:豎著拿的展開手機上的 Zoom,摺痕上方是共享螢幕,下方是通話中的人。圖片:Apple
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 行為,欄也是橫向的。

繼續閱讀

App · 規格閱讀約 4 分鐘

iPhone Duo 能執行 iPad App 嗎?iPhone Duo 與 iPad mini

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

10月6日閱讀指南