開發測試iPhone Duo

藉助程式設計代理將 App 適配到 iPhone Duo

用 Claude Code、Codex 等程式設計代理把 iOS App 適配到 iPhone Duo:用指令碼讓 Xcode 27.1 模擬器開合、旋轉和截圖,再用實測得來的佈局法則。

whiten2026年10月9日 · 更新於 2026年10月11日閱讀約 72 分鐘14 個來源

本文面向藉助程式設計代理(如 Claude Code、Codex 或 Xcode 內建 AI 代理)將現有 iOS App 適配至 iPhone Duo 的開發者。本文聚焦於 AI 代理單憑 Apple 官方文件無法掌握的核心知識:在無需人工操作 Device Hub 的情況下如何自動化驅動 Duo 模擬器、各硬體姿態下系統報告的具體度量數值,以及在真正審查螢幕視覺效果後依然站得住腳的佈局決策。文中附帶一套現成的工具包,可供 AI 代理直接拉取執行。

文中的案例來自電影票根收藏 App Flicit 的適配實戰。該 App 使用 Claude Code 歷經五輪迭代完成適配,每張截圖均由開發者人工審查。第四輪重點修復了手動測試模擬器時暴露的暗坑:一次特定旋轉導致的介面卡死、海報被粗暴裁成正方形,以及某種姿態下三個核心頁面僅適配了一頁的缺陷。第五輪則對照 Apple 關於 iPhone Duo 的全部六場 Tech Talk 逐一核驗,補齊了官方建議但此前遺漏的特性。文中所有資料均在搭載 Apple 晶片的 Mac 上通過 iPhone Duo 模擬器實測獲得(Xcode 27.1 Release Candidate,版本號 27A9275;iOS 27.1 執行時,版本號 24A94232)。鑑於 iPhone Duo 定於 10 月 23 日發售,本文全部結論目前均基於模擬器環境,尚未在實體硬體上驗證。

要點

  • 必須使用 Xcode 27.1 SDK 建置,而非僅僅依賴其執行時;在做任何修改之前,務必優先執行 Apple 官方的 app-resizability 技能(通過 xcrun agent skills export 匯出)。將摺疊螢幕專屬程式碼置於條件編譯標誌之後,以確保 Xcode 27.0 工具鏈依然能夠正常編譯。
  • 將工具包交給 AI 代理。 duo.py 負責開合、旋轉,擷取並錄製每種姿態下的畫面;xcode_device.py 則通過 Xcode 的 device-interaction 工具模擬點按和讀取 UI 層級,注意這些互動工具僅在手機閉合時完全有效。
  • 針對當前正在渲染的顯示螢幕截圖,並校驗影像絕非全黑。切勿在 Duo 模擬器上執行 simctl io screenConfig。
  • 查詢系統工具列的停靠邊緣(toolbarVerticalEdge)。自定義標籤列應直接嵌入該側邊列中,置於系統常駐元件下方(動態島會隨即時動態沿著該列向下延伸)。其餘內容均需嚴格遵循安全區域。
  • 針對裝置姿態(Pose)設計,而非單純針對尺寸:寬大於高時採用雙欄跨頁(豎版海報獨佔左半螢幕),縱向立起時收為單欄,並將分欄間距精準對齊摺痕。裝置平放時同樣要提前對齊:系統會將摺痕報告為未啟用的保留區域,因此彎折時不會發生突兀的二次排版位移。在筆記本姿態下,所有可能在該姿態開啟的頁面均應拆為上下兩部分,並完整保留其他姿態下的各項操作。
  • 僅對彎折做動畫。 開合與旋轉均發生在系統自身的快照與旋轉動畫之下。展開狀態下彎折手機是使用者唯一會親眼目睹 App 介面變化的時刻:僅在此處做動畫,切勿在其他時機新增,且切入的新佈局必須在首幀繪製已解碼的影像。
  • 切勿依據佈局決策自身所產生的尺寸,反向作為該決策的判斷條件。 Flicit 曾有一處雙欄元件靠測量自身尺寸來決定摺痕間距,導致旋轉到直向時陷入死迴圈卡死,直至被系統 Watchdog 強殺。務必始終測量外部容器分給你的可用空間。

把這些交給你的 AI 代理

這裡的所有內容都可以讓 AI 代理直接抓取:

  • 本指南的 Markdown 版本:foldhunt.com/guides/everything-your-agent-needs-for-iphone-duo/index.md。FoldHunt 的每個頁面均支援 Accept: text/markdown 響應頭。
  • 工具包,按 AI 代理技能(Agent Skill)的格式組織:SKILL.md 規定了核心原則與命令,配套指令碼就位於其同級目錄。同樣的檔案也以 iphone-duo-simulator-skill 發布在 GitHub 上,採用 MIT 許可。除了 Xcode 之外無需額外安裝環境;僅生成真機渲染圖的指令碼需要 numpy 與 Pillow。
檔案 作用
SKILL.md 本指南的精簡濃縮版:準備工作、硬性規則、姿態資料表、API 清單與佈局法則
scripts/duo.py 自動化開合與旋轉模擬器,擷取並錄製當前渲染螢幕的畫面,啟動 App
scripts/duo_fold.c duo.py 編譯並在模擬器內部執行的 HID 底層輔助程式
scripts/xcode_device.py 通過 Xcode 的 MCP 工具執行點按、滑動、文本輸入,並抓取 UI 檢視樹
scripts/duo_mockup.py 與 duo3d.py 將截圖嵌入 Apple 官方邊框,並在書本與筆記本姿態下進行 3D 彎折曲面渲染

安裝為 Claude Code 的技能(其他 AI 代理,包括 Codex,從各自的技能目錄讀取同一個資料夾即可):

git clone --depth 1 https://github.com/WhitenWhiten/iphone-duo-simulator-skill .claude/skills/iphone-duo-simulator

沒有 git 時,也可以從本站抓取同樣的檔案:

d=.claude/skills/iphone-duo-simulator; mkdir -p "$d/scripts"
for f in $(curl -fsSL https://foldhunt.com/kits/iphone-duo/files.txt); do
  curl -fsSL -o "$d/$f" "https://foldhunt.com/kits/iphone-duo/$f"
done

建議的第一條指令如下:閱讀 iphone-duo-simulator 技能與 Apple 官方的 app-resizability 技能。先執行 app-resizability 的前置檢查,使用 Xcode 27.1 建置,然後完整遍歷每一種姿態並彙報視覺異常之處,在此之前不要修改任何程式碼。 更多技能、MCP 伺服器和樣機工具,詳見 iPhone Duo 開發者工具目錄。

工具鏈

必須使用 27.1 SDK 建置,不能僅靠其執行時

iOS 27.0 SDK 中完全不包含摺疊螢幕相關的 API:其介面檔案中沒有 reservedRegions、ArrangementView、onHingeChange、toolbarVerticalEdge、toolbarVerticalBehavior 和 axisBehavior。這裡的隱蔽陷阱在於,使用 27.0 SDK 建置的產物依然能夠正常啟動:27.1 執行時可以正常引導 Duo 模擬器並喚起 App。但正如 Apple 在 Prepare your app 講座中所指出的,使用 iOS 27.0 SDK 建置的 App 在內螢幕上會被限制在狀態列側邊列的左側邊緣,且各類工具列依然維持橫向排布;唯有使用 27.1 SDK 建置,App 才能真正延伸至螢幕邊緣,並將工具列垂直嵌入側邊列。由於整個過程沒有任何報錯,AI 代理極易將這種未適配的舊佈局誤判為適配成功。

使用 ls /Applications/Xcode*.app 定位 Xcode 27.1,將 DEVELOPER_DIR 指向它,並在信任建置結果前校驗 SDK。SwiftUI 的摺疊螢幕 API 位於 SwiftUICore 中,僅當 SDK 包含這些符號時,以下命令才會輸出匹配的檔案路徑:

export DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer
grep -rl --include='*.swiftinterface' reservedRegions \
  "$(xcrun --show-sdk-path -sdk iphonesimulator)/System/Library/Frameworks/SwiftUICore.framework"
xcodebuild -scheme App -destination 'platform=iOS Simulator,id=<DUO-UDID>' build

把以下關鍵事實直接告知 AI 代理,避免它在 .swiftinterface 中自行摸索拼湊:

  • @Environment(\.toolbarVerticalEdge)(型別為 HorizontalEdge?)指示系統把欄立在側邊列時停靠在哪一側(.trailing 或 .leading)。Apple 文件對它的定義很精確:它是系統在當前上下文中為豎向欄選定的首選邊緣,“無論當前是否顯示豎向欄”都有值,由語言區域和裝置決定;只有在系統從不使用豎向欄的場合才為 nil(不具備豎向欄的硬體,或使用水平欄的 Size Class 與方向,例如豎立展開的 Duo)。因此非 nil 的邊緣只說明欄會放在哪裡,並不代表它正在顯示:還要同時檢查該側的安全區域內邊距。任何檢視均可讀取該環境值,不僅限於工具列項。UIKit 中的對等屬性為 traitCollection.verticalBarEdge,SwiftUI 返回 nil 的場合它返回 .unspecified。
  • GeometryProxy.reservedRegions(kind:options:) 會返回摺痕對應的 .division(分割)區域,以及狀態列時鐘與前置鏡頭等對應的 .occlusion(遮擋)區域,每個區域均包含 frame、margins 和 isActive 屬性。這些 frame 位於所查詢檢視自身的座標空間中;在從右到左的佈局中,frame 會被映象,除非傳入 layoutDirectionBehavior: .fixed。Apple 的文件稱該方法返回與你的檢視相交的區域,但在這一版本中,頁面角落裡一個 60 pt 的小方塊拿到了整塊螢幕上的全部區域,時鐘側邊列和摺痕都在內,所以請自行用檢視的邊界逐一檢驗每個 frame。必須在實際發生佈局的閉包中讀取該屬性(即 GeometryReader 的閉包體或 onGeometryChange 變換閉包中),切勿僅在檢視的 onAppear 中讀取一次:這些區域資料需要等到首輪佈局完成後才會提供,首次求值時讀取結果為空。預設情況下該方法僅返回當前處於啟用狀態(Active)的區域:Apple 佈局篇如此說明,模擬器的表現也一致,但該方法的文件卻寫著它返回區域“無論其當前是否處於啟用狀態”。無論如何,請自行檢查 isActive。若傳入 options: .includeInactive,則裝置展平時同樣會返回摺痕資料(標為未啟用,即未來發生彎折的幾何位置);內螢幕前置鏡頭在未被相機呼叫時也會以此方式報告。UIKit 中的對等方法為 UIView.reservedRegions(kind:options:)。
  • onHingeChange 的閉包會傳入變更前後的兩個 DeviceHingeContext。在不含轉軸的普通手機上其 hinge 為 nil;存在轉軸時,hinge 則提供 status(.closed、.partiallyOpen、.fullyOpen;該列舉為帶靜態屬性的結構體,因此 switch 必須包含 default 分支)以及 angle(轉軸角度)。狀態由系統根據角度與裝置方向共同判定。UIKit 中的對等元件為 UIHingeInteraction,其 UIHinge.angle 以弧度為單位;文件還指出,角度更新的頻率與精度由系統策略決定,若只需知道開合狀態,應優先使用 status。Apple 官方的設計原則是:將轉軸角度資料留給動效與即時互動,實際佈局定位則完全由保留區域(Regions)驅動。
  • 欄相關 API 中,工具列項的 axisBehavior(.verticalPreferred) 與 .horizontalOnly,以及檢視級別的 toolbarVerticalBehavior(.disabled) 與 toolbarVerticalCompressionBehavior 均為 27.1 SDK 新增。最後一個修飾符(豎向欄篇稱之為 toolbarCompressionBehavior)決定了在側邊列縱向空間不足時,標籤列與工具列誰優先壓縮讓位。visibilityPriority 與 ToolbarOverflowMenu 早在 27.0 中就已提供。自定義檢視預設留在水平工具列中,除非顯式聲明加入側邊列。

保證 Xcode 27.0 仍可正常建置

用 Xcode 27.0 建置摺疊程式碼時,編譯器會在每個 27.1 符號上報錯,而報錯並不總能說明原因:

error: value of type 'GeometryProxy' has no member 'reservedRegions'
error: value of type 'GeometryReader<Content>' has no member 'onHingeChange'
error: cannot find 'ArrangementView' in scope
error: generic parameter 'Value' could not be inferred
error: cannot infer key path type from context; consider explicitly specifying a root type

它們的意思都一樣:這個 SDK 早於摺疊 API。最後兩條來自 @Environment(\.toolbarVerticalEdge),onHingeChange 那條裡的型別就是你呼叫它的那個檢視。用 Xcode 27.1 建置、而部署目標低於 iOS 27.1 時,報錯是熟悉的 'toolbarVerticalEdge' is only available in iOS 27.1 or newer,用 if #available(iOS 27.1, *) 即可解決。

#available(iOS 27.1, *) 無法解決編譯期問題:面對 27.0 SDK 時,reservedRegions 等符號在標頭檔案中根本不存在。#if compiler(…) 同樣無效:Xcode 27.0 與 27.1 Release Candidate 搭載的是同一個 Swift 6.4 編譯器。如果團隊成員或 CI 自動化建置環境仍在使用 Xcode 27.0,可使用預設開啟、僅在 27.0 SDK 下清除的條件編譯標誌。這樣後續所有新版 iOS SDK 無需再次修改配置即可自動包含摺疊程式碼:

// Debug 配置;Release 下該條件僅保留 FOLD_SDK,27.0 時置空為 ""
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG FOLD_SDK";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.0*]" = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.0*]" = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=macosx*]" = DEBUG;   // 僅限含 Mac Catalyst 目標的專案

最後一行只在 App 含有 Mac Catalyst 目標時才需要。Catalyst 使用 macOS SDK 建置,而 Xcode 27.1 RC 附帶的仍是 macOS 27.0,其 Catalyst 介面中沒有任何摺疊螢幕 API,因此缺少這一行時,Catalyst 建置在兩個版本的 Xcode 中都會失敗。等到某個 macOS SDK 提供這些 API 後再刪除它。原生 visionOS 目標處境相同(RC 中的 visionOS SDK 為 27.0),仍在使用的早於 27.0 的 SDK 也一樣。

將所有呼叫 27.1 符號的程式碼集中收斂在一處,外層包裹 #if FOLD_SDK,內層使用 if #available(iOS 27.1, *),fallback 分支直接返回未修改的原檢視。Flicit 設計了一個統一的狀態監聽 Modifier,向外發布單一的“摺疊階段狀態(Fold Stage)”(包含轉軸、摺痕、遮擋區域以及側邊列邊緣),App 其餘部分僅讀取該狀態。這樣一來,27.0 的成功建置即可作為“無符號洩漏”的直接證明,摺疊邏輯的單元測試也能在任何標準模擬器上直接執行。此處有一個暗坑:在 .toolbar { } 檢視建置器內部使用 if #available 依賴 buildLimitedAvailability,該特性要求 Deployment Target 為 iOS 17.5 以上。若需支援更早的系統版本,應將條件分支移至整個 .toolbar 修飾符外部。

優先執行 Apple 官方的 Agent 技能

Xcode 27.1 內建了 AI 代理技能。通過 xcrun agent skills export --output-dir xcode-skills 可匯出 10 個技能,而 xcrun agent plugin path --plugin-format claude(或 codex、gemini)則能打包包含另外 3 個技能及 Xcode MCP 服務的完整外掛。其中專為 Duo 打造的是 app-resizability。當向 AI 代理提出“為 iPhone Duo 適配我的 App”時,該技能會將其拆解為全套現代化改造任務,因為可變尺寸螢幕會將以下隱患集中暴露:替換 UIScreen.main、清理基於 interfaceOrientation 與 userInterfaceIdiom 的死板佈局判斷、遷移至多場景(Scene)生命週期,以及糾正假定左右兩側 Safe Area 對稱的程式碼缺陷。此外它還會校驗 Info.plist 的三項核心先決條件:提供 Launch Screen、支援 iPad 的全部 4 個方向,且未聲明 UIRequiresFullScreen(該技能只報告此項而絕不刪除,因為刪除後 App 會立即變為可調整尺寸)。Xcode 27.0 匯出的前代技能 uikit-app-modernization 僅針對 UIKit,對 Duo 的特性一無所知。

該技能聚焦的是系統動態調整視窗尺寸帶來的適配問題,並未涵蓋摺疊形態賦予的全新互動可能(它完全不涉及 reservedRegions、轉軸資料或 ArrangementView)。因此應先執行它完成基礎加固,再去設計各姿態下的專屬佈局。在 Flicit 中,它掃描出了 3 處問題。其中一處是嚴重的潛在 Bug:一個 Metal 檢視此前使用 UIScreen.main.scale 計算 Drawable 渲染尺寸,現已改為使用檢視自身的 traitCollection.displayScale。對於仍需支援 iOS 17 的 App,還需顯式註冊 UITraitDisplayScale 變更監聽;自 iOS 18 起,UIKit 會在已讀取的 Trait 變更時自動觸發 layoutSubviews。在檢視尚未掛載到 Window 之前該 Scale 值為 0,此時應跳過佈局計算。另外兩處則是針對功能啟用而非佈局的 userInterfaceIdiom == .phone 判斷(用於決定是否開啟 iPad 所沒有的即時動態,以及提醒功能是否預設開啟),因此予以保留。

深度研讀 Apple 的六場 Tech Talk

講座中包含了技能所不具備的深層規範,本指南統稱如下:

講座 簡稱 技能未提及的關鍵資訊
Prepare your app for iPhone Duo 準備篇 27.0 與 27.1 SDK 的差異邊界;內螢幕不會受限於你所聲明的支援方向;用於匹配螢幕圓角的 ConcentricRectangle
Raise the bar with iPhone Duo 豎向欄篇 工具列項移入側邊列的規則、axisBehavior、溢位優先順序、單按鈕 Sheet 的最佳化
Strike a pose with adaptive layouts 佈局篇 保留區域(Reserved Regions)、位移重排(Displacement)、ArrangementView
Leverage multiple displays and scenes 多工篇 轉軸 API、分割畫面(Split View)、新視窗機制、場景配件
Build a great camera experience 相機篇 虛擬前置鏡頭(Virtual Front Camera)、方向協調器
Design for iPhone Duo 設計篇 側邊列的共享元素、置頂畫中畫的避讓、各姿態下的 Sheet 行為、筆電形態佈局規範

多工篇與相機篇最容易被開發者忽略,但兩場講座均至關重要。僅有相機類 App 能夠在雙螢幕展開時在外螢幕顯示附屬檢視:CameraCaptureAccessory 要求 App 在內螢幕全螢幕執行且處於活躍的相機會話中,Apple 小組實驗室(Group Labs)補充說明這還需要特定的 Entitlement 授權。在外螢幕形態下無法喚起新視窗。相機篇介紹了虛擬前置鏡頭,它能在內外部前置鏡頭之間自動平滑切換,併為需要手動控制鏡頭的 App 提供了 AVCaptureDeviceDirectionCoordinator;它位於 AVKit,而非 AVFoundation。Flicit 的票根掃描功能使用了後置鏡頭配合 AVCaptureDevice.RotationCoordinator,據相機篇介紹,該協調器會在 App 跨螢幕移動時自動更新,因此無需任何額外適配。

用命令列開合、旋轉 iPhone Duo 模擬器並截圖

Device Hub 提供了姿態預設按鈕、隱藏式轉軸滑塊(在姿態按鈕上按住 Option 鍵)以及方向選擇器,模擬器測試清單介紹了如何手動使用它們。然而 AI 代理無法直接操作這些圖形控制元件。simctl 自身並未提供轉軸控制或螢幕旋轉命令。Xcode 27 的 devicectl 雖能列出模擬器並提供 device orientation set,但在 Duo 模擬器上,無論合上還是展開,它都會返回成功提示卻並未實際旋轉機身,而 device orientation get 不論什麼姿態都讀到 portrait。在無 UI 的 Headless 模擬器環境下,修改 XCUIDevice.shared.orientation 同樣毫無效果。

實際上,轉軸滑塊與方向選擇器各自向系統傳送了一個廠商自定義的 HID 事件(Usage Page 0xFF61,Usage 0x5B),其載荷為一個精簡的二進位制 plist。通過 simctl spawn 在模擬器內部投遞完全相同的事件,即可實現裝置的自動化開合與轉向。Artem Novichkov 開發的 hinge 工具將轉軸事件(不含螢幕方向)封裝成了 Homebrew CLI;而 Meta 的 idb 則在其 HID 底層程式碼中從 Mac 端投遞這兩種事件。

控制元件 source type value
轉軸滑塊 hinge-slider-control range 角度值,0° 為閉合,180° 為完全展平
方向選擇器 orientation-picker-control enum portrait、pud、landscape-left、landscape-right

兩種載荷均包含 provider 標識:工具包與 hinge 投遞的是 com.apple.Virtualization,idb 投遞的是 com.apple.Virtualization.VirtualMachines。工具包中的 duo_fold.c 負責底層事件分發,duo.py 在首次執行時自動完成編譯並提供外層 CLI 封裝:

python3 duo.py fold close               # 0°,閉合,使用外螢幕
python3 duo.py fold open                # 180°,完全展開,使用內螢幕
python3 duo.py fold book                # 128°,模擬器的標準書本姿態
python3 duo.py orient upright           # 機身縱向立起
python3 duo.py fold laptop              # 110°;機身縱向立起時即為筆電形態
python3 duo.py path 1.0 180 140 180     # 一次帶時間插值的連續翻折運動
python3 duo.py scene --fold laptop --orient upright --bundle com.example.app --out shots/

scene 命令會自動按順序執行:摺疊機身、等待轉軸運動平息、旋轉方向、重啟 App 並擷取螢幕。需要注意的是,方向命名沿用了裝置的原生定義,在展開態的 Duo 上極易混淆。展開狀態下的 iPhone Duo 原生方向為橫向(Landscape),因此設定 portrait 會呈現摺痕豎貫居中的橫向介面;而 landscape-left 才是將機身縱向立起、摺痕橫貫水平的形態。為避免混淆,duo.py 提供了直觀的 sideways(展開橫置)與 upright(縱向立起)兩個別名。在 iOS 27.0 執行時上,iPad mini 模擬器會直接忽略該事件,因此該指令碼不會誤影響其他模擬器。

在設定每個姿態時,必須在轉軸停止運動後再設定螢幕方向。若在轉軸運動期間投遞方向事件,事件極易被系統丟棄,因此 duo.py orient 會自動等待並連續投遞兩次。此外,裝置在翻折前後會保持上一次的方向狀態:在 Flicit 的姿態遍歷測試中,剛擷取完筆記本姿態就立即合上手機,外螢幕呈現為橫置的 678×466 狀態,系統側邊列停靠在 Leading 邊緣;緊接著切換書本姿態時,擷取到的卻是縱向立起形態。這兩種均為合法的物理姿態,但並非測試指令碼預期的目標狀態。

要從展平狀態彎折,而不是從合上狀態:模擬器的轉軸有滯後。從合上往外開啟時,App 一直留在外螢幕,轉軸一直報告 closed,直到約 147°(設定 150°);要過了 160° 才換到內螢幕。從展平往回合時,內螢幕一直亮著,轉軸報告 partiallyOpen,直到約 24°(設定 20°),只有 0° 才回到外螢幕。因此外螢幕正在出畫時,duo.py fold 會先把手機展平再彎過去;已經展開的手機則直接彎折,不會多經過一次展平。從合上狀態開始的 path 或 sweep 也有同樣的陷阱。

path 命令專為秒級連續手勢設計。若拆分為多次單獨程序呼叫,每次啟動開銷接近一秒,極易導致快速彎折動作超出 App 預設的時間視窗。

系統不提供轉軸角度的回讀機制。 hinge get 背後執行的是 xcrun devicectl device motion hinge-angle,而 Xcode 27.0(27A266a)與 27.1 RC(27A9275)自帶的 devicectl(兩者均為 651.13.5 版)都會拒絕它:“Error: 2 unexpected arguments: ‘motion’, ‘hinge-angle’”。hinge 的 README 稱其已在 Xcode 27.1 上驗證;無論那是哪個建置版本,這個版本都沒有該命令。系統的 CoreDevice 框架中已經包含轉軸角度監測相關的提示資訊,因此後續的 devicectl 可能會加入它:執行一次該命令即可知曉。模擬器內部沒有可供監聽此變更的 HID 接收器,也沒有任何 plist 記錄該角度。能讀回的是姿態:xcrun devicectl device info displays --device <udid> 會列出兩塊螢幕,LCD(外螢幕,1398×2034)和 LCD-1(內螢幕,2007×2853),各帶一個 "active" 真假值,加上 --stream 還會逐次列印變化,因此無需截圖即可分辨合上與展開。它的 currentDeviceOrientation 跟隨展開的手機,按內螢幕立起時的座標命名(橫置讀作 landscapeRight,立起讀作 portrait),但合上時無論怎麼轉都停在 portrait。必須在指令碼中記住設定的目標角度(duo.py status 會輸出最近一次設定值)。App 讀到的角度與設定值有偏差,越接近展平偏差越大:設定 110° 時 App 讀到 108.9°,128° 讀到 125.9°,175° 讀到 170.3°,只有恰好 180° 才讀到 fullyOpen。

擷取內螢幕時避免得到全黑圖片

在任何姿態下,呼叫 simctl io <udid> enumerate 列出的輸出埠中均包含兩塊螢幕:外螢幕解析度為 1398×2034 像素,內螢幕為 2007×2853 像素(按 3x 渲染倍率計算,分別對應 466×678 pt 與 669×951 pt)。閉合時 App 在外螢幕渲染,內螢幕埠擷取的畫面為純黑;雙螢幕展開時則完全相反。若執行 simctl io screenshot 時未攜帶 --display 引數,命令會預設擷取外螢幕,因此手機展開後將截獲全黑影像。

必須每次依據像素解析度動態匹配顯示埠,切勿按名稱硬編碼:埠 UUID 在每次模擬器引導後均會變動;--display=internal 只是碰巧對映至內螢幕,而 --display=external 會直接超時。在信任截圖之前務必進行校驗:休眠未啟用的螢幕輸出為約 50 KB 的全黑 PNG,而正常渲染的螢幕截圖大小為數兆位元組。duo.py shot 內部完整封裝了這一校驗邏輯:它會解碼截圖頂部的行像素,輪詢重試直到螢幕點亮,並標註具體點亮的螢幕名稱。切換姿態後應重啟 App,併為冷啟動延遲預留充足時間:Flicit 在重新安裝後的首發冷啟動耗時比常規啟動多出整整 10 秒,足以導致截圖誤落在啟動 Splash 畫面上。

Xcode 27 的 devicectl 也能按顯示器擷取,用 --display-unique-id 指定(ID 由 device info displays 列出)。它的 device capture screenshot 可以擷取任一塊螢幕,截圖已按介面方向轉正(展開橫置時為 2853×2007,而 simctl 給出的是原始的 2007×2853 幀緩衝),熄螢幕的那塊同樣是全黑。device capture screen-record 錄的是原始幀緩衝,每臺裝置一次只能錄一路:第二路會立即失敗,報“Host recording is already in progress”。這些 ID 同樣在每次啟動後變化。有 active 標記在旁,device info displays 是找出正在出畫那塊螢幕最快的辦法。

錄製翻折過程的影片

靜態截圖反映最終落地的佈局形態,動態螢幕錄影才能還原佈局過渡過程。simctl io <udid> recordVideo --display=<埠 UUID> 可錄製指定螢幕,但在 Duo 上有兩個非直觀的特性:其一,單臺模擬器在同一時刻僅支援錄製一塊螢幕,若併發開啟第二個 recordVideo 錄製任務,程序雖持續執行,但輸出檔案永遠無法正常封包結束;其二,螢幕錄影輸出不會跟隨顯示焦點自動切換,當前螢幕熄滅後它會持續錄製黑螢幕。因此正確的做法是:優先錄製當前活躍螢幕,待機身翻折點亮另一塊螢幕幕後再動態切換錄製流。duo.py record 接受一系列階段指令並自動處理切流:

python3 duo.py record --out films/ path:1.2:0:180 rec:inner wait:4          # 展開機身,切換錄製內螢幕
python3 duo.py record --out films/ path:2:180:128 wait:2.5 path:2:128:180   # 摺疊至書本形態並重新展平

影片幀僅在螢幕內容發生變化時按需產出,幀間隔並不均勻。匯出影片幀時必須採用直通模式:ffmpeg -i film.mp4 -fps_mode passthrough frames/%04d.jpg;切勿使用固定 fps 濾鏡強制重取樣,否則會生成大量重複舊幀,偽造出介面掉幀卡頓的假象。

在 Flicit 的實測螢幕錄影中,絕大多數過渡動畫均由系統底層接管。在開合過程中,系統會生成一張模糊的高斯模糊快照遮蓋雙螢幕切換過程;在彎折機身時,模擬器會自動將傾斜背離視線的那一側摺頁做暗化與模糊處理。在其掩護下,App 會在單幀內瞬時重排至新佈局。唯一能被肉眼察覺屬於 App 自身的動態突變,發生在彎折瞬間的二段重排:當摺痕區域變為啟用態時,票根列表列突兀跳動對齊至摺痕,而在完全展平時又跳回原位。任何等待摺痕變為 Active 狀態才介入排版的雙欄佈局都會暴露這一視覺瑕疵;具體解決方案見第 7 節。

一次螢幕錄影包含數百幀,而瑕疵往往只集中在極少數幾幀。以毫秒時間為各幀命名,逐幀與前一幀比對,並優先檢視差異最大的幀:硬切是一次巨大的單幀跳變,淡入淡出是一連串微小變化,而空白幀在兩端都會顯著突出。

ffmpeg -i films/laptop-inner.mp4 -fps_mode passthrough -enc_time_base 1/1000 -frame_pts 1 frames/%06d.png
prev=; for f in frames/*.png; do
  [ -n "$prev" ] && echo "$f $(compare -metric RMSE "$prev" "$f" null: 2>&1)"; prev=$f
done | sort -t'(' -k2 -g -r | head

修改前後均錄製對比影片:第 7 節中筆記本姿態的螢幕錄影發現了一處硬切所掩蓋的佔位圖。在模擬器中,摺痕在轉軸停止大約一秒後才變為啟用狀態,因此彎折觸發的佈局切換顯示在彎折之後,而非彎折途中。旋轉後,保留區域的更新甚至耗時更長:在認定最後一幀是 Bug 之前,務必給螢幕錄影留出足夠的等待尾部。

旋轉時 App 卡死的排查

無法收斂穩定的死迴圈佈局不會立即引發崩潰。介面會停滯在舊佈局上不再響應。只有當 App 被系統殺死(Watchdog 超時強殺,或人工強制退出)後,才會生成崩潰日誌,初看像是 Dispatch Queue 裡的崩潰。Flicit 僅在特定旋轉時暴露了該問題:從橫置轉為豎立時卡死,反向則正常。排查鏈路如下:

  1. 崩潰報告:模擬器的報告位於 Mac 端的 ~/Library/Logs/DiagnosticReports/<App>-<日期>.ips。Flicit 的報告指明異常碼為 0x8BADF00D,即系統 Watchdog 強殺:“scene-update watchdog transgression … exhausted real (wall clock) time allowance of 5.00 seconds”,強制退出後則記錄為 “Failed to terminate gracefully”。主執行緒呼叫棧停留在 UIKit 的螢幕旋轉流程中:-[UIViewController window:willAnimateRotationToInterfaceOrientation:duration:newSize:] → -[UINavigationController _layoutViewController:] → _UIHostingView.layoutSubviews(),正在渲染 SwiftUI 檢視樹。這證明是一輪耗盡時間配額且無法返回的死迴圈佈局計算,而非多執行緒死鎖。
  2. 對卡死程序即時取樣:使用 duo.py orient upright 復現卡頓,隨後對模擬器中的 App 程序執行 sample <pid> 3。主執行緒抓取到的所有堆疊均深陷在同一個 ViewGraphRootValueUpdater.render 之中,下掛 GraphHost.flushTransactions 以及不斷增長的動畫事務佇列:表明 SwiftUI 正在單次佈局輪次中瘋狂提交狀態變更並立即觸發重繪。
  3. 捕獲 SwiftUI 內部日誌:通過 simctl spawn <udid> log show --info --debug --predicate 'subsystem BEGINSWITH "com.apple.SwiftUI"' 捕獲底層錯誤,會打印出諸如 “… tried to update multiple times per frame” 等故障資訊,並直接指出涉事的 Binding。在嫌疑檢視的 body 中加入 Self._logChanges()(iOS 17.1+,更早版本需包裹在可用性檢查中),每次 body 求值時均會列印變更的屬性。Flicit 的雙欄檢視在 30 秒內列印了 2,450 次 __frame changed。
  4. 具體數值印證:在涉事元件的幾何變更回撥中插入 Logger 記錄,結果顯示其 Frame 正在兩個固定寬度之間無限交替震盪。死迴圈成因與修復詳見第 9 節。

僅憑 CPU 佔用率無法確診問題:App 在全新安裝後的首次啟動期間,可能因建置各類系統快取而長時間佔滿 100% CPU,因此務必先採樣驗證,再歸咎於佈局邏輯。

分割畫面與畫中畫的視窗尺寸

在 Headless 無頭自動化環境下,Duo 上有兩種視窗形態無法直接觸發。分割畫面(Split View)依賴從展開螢幕底部的 Home Indicator 向上拖拽手勢;而置頂在頂部的畫中畫(PiP)則要求系統中存在實際播放中的影片源——它會動態壓縮 App 的垂直高度,且一旦手機摺疊即霸佔半塊螢幕。能夠模擬觸控的 Xcode 工具在手機展開時又僅能捕獲外螢幕(見第 4 節)。devicectl device appResize 只能走到一半。手機展開過一次之後,Duo 會多列出第三塊顯示器 Resizable,appResize start --preferred-size <W>x<H> 會把最前端的 App 移到上面,appResize set 可以即時調整尺寸(最大 1280×1280),device capture screenshot 也能擷取它。它要求裝置已解鎖,並處於 devicectl 所說的 portrait 方向:橫置時它會拒絕(“Rotate the device to portrait”);展開立起時,內螢幕上的 App 被調暗,Resizable 顯示器截到的是全黑;合上時則正常工作。Flicit 按要求呈現為 475×669 和 669×455,要求 380×600 時則成了 380×667。但那只是一個普通的可調整尺寸視窗,沒有側邊列、摺痕,也沒有 Duo 的安全區(Flicit 把標籤列放在了底部),所以它檢驗的是尺寸,而不是 Duo 的視窗。這些視窗應改用主動渲染的方式覆蓋:在單元測試中,將待測頁面置於 UIWindow 內的 UIHostingController 中,動態調整 Window 尺寸遍歷所有邊界規格。依據螢幕物理 pt 尺寸估算,主要尺寸涵蓋:

  • 橫置展開:951×669;
  • 置頂影片擠壓:951×420;
  • 分割畫面半螢幕(Compact 寬度):475×669;
  • 縱向立起展開:669×951;
  • 筆記本姿態立起上半螢幕被影片佔用:669×455。

陷入尺寸測量死迴圈的佈局在此測試中將永遠無法返回。Flicit 的頁面在全部上述尺寸下均實現了快速收斂穩定,並確保開啟的票根與未儲存的影評在尺寸切換中完好保留。在兩種垂直高度高度受限的緊湊視窗中,縱向票根的尺寸直接受制於可用高度,這正是筆記本姿態橫向票根票樣設計所專門解決的場景。

切勿執行 simctl io screenConfig

嘗試使用 simctl io screenConfig 喚醒或配置內螢幕是極易誘發災難的操作。在 Duo 模擬器上,執行 power on 與 geometry 不會產生任何實際效果,但接下來的下一次轉軸事件會直接引發 SpringBoard、InputUI 與 chronod 在同一渲染幀內發生級聯 SIGTRAP 崩潰:

EXC_BREAKPOINT (SIGTRAP), main thread
FrontBoardServices -[FBSDisplayMode _initWithWidth:height:…validityCheck:]
FrontBoardServices -[FBSDisplayConfiguration _initWithImmutableDisplay:…]
QuartzCore CA::Display::Display::update()     ← ensure_displays()

此後崩潰會無限迴圈,直至 launchd 放棄重新拉起程序;此時 App 自身雖未崩潰但系統已徹底假死。唯一的恢復手段是對該裝置 UDID 依次執行 simctl shutdown 與 simctl boot。務必將此禁令加入 AI 代理的常駐指令集(工具包的 SKILL.md 已收錄),切勿僅寫在普通的原始碼註釋中。

其他能驅動 Duo 模擬器的工具

本工具包並不是唯一的途徑。下面是這些工具在 Xcode 27.1 上能做什麼,短橫表示工具自己沒有說明:

工具 開合 旋轉 點按展開的手機 說明
duoctl 能 能 能 本文於 10月11日核驗過(見第 4 節);它讀回的轉軸角度為 null,與 hinge get 相同
hinge 能 不能 不能 通過 Homebrew 安裝的命令列工具兼 AI 代理技能,負責轉軸事件;hinge get 在 651.13.5 版 devicectl 上失敗
serve-sim 能 – – 在瀏覽器中呈現模擬器,據其 README,為 Duo 提供開合按鈕和轉軸滑塊
Appium XCUITest driver 能 – – mobile: setSimulatedHingeAngle,10月8日合併;它不會等待佈局穩定
idb 能 能 – 從 Mac 端投遞兩種 HID 事件
Maestro 不能 不能 – Duo 姿態支援仍是一個未關閉的需求

點按與讀取介面:Xcode 的 MCP 工具與 duoctl

靜態截圖負責檢驗排版,業務流測試則依賴點按互動。Xcode 通過 xcrun mcpbridge 對外暴露了一套 MCP 工具,包括 DeviceInteractionStartSession、DeviceInteractionSynthesize 與 DeviceInteractionEndSession,並配備了專門的 device-interaction 技能文件。Synthesize 每次執行一條原子操作,並返回即時截圖及攜帶每個元素可點選座標(Hit Point)的 UI 檢視樹。支援的操作包括單點(t x y)、雙擊、滑動(t x1 y1 f x2 y2 秒數)、拖拽、多點觸控、物理硬體按鍵、鍵盤輸入、等待延時以及 orientation 旋轉。執行前 Xcode 必須處於開啟狀態,首次建立握手時會彈出一次系統授權彈窗。工具包中的 xcode_device.py 便是針對這三個工具的輕量客戶端:

export DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer
python3 xcode_device.py start --device <DUO-UDID>   # 傳入確切 UDID,避免拉起其他裝置
python3 xcode_device.py look                        # 截圖、讀取 UI 樹結構及可互動元素列表
python3 xcode_device.py tap "Settings"              # 根據 Accessibility Label 精確點按
python3 xcode_device.py do "t 200 560 f 200 260 0.4"
python3 xcode_device.py end                         # 互動 Session 開銷昂貴,用完務必及時關閉

需要特別注意的是,在 iPhone Duo 上,Xcode 預設固定通過外螢幕讀取裝置狀態:

  • 閉合形態下完全正常:能夠輸出真實的 466×678 截圖,UI 層級完整標註 Accessibility Label 與點選座標,點按與手勢滑動均能精準落地。
  • 雙螢幕展開後端口錯位:截圖呈現為全黑的外螢幕;UI 檢視樹會粗暴剔除所有超出外螢幕物理寬度的元素(在橫置展開頁面中,位於側邊列內的全部內容均被丟棄);觸控指令無法生效。呼叫 orientation landscapeLeft 同樣無法驅動展開態裝置旋轉。
  • 不支援轉軸指令:投遞 hinge 與 fold 均會返回“Invalid command”。

因此兩套工具必須協同分工:duo.py 負責驅動各姿態下的機身開合、旋轉與螢幕捕獲;xcode_device.py 專職負責在手機閉合狀態下模擬點按互動並審查層級。測試完整業務流時,先在閉合形態下完成互動鏈路,再呼叫 duo.py 展開機身,驗證展開後遺留的狀態與佈局。此處存在一個典型時序暗坑:在動畫進行期間讀取到的 UI 樹包含過期的座標資料(Stale Hit Points)。在 Flicit 測試中,搜尋欄尚在向上滑動動畫過程中,指令碼便讀取了檢視樹,導致針對關閉按鈕的點選偏落在按鈕下方,僅造成了輸入框失去焦點。在重試操作前,必須重新拉取一次檢視樹。

展開的手機可以交給 duoctl。它在模擬器內部把正在出畫那塊螢幕的觸控式螢幕克隆為一個虛擬 HID 服務,再經由它傳送觸控,座標用的是介面的點。10月11日核驗:duoctl tap 908 488 在展開手機的側邊列裡把 Flicit 切換到了日曆頁,而 Xcode 的點按在那裡落不下去;duoctl rotate portrait 把展開的手機立了起來;duoctl state 報出了正在出畫的螢幕及其尺寸。它的 hingeAngle 讀回為 null,因為它讀取角度用的也是那條不存在的 devicectl 命令。只有裝了 AXe 工具,它才能按標籤查詢元素;否則就從截圖中取座標:devicectl 的截圖是介面的點乘以 3。所以,合上時用 Xcode 的工具及其檢視樹走一遍流程,展開時用 duoctl 和截圖。

UI 樹同時也是精準的度量審查工具。為驗證展開搜尋欄不會導致底層整個頁面重新建置,AI 代理先將 Flicit 的“我的”頁面向下滾動,點按側邊列內的搜尋按鈕並再次關閉,隨後比對三次抓取的檢視樹中“Viewing Count”(觀影統計)標題的絕對座標:座標分毫不差,證明底層 ScrollView 的滾動偏移完好保持,未被重建。

安全區、摺痕與保留區域的度量數值(單位:pt)

以下資料均在遵循 Safe Area 安全區域的檢視內部測得,保留區域的 frame 已從該檢視的座標換算到視窗座標。10月11日,一個最簡的 SwiftUI 探測 App 把每一行重新測了一遍,數值完全相同:

姿態 視窗解析度 安全區域內邊距(Insets) 活躍遮擋區域(Active Occlusions) 摺痕區域(Crease) 工具列停靠邊緣(Bar Edge)
閉合 466×678 trailing 84,bottom 34 時鐘列 (382, 0, 84×170);前置鏡頭 (399.7, 29.3, 37×37) 無 trailing
展開,橫置,平放 951×669 trailing 84,bottom 34 時鐘列 (867, 0, 84×120) 無 trailing
展開,橫置,128°(書本) 951×669 trailing 84,bottom 34 時鐘列 (867, 0, 84×120) (455.5, 0, 40×669) trailing
展開,縱向,平放 669×951 top 82,bottom 34 狀態區 (535, 0, 134×82) 無 nil
展開,縱向,110°(筆記本) 669×951 top 82,bottom 34 狀態區 (535, 0, 134×82) (0, 455.5, 669×40) nil

由上述實測資料可得出以下設計準則:

  • 側邊列貫穿螢幕的全高,但系統常駐系統級元素在展開時僅佔頂部 120pt,閉合時佔 170pt。其下方區域完全預留給系統工具列擺放;若 App 未聲明任何工具列項,該區域將保持空白。
  • 排除內邊距後,可用頁面寬度在閉合時為 382pt,橫置展開時為 867pt。在這一建置版本中,當寬度低於約 800pt 時,NavigationSplitView 會放棄將列表與詳情並排展示,因此縱向展開狀態(669pt)以及直向下的 iPad mini(744pt)必須採取針對性的單欄收攏策略(見第 7 節)。
  • 縱向立起展開時,裝置形態等同於一臺直向的小尺寸 iPad:頂底各有一條水平工具列,頂部附帶 82pt 的狀態列區域,可用內容寬度為 669pt。準備篇講座特別指出,內螢幕不會受限於你所聲明的支援方向,因此切勿依據裝置的物理方向(Interface Orientation)來做硬編碼佈局。
  • 摺痕寬度為 40pt,物理座標精準位於視窗幾何中心,且僅在半折狀態下處於啟用狀態(Active)。切勿將任何互動或邏輯與摺痕被啟用的具體臨界角度進行強繫結。
  • 外螢幕同樣支援旋轉。在旋轉方向未復位的情況下合上機身(例如剛剛退出縱向立起形態),外螢幕會呈現為橫向的 678×466 尺寸,系統側邊列也會移至 Leading 邊緣,時鐘佔據側邊列頂部 82 pt。若佈局程式碼假定外螢幕永遠是 382pt 寬且高大於寬,便會遭遇完全相反的容器尺寸。

若在查詢時傳入 .includeInactive,還會額外捕獲三組基於視窗座標的關鍵讀數:

  • 在裝置平放狀態下,摺痕作為未啟用區域(Inactive)返回,其幾何座標與啟用態摺痕完全一致:橫置時為 (455.5, 0, 40×669),縱向立起時為 (0, 455.5, 669×40),閉合時無此區域。Apple 官方文件曾將其描述為零寬度,因此程式碼中必須同時相容 0 與 40 兩種寬度情況。
  • 內螢幕前置鏡頭作為一個未啟用的遮擋區域返回,橫置時座標為 (677.3, 21, 58×37),縱向時為 (21, 215.7, 37×58)。僅當相機會話實際呼叫該鏡頭時,它才會切換為活躍遮擋並要求避讓。
  • 在機身旋轉或開合瞬間,會有單幀過渡佈局攜帶上一個姿態的歷史區域資料進入新尺寸:例如縱向頁面暫時攜帶了橫置態的摺痕,或外螢幕暫時保留了展開態的區域。在使用區域資料前,務必先結合當前頁面本身的寬高比進行合法性校驗。

度量程式碼中的一大常見陷阱是:顯式忽略了安全區域的 GeometryReader 會報告零邊距,因為安全區域已被其主動丟棄。

豎向欄的側邊列與安全區邊距

在手機閉合以及橫置展開狀態下,系統會將工具列垂直嵌入螢幕側邊的一列中(Apple 官方規範詳見豎向欄指南)。頁面最終是呈現出“專為側邊列精心定製”,還是“被側邊列粗暴推向一邊”,完全取決於對以下規則的處理。

自定義標籤列應直接嵌入側邊列

Apple 在豎向欄篇中提到:系統原生工具列會自動移入側邊列,而開發者自行繪製的工具列則會保持在原位。若機械執行此原則,自定義的懸浮標籤列依然會橫臥在頁面底部,緊鄰一條 84pt 寬、僅在頂端頂著一個時鐘的狹長空白側邊列。這會導致所有頁面整體向左嚴重偏斜。Flicit 第一輪迭代正是如此,開發者的第一直覺回饋便是“所有頁面看起來都被粗暴推擠到了左側”。

若直接將自定義標籤列順時針旋轉貼在頁面邊緣同樣糟糕:若貼在 Trailing 邊緣會與時鐘發生重疊,若貼在 Leading 邊緣則會遮擋核心正文。真正優雅的解法是在系統側邊列的內部(位於時鐘下方)建置一條立式導軌(SideRail):正如設計篇所展示的,系統自身的標籤列同樣停靠在側邊列底端。首發合作夥伴亦採取了相同方案:在展開裝置上,Slack 將其標籤項與搜尋按鈕全部垂直立在側邊列底部。

兩臺合上的 iPhone Duo 顯示 Flicit 的票夾,最上面是《F1:狂飆飛車》《伯德小姐》《火山摯戀》三張票根:左邊的標籤列浮在頁面底部,旁邊那一列在時鐘下方空著;右邊的標籤印在一條豎起的紙條上,放在這一列裡,位於時鐘下方
閉合形態前後的設計對比。懸浮標籤列導致頁面側方留出一條空曠的側邊列;將其嵌入側邊列後,標籤項自然融入為全域主導航,頁面底部也完整歸還給了內容區域。圖片:Flicit
// 位於根檢視中,與原有的水平懸浮標籤列並列。僅限 27.1 SDK。
@Environment(\.toolbarVerticalEdge) private var barEdge

.overlay {
    if let barEdge, columnWidth > 0 {               // columnWidth:barEdge 邊緣對應的 Safe Area Inset
        SideRail(selection: $tab, search: expandSearch)
            .frame(width: columnWidth)
            .padding(.bottom, 12)
            .frame(maxWidth: .infinity, maxHeight: .infinity,
                   alignment: barEdge == .leading ? .bottomLeading : .bottomTrailing)
            .ignoresSafeArea(.container, edges: barEdge == .leading ? .leading : .trailing)
    }
}
  • 動態讀取邊緣屬性,切勿寫死為 .trailing。在分割畫面(Split View)下,App 控制元件會移至靠外的一側,對於左側 App 而言該側正是 Leading 邊緣。豎向欄篇還指出,在從右到左的語言中,側邊列仍留在裝置的同一側,而那一側此時是 Leading 邊緣。而在縱向立起時該屬性為 nil,此時自動切回水平懸浮標籤列。
  • 依據該側的 Safe Area Inset 動態確定 SideRail 寬度。邊緣只說明欄會放在哪裡,若該側沒有內邊距,就沒有側邊列,也不應顯示導軌。導軌僅在該停靠邊緣忽略安全區域,頁面主體繼續保持正常的安全內邊距。
  • 當 SideRail 顯示時,從各子頁面的滾動邊距(Scroll Margin)中扣除原底部懸浮欄的高度。若搜尋入口整合在標籤列中,開啟搜尋時直接在頁面上方浮現搜尋欄,避免改動頁面的邊距(防止觸發第 9 節所述的檢視標識突變)。
  • 確保 SideRail 聲明忽略鍵盤安全區(.ignoresSafeArea(.keyboard))。否則鍵盤升起時導軌會被一路向上推擠,直接頂入時鐘遮擋區。
  • 當處於需要隱藏主導航的場景(例如全螢幕詳情頁或多選底部操作欄)時,同步隱藏 SideRail。
  • 將 SideRail 設計為原懸浮標籤列的立向翻折形態,而非另起一套突兀設計。Flicit 的標籤列是一張長條電影票根:各標籤整齊印在紙條上,中間開有齒孔半月槽,副券延伸出搜尋入口。嵌入側邊列後,同一張票根順勢立起,副券停靠在底端,在手機開合旋轉時導航僅發生空間平移,其物理隱喻始終保持高度統一。
  • 逐一審查仍保留系統導覽列的下級頁面。僅包含圖示符號的 ToolbarItem 會自動移入側邊列,極易與自定義 SideRail 發生重疊衝突;自定義 View 預設留在原處,除非主動聲明進入(見下文)。

與系統共用側邊列

側邊列並非由 App 獨佔。Apple 設計篇明確列出了該區域的共享常駐元件:狀態列以及動態島(Dynamic Island)——動態島會隨即時動態的增多沿側邊列持續向下膨脹。當系統原生工具列在該列中空間告急時,會將多餘項收進溢位選單。自定義工具列無法加入該系統級溢位選單,因此必須具備自適應避讓能力。Flicit 的 SideRail 原先採用固定 274pt 高度(5 個 54pt 單元格加頂部 4pt 邊線),錨定在底部內邊距上方 12pt 處,完全忽視了上方懸掛的元素。如今它能即時讀取當前側邊列覆蓋的 Active Occlusions(時鐘區、鏡頭、動態島),並自動將 5 個單元格排布在最低遮擋線下方:每個單元格從 54pt 動態向下壓縮至最小 44pt 觸控目標。若依舊無法容納,則整條 SideRail 優雅降級,主動讓位給原有的底部懸浮欄。

// chrome:停靠側邊列上所有活躍遮擋區域的底邊全域 Y 座標
let available = pageBottom - spacing - chrome - spacing
let cell = ((available - lip) / 5).rounded(.down)
let railCell: CGFloat? = cell >= 44 ? min(54, cell) : nil     // nil:降級喚回底部懸浮標籤列

在模擬器環境下無法直接觀測動態島的膨脹:前臺 App 永遠無法在動態島中看到自身的即時動態,而在無 UI 自動化測試中又無法直接拉起其他 App 的活動。Flicit 在 Debug 建置中注入了 -columnChromeHeight <pt> 引數模擬膨脹的動態島:閉合態下傳入 380pt 可將單元格壓縮至 47pt,而在旋轉外螢幕傳入 200pt 或展開態傳入 400pt 時,會成功觸發降級並喚回底部懸浮欄。

自定義工具列項進入側邊列

在 Apple 豎向欄規範中,工具列的純圖示專案會自動移入側邊列,純文本專案保持在水平工具列中,而自定義 View 預設停留在水平欄中,除非顯式配置 axisBehavior(.verticalPreferred) 並針對縱向軸線完成佈局。在分欄檢視(Split View)中,僅有詳情欄(Detail)的工具項會移入側邊列,其餘列保持水平。Flicit 的工具列均為自定義檢視(紙質返回按鈕、關閉圓盤以及印在裁切紙片上的四個功能圖示),因此在外螢幕詳情頁頂部始終橫亙著一條欄,緊鄰著只放著時鐘的空曠側邊列。如今在主 SideRail 收起的前提下,詳情頁通過聲明主動讓返回按鈕與功能紙條移入側邊列並垂直排布。當 SideRail 處於顯示狀態時,它們重新設為 .horizontalOnly,避免與主導航重疊。這一最佳化使外螢幕下的票根詳情直接向上延伸了一個工具列的高度。

ToolbarItem(placement: .topBarTrailing) { strip }      // 嵌入側邊列時為 VStack,置頂時為 HStack
    .axisBehavior(railIsAway ? .verticalPreferred : .horizontalOnly)

模態彈出頁(Sheet)同樣遵循此規則。閉合狀態下,Sheet 的工具列會收編為立式工具列;展開時 Sheet 居中並採用常規水平欄;在書本形態下系統會自動將其從摺痕中縫滑移至某一側摺頁。若 Sheet 工具列中僅包含單一的“關閉”或“完成”按鈕,Apple 推薦聲明 toolbarVerticalBehavior(.disabled)(UIKit 中為 preferredVerticalBarBehavior),避免僅為一個按鈕便剝奪整整一列寬度。其文件要求把它當作穩定的選擇:不要隨檢視狀態切換。聲明後 Sheet 會直接向上延伸至前置鏡頭下沿,狀態列也會自動避讓。Flicit 針對其 10 個此類 Sheet 均啟用了該最佳化;搜尋 Sheet 的結果流此前因避讓側邊列被截斷,如今完全用滿了外螢幕完整的 466pt 寬度。

什麼不屬於真正的適配

以下幾種做法看似視覺對齊,實則粗暴違背了硬體與人機工程學約束:

  • 為時鐘無端頂出一條全域橫帶。 將頁面頂部 Header 人為墊高到時鐘區域的高度,雖然能防止列表卡片與時鐘重疊,但卻導致全站所有頁面無端下沉 170pt(閉合)或 120pt(展開),而時鐘在物理上僅存在於一側邊緣。
  • 讓常規圖文直接穿透到側邊列下方。 在根檢視上聲明 ignoresSafeArea(.trailing) 毫無效果,因為 UIKit 導航宿主會強制重讀安全區域。若在子頁面內部強行聲明,海報圖片與序列號會直接被壓在側邊列下方,形成嚴重的文字截斷。背景照片是唯一的例外:全螢幕背景圖連同漸變暗化層可以安全貫穿到側邊列底部,但正文排版必須嚴格留在安全區域以內。
  • 在 Leading 側人為對稱映象一條空列。 為追求視覺上的居中而人為在左側新增 84pt 空白,會導致閉合態下的卡片內容被擠壓至文字嚴重摺行,在展開態下更會導致雙欄佈局坍縮為單欄。

窄內容在整螢幕物理玻璃上居中

僅在 Safe Area 內邊距中居中的頁面,其視覺重心相較於整塊物理玻璃的中線會向左偏離半個側邊列的寬度。當頁面鋪滿內容時該偏移並不明顯;但當內容受限時便十分扎眼(例如限制行寬的設定頁、年度報告頁,或外螢幕上孤立的一張卡片)。Apple 設計篇允許非滾動的強視覺頁面以整塊螢幕為基準居中,前提是側邊列內的控制元件不會遮擋任何互動元素。Flicit 將此設計延伸至可滾動頁面的窄欄佈局中,但施加了嚴格約束:位移嚴格受限於頁面自身的留白邊距(Margin),確保內容永遠不會穿透到側邊列下方;且該規則僅適用於佔據絕大部分視窗的主頁面,絕不可濫用於列表旁邊的詳情窗格中:

// page:ScrollView 全域 Frame;column:內容限定寬度
let glassMidX = (stage.contentOriginX + stage.contentWidth + stage.trailingInset) / 2
let margin = max(0, (page.width - column) / 2)
let shift = min(margin, max(-margin, glassMidX - page.midX))
content.offset(x: shift)        // principal 工具列標題同步平移,確保與正文垂直對齊

在 Flicit 中,橫置展開狀態下的年度回顧頁及其標題因此向物理中心平移了 42pt。

針對姿態重新佈局,而非無腦拉伸手機介面

確保每個頁面能夠正常渲染是第一輪的工作;第二輪的重心是重塑體驗:在橫向展開、平板級縱向展開以及翻折形態下,每個頁面應當呈現何種最合理的形態。

Apple 設計篇建議針對 Regular 與 Compact 兩種 Size Class 進行設計,避免寫死絕對寬度與斷點。但在雙螢幕展開的 Duo 上,僅靠這一條準則遠遠不夠:無論橫置還是縱向立起,內螢幕在水平和垂直方向上均為 Regular 類別,因此單純依靠 Size Class 無法區分跨頁與單欄。Apple 官方的自適應容器同樣綜合讀取了寬高比與 Division 分割區域,Flicit 亦遵循此邏輯:它即時研判頁面是否寬大於高,以及中縫摺痕帶的幾何走向。它所保留的少數幾個絕對閾值(跨頁採用 600pt、Split View 停止分欄的約 800pt、超過 160pt 的內邊距視為獨立分欄),描述的均為內容本身的固有屬性(如容納兩個手機寬度的分欄,或列表加詳情),而非針對某塊特定硬體螢幕。其中 800 是需要留意的例外:它是這一建置版本的 NavigationSplitView 放棄並排顯示的寬度,並非 Apple 公佈的數字,因此每換一個 SDK 都要重新測量。

橫向展開:雙欄跨頁,以及貼合內容的卡片

展開的 iPhone Duo 上 Flicit 的“我的”頁面:左邊是 2026 年的各項統計數字,旁邊是全年的座位圖,圖下是“年度報告”按鈕,再往下是一排海報,標籤豎排在右側邊緣的一列裡
橫置展開時,“我的”頁面呈現為完整的雙欄跨頁:左側是全年的觀影核心資料,緊鄰著全年的影廳座位圖及下方的年度回顧入口;下方則是票夾收藏與兩組圖表並列呈現。圖片:Flicit

當可用寬度拉大至 867pt 時,簡單的單欄佈局會淪為滑稽的“巨型手機”:單張圖表佔據整螢幕,核心統計資料被推至下方極遠的滾動區域。一旦內容寬度超過單臺手機(Flicit 閾值定為 600pt),必須重新排布為跨頁分欄:左側資料、右側圖形,下方輔以並排子窗格。隨後應著力排查因頁面變寬而被不當拉伸的細節:

  • 拒絕無意義的等高拉伸。 Flicit 的熱力圖卡片原先被強制拉伸至與旁邊的資料面板等高,導致月檢視中的三排座位孤零零懸浮在巨幅空白卡片中。重構後卡片緊密貼合內容尺寸,並將資料下方的連結移至熱力圖下方,在視覺上形成平衡。
  • 為自適應拉伸元素設上限。 選座熱力圖在達到手機單螢幕標準尺寸後即停止放大,確保影廳在加寬的卡片中始終保持真實的劇場比例。
  • 壓縮手機上因空間不足而人為做高的圖表。 手機上兩組原高 160pt 的圖表及頂部的篩選晶片,重構為 104pt 的緊湊圖表並將篩選器收納進小節標題中;橫跨 10 個半星檔位的折線圖(平線居多)被替換為緊貼均值顯示的柱狀分佈圖。
  • 狀態維持必須脫離佈局之外。 在某一種形態下做出的互動選擇(例如熱力圖選擇全年還是單月),在裝置開合重排後必須無縫繼承。
  • 嚴禁讓副窗格處於空置狀態。 使用者在列表頁展開雙螢幕時,最先映入眼簾的就是列表右側空空如也的詳情頁。Flicit 原先顯示一幅靜態插圖與提示文案;如今直接在右側鋪開最新的三張票根,各自渲染對應的主題票樣,點選即可直接檢視。左側列表保持原狀,右側摺頁承載更多收藏內容,而非冷冰冰地要求使用者做出選擇。
  • 並排小節採用統一的標題基準高度。 附帶操作控制元件的標題高度往往高於純文本標題:Flicit 的觀影圖表標題包含“年/月/周”分段控制器(44pt 觸控高度),而評分圖表僅有文本。兩者並排時會導致兩側基準線錯落,卡片拉伸後多出奇怪的空白地臺。如今儀表盤上所有並排小節的標題最小高度統一設定為 44pt。
展開的 iPhone Duo 上 Flicit 的票夾:左頁是票根列表,右頁在“最近的票根”標題下鋪開最新的三張票根,各按自己的主題印製,標籤在右邊緣豎排成一列
停在票夾、還沒選中任何票根時,右側頁面直接鋪開最新的三張票根,各按自己的主題印製,點一下就能開啟,不用先做選擇。圖片:Flicit

豎版海報獨佔左半螢幕,而非截斷做成橫幅

手機頁面通常喜歡在頂部放置一張貫穿通欄的焦點圖。Flicit 電影詳情頁原先正是如此:海報鋪滿整個寬度、頂端對齊、高 560pt,片名壓在海報下沿。在直向手機上這能呈現海報的大部;然而在橫置展開的 951pt 巨幅螢幕上,海報僅能露出頂部的 1/4。通欄橫幅越寬,豎版縱向圖片被嚴重裁切的慘狀就越甚。

真正的解法是讓海報獨佔一扇摺頁(Leaf)。在橫置展開態下,Duo 的左半扇螢幕尺寸為 455×669pt,其比例幾乎完美契合電影海報經典的 2:3 縱橫比。因此 Flicit 的海報現在頂天立地完整立在左摺頁上,片名壓在底部;而演職員表、電影簡介與票根動作則全部收攏在右摺頁內,自摺痕右側平滑滾動。這正是 Vidit Bhargava 在 Movie Buzz 上的做法,且該佈局能直接無縫繼承至書本形態。在其他常規螢幕上,該摺頁寬度則動態計算為契合海報比例的自適應寬度。

// page:檢視在 Safe Area 內部的全域 Frame
guard page.width >= 560, page.width > page.height * 1.15 else { return nil }   // 橫置展開狀態
if let crease = creaseBand,                                    // 活躍摺痕,或平放時未來的摺痕中縫
   crease.lowerBound > page.minX + 320, crease.upperBound < page.maxX - 320 {   // 兩側各保留至少一部手機的寬度
    return (leaf: crease.lowerBound - page.minX, gutter: crease.upperBound - crease.lowerBound)
}
return (leaf: min(glassHeight * 2 / 3, page.width * 0.48), gutter: 0)

該準則同樣適用於橫向外螢幕與橫持的普通 iPhone。而在縱向立起的平板級寬度下(縱向展開的 Duo 或直向 iPad mini),頁面收攏為單欄,頂圖橫幅沿海報比例自然拉高(最高可佔螢幕全高的 3/4),確保下方依然能露出正文簡介。

當從票根直接喚起該頁面時,該檢視會巢狀在 Split View 的 Detail 欄中,緊鄰左側列表。此時該列的 Leading Inset 是整條列表的完整寬度(455pt),盲目下穿到 Inset 下方的海報會被完全遮擋在不透明的列表下方。如今頂圖僅貫穿至視窗物理邊緣與側邊列,絕不穿透至任何大於常規工具列寬度的內邊距(Flicit 判定閾值為 160pt),而正文排版嚴格始於詳情列 Leading 邊緣對應的摺痕線之後。

縱向立起:收為單欄,而非拆為兩條侷促窄欄

縱向立起時,展開內螢幕尺寸為 669×951pt。此時極易產生將其繼續拆分為左右雙欄的衝動。Flicit 日曆頁面此前嘗試過這種做法,結果是單臺手機寬度的月曆旁塞進侷促的當日日程,而絕大多數觀影日只有一兩張票根,導致整塊螢幕下半部分大面積空置。縱向立起時應當堅定採用單欄佈局,當日票根居中排布在月曆正下方。左右分欄僅保留給寬大於高的物理形態。

該單欄最初的版本橫貫全寬,併為了在單螢幕內同時露出月曆與當日首張票根,強行將海報格子從 2:3 壓縮接近正方形。功能目標雖然達成,視覺呈現卻災難性地醜陋:所有海報均被暴力裁切成正方形,開發者給出的評價是一致的難看。海報在任何場景下均必須保持神聖的 2:3。修復方案是堅守 2:3 比例,改為收窄月曆整體寬度,將其限制在剛好能完整擺放 6 行海報且在標籤列上方露出當日日程的適宜寬度:約佔 669pt 中的 455pt,水平居中,且絕不窄於手機單螢幕月曆。在直向 iPad mini 上全寬剛好能夠容納,因此無需收窄。當佈局空間產生衝突不得不妥協時,寧可收縮可用寬度留白,也絕不能拉伸扭曲內容比例。

NavigationSplitView 在原生邏輯下同樣會暴露此缺陷。當總寬低於約 800pt 時,它無法並排呈現列表與具備良好可讀性的詳情,轉而將列表懸浮在暗化的詳情上方。當將展開的 Flicit 從橫向旋轉至縱向時,列表往往直接被強制 Dismiss 僅留下一片空白詳情;直接在縱向冷啟動時,列表則懸浮在一片灰色背景上。在此寬度下,應當主動為 Split View 注入 Compact 的 Size Class,促使其摺疊收攏為單欄導航棧(Stack):

@Environment(\.horizontalSizeClass) private var windowSizeClass
@State private var width: CGFloat = 0

private var sizeClass: UserInterfaceSizeClass? {   // 800:iOS 27.1 的 Split View 停止並排的寬度
    windowSizeClass == .regular && width > 0 && width < 800 ? .compact : windowSizeClass
}

NavigationSplitView { List(…) } detail: { Detail(…) }
    .environment(\.horizontalSizeClass, sizeClass)
    .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width = $0 }

如此一來,內部所有頁面均遵循單一邏輯:列表承載整頁,點選條目推入攜帶返回按鈕的詳情頁,原本為 Compact 詳情編寫的程式碼(如隱藏底部標籤列)全部正常生效。重新橫置展開時,兩欄重新平鋪並保持選中的項。全螢幕列表本身也需適配此寬度:Flicit 的票根瀑布流在能夠並排容納兩張票根時展開為雙列,空間不足時收為 520pt 居中單列。

隨後必須全面驗證在詳情開啟狀態下的旋轉回歸。在橫置狀態下檢視著某張票根並直接將手機旋轉立起,Flicit 此前會直接退回列表導致票根丟失,即便 preferredCompactColumn 明確聲明為 .detail:摺疊後的分欄檢視僅在繫結值突變為 .detail 時才觸發 Push,若其原本就處於 .detail 則完全靜默無響應,這也是為何直接冷啟動縱向詳情正常、中途旋轉卻失效的根源。而當它終於能夠保持顯示票根時,標籤列卻陰魂不散地覆蓋在其上方。分欄檢視在收折時會在舊檢視尚未銷燬前預先建置新檢視,而兩者使用了存放在票根 Store 中的同一個所有者 ID 申請“隱藏標籤列”,導致舊檢視在銷燬解綁時順帶將新檢視的隱藏聲明一同登出了。針對上述問題的兩處精準補丁:

// 在 Split View 中:攜帶開啟的詳情收折時,在下一輪 Runloop 中重新觸發啟用
.onChange(of: sizeClass) { _, sizeClass in
    guard sizeClass == .compact, selection != nil, compactColumn == .detail else { return }
    compactColumn = .sidebar
    Task { @MainActor in compactColumn = .detail }
}

// 在詳情檢視中:將介面聲明權繫結到檢視例項(View),而非跨例項的資料項
@State private var barOwner = UUID()

Apple 設計篇嚴正要求 App 在使用者開合翻轉機身時必須保持連貫可預期,而上述兩個嚴重體驗 Bug 在靜態截圖上均極易偽裝成合理的正常排版。務必在詳情開啟的狀態下對機身進行多方向雙向翻轉,仔細審查每一步的表現。

豎拿的展開 iPhone Duo:Flicit 的日曆,整月的海報都保持完整,月曆收窄並在螢幕上居中,14 日的《火山摯戀》票根從下方開始
縱向立起時,日曆收為單欄:月曆由未被裁切的完整海報組成,居中收窄以完美契合螢幕,當日票根整齊排列於下方。圖片:Flicit

半折書本形態:將分欄間隙對齊摺痕

在橫向半折狀態下,物理摺痕會作為螢幕中央一條 40pt 寬的分割帶出現(具體 API 見摺痕指南)。雖然可滾動文本可以安全跨越摺痕,但對於雙欄並排頁面而言,核心議題在於兩欄之間的間距(Gutter)如何安放。正確的做法是將分欄間距精準對齊摺痕:將左側欄的主寬精準限制在摺痕左側邊緣,並將摺痕自身的物理寬度直接作為分欄間距,同時確保兩側欄均滿足自身最小可用寬度。這樣左右兩側便各安一折頁,宛如翻開一本真正的實體書籍。

更關鍵的是:在裝置完全平放時同樣要提前對齊。系統在預設情況下僅在機身半折時報告摺痕,但若在請求時開啟 .includeInactive,則完全平放狀態下系統同樣會提前預報摺痕的未啟用位置(即未來的彎折軸線)。如果元件依賴 Active 狀態才介入重排,在機身彎折的瞬間,頁面會在系統底層的摺疊動畫掩護下發生突兀的二次排版位移——這正是 Flicit 在螢幕錄影中所暴露的瑕疵。而如果從平放狀態起便直接錨定在未啟用摺痕帶上,整個彎折過程介面紋絲不動:

// 位於 GeometryReader 內部;所有 Frame 均需累加讀取器的全域原點
let divisions = proxy.reservedRegions(kind: .division, options: .includeInactive)
let active = divisions.first { $0.isActive }?.frame
let resting = active == nil ? divisions.first { !$0.isActive }?.frame : nil

// 摺痕在全域 X 軸的覆蓋範圍:啟用態摺痕,或平放時未來的中縫線
var creaseBand: ClosedRange<CGFloat>? {
    if let seam = active { return seam.minX...seam.maxX }                  // 半折書本形態
    guard let fold = resting, fold.height > fold.width,                     // 豎貫螢幕
          abs(fold.height - fullPageHeight) <= 1,                           // 排除上一姿態遺留的髒資料
          pageWidth > pageHeight else { return nil }                        // 僅限橫置狀態
    let half = (fold.width > 0 ? fold.width : creaseWidth) / 2              // 硬體上平放可能為零寬
    return (fold.midX - half)...(fold.midX + half)
}

// 雙欄元件。務必測量外部賦予的寬度,切勿測量子欄自身的尺寸:
// 聲明了 minWidth 與 maxWidth 的 Frame 會強制接受外部給予的空間,無視內容尺寸要求。
HStack(spacing: gutter?.width ?? 16) { primary.frame(width: gutter?.primaryWidth); secondary }
    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
    .onGeometryChange(for: ClosedRange<CGFloat>.self) { proxy in
        let frame = proxy.frame(in: .global)
        return frame.minX...frame.maxX                  // 僅監聽 X 軸:縱向滾動不會觸發重新計算
    } action: { span = $0 }

guard let crease = creaseBand, let span,
      crease.lowerBound > span.lowerBound, crease.upperBound < span.upperBound else { return nil }
let primaryWidth = crease.lowerBound - span.lowerBound  // 左半螢幕摺頁寬度
let gutter = crease.upperBound - crease.lowerBound      // 摺痕間隙本身
// secondary:摺痕右側剩下的可用空間,前提是滿足最小寬度要求

Flicit 最初曾試圖根據轉軸角度(如大於 90°)結合螢幕幾何中線自行推導摺痕,但實踐證明每個寫死的角度閾值都是災難之源:例如等待 150° 的閾值導致摺痕計算在翻折中途突然失效斷檔,引發兩側欄瘋狂閃爍。Apple 多工篇強調將轉軸角度留給互動動效,排版必須全權委託給保留區域;未啟用保留區域在任何平放角度下均穩定存在,直至物理摺痕被正式啟用。上述程式碼保留了兩個至關重要的防護:確保當前頁面處於寬大於高(防止直向狀態下橫貫的摺痕引發第 9 節的無限死迴圈),以及確保區域全高貫穿頁面(防止旋轉合上瞬間外螢幕依然攜帶上一姿態的摺痕資料)。

對於非導航類的純內容雙欄排版,Apple 官方推薦使用 ArrangementView(UIKit 中為 UIArrangementViewController)。它接受主次兩個檢視,依據 Size Class、寬高比及 Division 區域自動完成並排或層疊。佈局篇劃定了兩條紅線:切勿將其置於 List 或 ScrollView 內部,也切勿在其內部巢狀 NavigationSplitView。Flicit 的雙欄行原本就位於 ScrollView 內部,不符合 ArrangementView 的使用場景,因此採用上述手動對齊方案。

對於原生的 NavigationSplitView,同樣需進行摺痕對齊處理。在逐一列印各姿態下的 Column Frame 後,總結出了以下三大核心細節:

  • 半折狀態下,系統會自動定位分欄割線,精準卡在摺痕起始邊緣(455.67pt,即摺痕 455.5pt 的像素對齊值),完全無視開發者傳入的理想寬度。Apple 佈局篇描述的正是這一行為:系統會以均分的 50/50 佈局讓兩欄都保持可見。因此在平放狀態下,理想寬度直接以該邊緣為基準申請,方能實現平放與翻折無縫接軌。
  • 平放狀態下,新的理想寬度會立即生效,但系統強制要求 Detail 列保留至少約 448pt 的寬度。 在展開後 867pt 的頁面中,這導致 Flicit 的列表被強行截斷在 419pt 處,距離摺痕尚差 36pt,直到翻折瞬間才突兀跳動歸位。因此針對摺疊裝置,必須主動調低 Detail 列的最小寬度限制。
  • 摺痕隨後會豎貫在 Detail 列的 Leading 邊緣。 在兩種姿態下均需為 Detail 列新增等於摺痕寬度的 Leading SafeAreaPadding,確保票根內容完全起始於右半扇摺頁。
// toFold = crease.lowerBound - frame.minX,僅在 360...520 之間時採用;其餘情況為 nil
NavigationSplitView {
    List(…)
        .navigationSplitViewColumnWidth(min: 360, ideal: toFold ?? 410, max: max(440, toFold ?? 410))
} detail: {
    Detail(…)
        .safeAreaPadding(.leading, crease.upperBound - detailFrame.minX)  // 40pt 安全避讓
        .navigationSplitViewColumnWidth(min: 320, ideal: 412)              // 僅限摺疊裝置調低閾值
}

注意 frame 與 detailFrame 均在全域座標系中度量。無摺痕存在時,維持預設理想寬度並將 Padding 置為 0。切忌在不降低 Detail 最小寬度的情況下強行加大 List 寬度,否則兩欄無法共存,Split View 會直接將 List 懸浮在 Detail 上方,引發恢復邏輯的拉扯死迴圈。更切忌將 min 與 max 設為同一值強制固定寬度(見第 9 節)。

通過上述三重調優,Flicit 的影庫在完全平放、書本微折、重新展平、閉合喚醒以及直向轉入橫向的全部場景下,均呈現出絕對的一致性與穩定性。

像書本一樣半折的 iPhone Duo:左頁是 Flicit 的票根列表,右頁是一張《火山摯戀》票根,海報印成了點陣
半折書本形態下,Flicit 的列表精準止於摺痕中縫,票根詳情工整鋪展於右半扇摺頁上。圖片:Flicit

網格佈局:摺痕穿過元素間隙,絕不可縱切單個元素

若在一整排展開螢幕上機械平鋪照片或海報網格,遲早會有某一格元素恰好橫跨在中縫線上,一旦機身半折,該元素便會沿中軸嚴重扭曲折斷。佈局篇給出的明確建議是:只要檢測到 Division 分割區域(不論是否處於啟用狀態),網格列數均優先採用偶數列。Wesley Matlock 在 VinylCrate 的黑膠唱片網格中便嚴格貫徹了這一法則。Flicit 的電影收藏呈現為連續的膠片印樣:全螢幕排布多行電影膠捲,每個畫幅內嵌一張海報。如今每條膠捲在左右兩扇摺頁上強制均分相同數量的畫幅,將中縫摺痕(及其平放預估區域)作為左右兩部分畫幅之間的空白走廊;而膠捲底片的外沿與打孔齒孔則平滑穿透中縫,在視覺上依然維持一條完整的連貫膠捲。具體列數依據膠捲全域跨度及摺痕帶動態計算:每摺頁列數以較窄一側摺頁的容量為準;若計算導致單格過於狹窄,則自動回退至常規排版。

居中焦點元素:視覺與互動焦點主動避讓摺痕

Apple 佈局篇嚴正提醒開發者仔細審計所有居中排版的佈局,並以橫跨書脊跨頁印刷的攝影大圖作為反面教材:橫跨摺痕的內容無法形成完整的視覺閉環,且位於中縫處的按鈕極難被拇指精準觸達。連續滾動的文字可以跨越摺痕,但視覺注意力與拇指互動的核心焦點絕不能壓在上面。Flicit 發現頁原先以一組居中扇形展開的熱門海報 Carousel 開篇,在橫置展開時其中心海報恰好不偏不倚地卡在物理摺痕上。重構後,一旦檢測到摺痕帶穿透輪播區域(無論平放還是半折),整個扇形海報組立即向左位移整齊排列在左摺頁內(中心海報兩側各露一張);而該焦點影片的文字後設資料卡片(片名、上映年份、片長、評分及劇情簡介)則整齊佔據右摺頁,並隨左側扇形海報的滑動旋轉而即時無縫聯動切換,點選任意一側均能直接喚起影片詳情。Apple 將此設計範式稱為位移重排(Displacement):將核心元素平移至契合其物理屬性的區域,與原位置保持臨近,並將強關聯的附屬元件成組協同位移。系統層級的 Sheets、Alerts 與 Menus 均已全面內建了這一行為。

展開的 iPhone Duo 上 Flicit 的發現頁:左頁是三張海報組成的扇形,最前面是《銀翼殺手2049》,右頁是它的片名、年份、片長、評分和簡介,下方是一排正在上映的影片
平放展開時,熱門海報的扇形落在左摺頁上,正中那部影片兩側各露一張海報,影片的文字卡片佔據右摺頁。視線和拇指要去的地方,都不壓在摺痕上。圖片:Flicit

筆電形態:上螢幕呈現,下螢幕互動

將雙螢幕裝置縱向立起並半折至約 110°:上半部分如同筆電螢幕般立起,下半部分平臥在桌面上。必須為上下兩個半螢幕明確劃分職能分工,並且這項工作必須覆蓋所有可能在該姿態下開啟的子頁面,絕不僅限於你主觀為其設計的那一個核心介面。Flicit 第一版僅在單個頁面上實現了此形態:票根縮小至立起的上半螢幕,選座影廳鋪在下半螢幕;然而一旦橫滑至票根筆記或電影資料頁,介面便再次暴露出橫跨中縫的整頁排版。

  • 內容形態必須與所在半螢幕的長寬比高度契合。 立起的上半螢幕物理尺寸為 669pt 寬、約 373pt 高,呈現明顯的寬螢幕比例。常規縱向票根在此處會被高度嚴重限制:Flicit 縱向票根被壓縮至僅剩 192×341pt,相較於平放時的 408×720pt 顯得極度縮水。重構後,上半螢幕切換為票根的橫向票樣形態(即票夾中收納的完整入場券票樣),採用專屬主題繪製並放大填滿整個上半螢幕,可用可視面積擴大了整整三倍。若你的核心業務資料沒有現成的橫向形態,寧可在此處呈現具備橫向比例的關聯元素,也絕不能勉強放一個微縮縮放的縱向殘缺檢視。
  • “看的放上面,觸控的放下面”。 票根主體在上半螢幕始終保持恆定展示,而下半螢幕則作為互動操作檯承接各個翻頁子面板:標明座位的放映廳全景、評分與觀影筆記、以及電影資料。Flicit 的電影詳情頁在上半螢幕展示海報與劇情梗概,下半螢幕集中承載評分、演員陣容以及購票票根操作;正如播客應用 Micropod 在轉軸上方展示專輯封面、在轉軸下方鋪設完整播放控制台一樣。
  • 上下兩半必須各自獨立滾動。 任何橫跨中縫摺痕滾動的頁面,都會導致一半的內容落在平臥的底板上,以完全無法直視的角度令人痛苦。必須在中縫處將其阻斷拆分為兩個獨立的 ScrollView,正如財務應用 Finma 將圖表與流水列表徹底分拆兩螢幕獨立滾動一樣。
  • 無法適配的複雜內容應堅決放棄分拆。 Flicit 的日曆頁面需要展示一整月縱向海報,其高度遠超任何單個半螢幕的物理極限,因此在筆電形態下它堅定保持原有的整體單欄滾動,絕不為了迎合形態而將整月海報強行縮成毫無可讀性的微型郵票。
  • 完整保留所有互動動作。 Apple 設計篇強調:針對特定姿態重構的佈局必須完整保留其他姿態下的全部互動控制元件與層級入口。Flicit 筆記本頁面最初因將票根頁切換為橫向票樣,意外遺失了撕票根副券的手勢(這是將待看電影標記為“已看”的唯一途徑)。如今在所有姿態下,票根的操作選單中均顯式補全了“標記已看”入口。

中縫分割判定必須依據 Active Division 區域的上下邊界與頁面的全域 Frame 進行即時計算:

// seam:啟用的 division 區域(全域座標);page:頁面的全域 Frame
guard seam.width > seam.height,                        // 橫向摺痕:機身縱向立起且半折
      seam.minY > page.minY, seam.maxY < page.maxY else { return nil }
let raised = seam.minY - page.minY                     // 摺痕上方(立螢幕高度)
let flat = page.maxY - seam.maxY                       // 摺痕下方(操作檯高度)
guard raised >= 220, flat >= 180 else { return nil }   // 確保兩個半螢幕均具備充足的可用高度

必須在填滿頁面的 GeometryReader 閉包內部或通過不依賴分割結果的穩定 Frame 完成此項計算,絕對禁止依據被分割元素自身的尺寸進行反向計算。

豎拿並半折的 iPhone Duo:立起的上半部分是橫向形式的《F1:狂飆飛車》票根,車手畫成了像素,平放的下半部分是面向銀幕的座位圖,H12 座位用墨跡標出
筆電形態:立起的上半螢幕呈現橫向票樣;平放的下半螢幕承載翻頁操作檯,首螢幕展示從銀幕視角望去的影廳全景,所選座位被墨跡著重標出。圖片:Flicit

外螢幕適配

外螢幕內部頁面寬度為 382pt,略窄於常規 390pt 的 iPhone。按標準手機尺寸設計的網格在此處極易丟失一列;原則上只要每個單元格的縮放比例不低於基準寬度的 86%,就應堅持保持列數不變。對於孤立展示單張卡片的詳情頁,應如第 6 節所述將其居中置於整塊物理玻璃上,並略微縮減繪製尺寸,防止卡片邊緣觸碰螢幕邊界。

Apple 準備篇推薦使用 ConcentricRectangle 與 UICornerConfiguration,以完美契合 Duo 外螢幕的硬體大圓角。Flicit 的設計語言特意採用了手工剪紙風格,因此並未追求嚴格的同心圓角。核心要求是確保所有可互動內容絕不被物理圓角裁切:SideRail 的底部圓角距離外螢幕底邊約 46pt、距離側邊約 15pt,安全內嵌於螢幕玻璃的倒角弧線之內。

當在機身旋轉狀態下合上手機時,外螢幕會呈現為橫向的 678×466 尺寸,寬大於高且系統側邊列停靠在 Leading 邊緣,此時必須全盤套用寬螢幕跨頁規則而非單列手機規則。Flicit 的日曆頁面此前在此時同樣暴露出將海報格子裁成正方形的 Bug;如今海報在全站任何場景均嚴格保持 2:3,電影詳情頁則無縫切換為海報獨佔單摺頁形態。

合上的 iPhone Duo 外螢幕正中的一張《伯德小姐》票根,海報被重繪成筆觸,返回按鈕和票根的操作按鈕豎排在時鐘下方的那一列裡
閉合形態下:票根詳情頁收起 SideRail,將自身工具列垂直嵌入側邊列,票根卡片在整塊物理玻璃上居中呈現。圖片:Flicit

iPhone Duo 的待機模式(StandBy),以及在模擬器中調出它

將半折的手機如帳篷(Tent)或翻開的書本般免手持立在桌面上,iPhone Duo 便會自動進入 StandBy(待機模式)模式——在兩塊螢幕中的任意一塊全螢幕呈現時鐘與小工具,且無需連線任何充電器。這初看像是一個需要開發者專門針對性佈局的硬體新形態。Flicit 曾深入挖掘該形態的適配路徑,最終確認其對外暴露的入口僅有 WidgetKit 小工具與 Live Activities 即時動態。

系統未開放任何可供感知該姿態的公共 API。 公開的轉軸 API(onHingeChange、UIHingeInteraction)僅提供基礎的 status 與 angle。系統內部確實為各形態命名:SwiftUI 私有的 _HingeState.MagicPose 列出了 book 和 laptop 及二者的倒置形態、tent、openFlatFaceDown、兩個橫向的 magicMount 姿態以及 ambiguous,SpringBoard 據此裁定何時觸發 StandBy 以及點亮哪塊螢幕。但所有這些符號均屬私有。WidgetKit 在 iOS 27 中新增的僅有 isDynamicIslandLimitedInWidth 屬性與 .systemExtraLargePortrait 尺寸規格,並未提供任何摺疊螢幕 StandBy 的專屬標記。Apple 小組實驗室(Group Labs)劃定了相同的邊界:普通 App 在帳篷形態下僅能在外螢幕呈現其常規的橫向介面;而鬧鐘響鈴時內螢幕漫射出的微光氛圍特效受 AlarmKit 嚴格管控,僅限鬧鐘類 App 呼叫。準備篇講座指明了普通 App 為此形態唯一能做的事情:外螢幕必須良好支援橫向顯示,“因為使用者可能會像帳篷一樣將手機立在桌面上”。

開合與旋轉模擬器無法觸發 StandBy。 SpringBoard 會在系統控制台中記錄裁決日誌,開發者可以直觀目睹它如何拒絕觸發:

xcrun simctl spawn <DUO-UDID> log stream --style compact \
  --predicate 'process == "SpringBoard" AND eventMessage CONTAINS[c] "magic"'
Ambient magic pose event detected [ tabletopPose : Amb ; hingeState : PO ; deviceOrientation : Lr ; … ]
Updating ambient trigger state : idle [ … isChargingRequirementFulfilled : YES ; ignoreCharging : YES ;
  … isMagicPoseSupported : NO ; magicPose : Amb ; magicPoseState : unsupported ]

Flicit 曾通過轉軸滑塊與方向選擇器構造了 10 組典型姿態遍歷 Duo 模擬器(涵蓋閉合、70°、110°、128°、完全展平,以及全部 4 個旋轉方向),日誌回饋始終如一:桌面擺放姿態始終處於不確定狀態(Amb),Magic Pose 均標記為不支援,待機模式觸發被阻斷並提示“no trigger asserted”。因為這兩個底層控制滑塊無法向系統傳遞“究竟是哪一條物理邊緣貼在桌面上”,導致模擬器永遠無法確認裝置處於立起支撐狀態。充電狀態並不是阻礙原因(日誌明確顯示 ignoreCharging : YES),而 Device Hub 的預設按鈕本質上也只是底層這兩組控制元件的宏組合。70° 時由哪塊螢幕出畫,取決於轉軸是怎麼到達這個角度的(見第 3 節)。從展平往下彎,內螢幕繼續出畫,外螢幕保持黑螢幕;從合上往上開啟,外螢幕繼續出畫,轉軸報告合上,這是模擬器最接近“App 被立成帳篷”的狀態。

devicectl 能把它調出來。 只要鎖定螢幕正在顯示、介面處於橫向,xcrun devicectl device settings standby --show --duration 10 --device <udid> 就能在模擬器上呈現 StandBy。鎖定螢幕出現之前,它一直拒絕:“StandBy needs the lock screen to be showing, and the device to be in a pose that supports StandBy.” simctl 沒有鎖螢幕命令,但側邊按鈕是一個鍵盤事件(consumer page 0x0C,usage 0x30),可以像轉軸事件一樣從模擬器內部投遞:按一下鎖螢幕,再按一下顯示鎖定螢幕。此後,展開橫置平放、橫置 128°、70° 橫置(正反兩個方向)時,StandBy 出現在內螢幕;合上並轉到某一個橫向(landscape-right)時,出現在外螢幕。立起時,以及合上豎持時,device info standby 讀到 unavailable。StandBy 顯示期間,SpringBoard 記錄下一個桌面姿態和已斷言的觸發器,這是開合與旋轉始終達不到的。每次它都先開啟 Apple 的“Welcome to StandBy”歡迎頁。Xcode 的工具無法點按展開的手機,但 duoctl 可以(見第 4 節):點過“Continue”之後,內螢幕上的待機模式呈現了系統自帶的電池和聯絡人小工具,沒有背景,滑動可以在各頁之間切換。App 自己的小工具要通過編輯待機模式來新增,這一步沒有試過。另外,Apple 小組實驗室(Group Labs)列出的 Device Hub 可調項中包括帳篷姿態與三維空間旋轉,超出了本文用指令碼驅動的這兩個 HID 控制元件。

因此,開發者應當做的是將針對 StandBy 的通用資產準備到位:確保小尺寸小工具在系統強制剝離背景後依然具備出色的可讀性(通過 showsWidgetContainerBackground 判定背景是否被移除);確保鎖螢幕即時動態在放大至兩倍尺寸後依然維持精緻視覺;以及視業務需要引入特大尺寸小工具(Group Labs 專家明確指出特大元件在 Duo 的 StandBy 下具備極高價值)。至於在 StandBy 模式下的真機呈現效果,只能靜待真機發售。Flicit 這一部分目前尚未完成重構:其小工具依然硬編碼繪製了私有的紙質紋理背景,且尚未提供特大規格尺寸。

姿態變換之間:僅對彎折做動畫

後續的一輪最佳化對每次姿態變化都進行了逐幀螢幕錄影比對;動畫指南闡述了完整的推導過程。開合發生在系統的高斯模糊快照之下,旋轉則由系統的旋轉動畫接管:App 在底層完成尺寸調整並落地到新佈局中,使用者完全看不見這個過程。唯有彎折沒有任何系統過渡遮蓋。由於分欄間距早已對齊靜止時的摺痕,Flicit 橫向握持時的彎折沒有引發任何可見位移。但筆電形態則不同:票根頁面與電影詳情頁在兩個方向上均以單幀硬切切換佈局,而且切入的新佈局在圖片淡入之前,其藝術圖佔位區域(票根上的黑塊、電影海報上的灰塊)會暴露 60 到 100 毫秒。

  • 僅對彎折做動畫,並在發布姿態的地方做裁決。 所謂彎折,就是摺痕在啟用與靜止之間切換、同時視窗(即受追蹤的檢視加上其 Inset 安全邊距)保持尺寸不變。開合與旋轉均會改變視窗尺寸,而首次求值則是從無到有。Flicit 的摺疊階段將彎折狀態包裹在 withAnimation 中發布(採用彈簧曲線,且在開啟“減弱動態效果”時不執行動畫),而其他所有讀數均不帶動畫發布。通過針對姿態的 if/else 切換佈局的頁面,隨即自然獲得 SwiftUI 的預設淡入淡出過渡:在螢幕錄影中大約耗時 120 毫秒。如果把 .animation(_:value:) 直接繫結到姿態上,它在手機彎折狀態下發生旋轉時也會觸發,疊在系統自身的旋轉動畫之上。
  • 在切入新佈局的首幀繪製圖像。 非同步載入的圖片檢視在初次建置時始於空白,即便該圖片此前早已解碼完成。將圖片檢視最後繪製的點陣圖儲存在一份主體(body)可同步讀取的小快取中,在展示佔位圖之前優先查詢快取:完全匹配的請求(相同檔案、像素尺寸與縮放倍率)優先,否則只要同張圖片的已有最大解碼尺寸達到所需尺寸的一半以上,就先用作墊底佔位,待檢視自身的非同步解碼完成後無淡入平滑替換。若存在取樣像素色彩的邏輯,需確認其在墊底圖下依然正常運作。Flicit 的票根標籤顏色取自海報 24×24 像素的取樣,螢幕錄影顯示在精準解碼替換墊底圖時該顏色並未發生變化。
  • 淡入淡出會閃爍時,改用硬切。 Flicit 的電影詳情頁並不能像票根頁那樣優雅地淡入淡出。其退出的舊佈局在單幀內直接消失而不是漸變褪去;展平手機時,切入的新頁面則在裸白紙底上淡入約 40 毫秒(兩到三幀的空白頁)。改用提前展現新頁面的 Ease-Out 曲線也無法消除這一空白。該頁面的兩套佈局均基於 ScrollView 建置,而票根頁的手動排版 Pager 卻能正常漸隱漸顯;螢幕錄影所證止於此,更深的原因暫未探明。空白閃爍比硬切更難以忍受。電影詳情頁通過 .transaction(value: isLaptopLayout) { $0.animation = nil } 退出了彎折動畫,在圖片早已繪製完成的前提下以單幀利落硬切。務必為每一個因姿態切換佈局的頁面逐一螢幕錄影:一個頁面的淡入淡出正常,並不保證下一個頁面也是如此。
// 在 stage 內部:摺痕變了,視窗沒變。
func isBend(to next: FoldStage) -> Bool {
    (seam == nil) != (next.seam == nil) && windowSize.width > 0
        && abs(windowSize.width - next.windowSize.width) <= 0.5
        && abs(windowSize.height - next.windowSize.height) <= 0.5
}

// 在視窗根節點的唯一監聽器中。
func publish(_ next: FoldStage) {
    guard next != stage else { return }
    guard stage.isBend(to: next), !reduceMotion else { stage = next; return }
    withAnimation(.interpolatingSpring(mass: 0.9, stiffness: 280, damping: 24)) { stage = next }
}

將轉軸作為互動控制元件:用 onHingeChange 實現手勢

Apple 多工篇劃定了嚴謹的架構界線:即時推送的轉軸高頻資料僅用於驅動物理互動與視覺動效,介面的實際排版定位必須全權交給保留區域(Regions)與 Arrangement API。僅當摺疊本身是完成某項物理操作最自然直觀的隱喻時,才應考慮將轉軸動作作為互動觸發器。 在 Flicit 中,撕下待看電影票根的副券代表著將其標記為“已看”;在 Duo 上,輕輕折彎機身,票根副券便會沿打孔齒孔物理翹起,重新完全展平時便將副券利落撕下——因為在現實物理世界中,撕下票根的動作正是如此。

半折的 iPhone Duo:Flicit 裡一張待看的《請以你的名字呼喚我》票根,副券正沿著齒孔撕開,旁邊是票根列表
輕輕折彎展開的機身,票根副券會沿齒孔物理翹起;重新完全展平即可直接撕下副券,並順勢彈出觀影評分面板。圖片:Flicit

轉軸手勢必須具備極強的狀態機健壯性,以徹底免疫使用者在日常開合裝置時的各種無意識誤觸(轉軸指南彙總了各款轉軸遊戲對手勢的精準過濾策略):

  • 手勢必須從明確的已知基準狀態啟用(Arm):此處設定基準狀態為完全展平(轉軸角度 ≥166°)。檢視出現時必須立刻執行一次初始讀取,切忌僅依賴變更通知:若開啟頁面時機身已處於展平狀態,一次快速折動最開始的幾次讀數極易被系統底層合併拋棄,從而導致手勢監聽失靈漏判(Apple 文件寫明,角度更新的頻率與精度屬於系統策略)。Flicit 將最新的 onHingeChange 讀數快取在全域 Fold Stage 中,票根詳情頁通過 onChange(of:initial: true) 確保初始掛載時立即捕獲。
  • 必須要求明確的物理運動行程:要求摺疊至 ≤148°(比展平少 32°),且必須在離開展平狀態後的 2.5 秒內重新展平。
  • 徹底合上機身絕非有效手勢:一旦角度低於 70°,手勢立即全面重置(Reset)。長時間半折置於桌上閱讀同樣不是手勢:彎折狀態超時後立即解除手勢啟用(Disarm)。
  • 執行前二次確認,並必須保留傳統互動兜底:撕下票根後彈出與手動拖拽撕票相同的確認面板,單純依賴轉軸不會靜默寫入任何持久化變更;且在所有裝置上手動拖拽撕票互動必須完好保留。

將上述狀態機邏輯完整收斂在一個純 Swift 值型別內部,僅接收角度與時間戳作為輸入;該狀態機可在任何標準模擬器(哪怕是不具備轉軸的機型)上執行高覆蓋率的單元測試。在 Duo 模擬器中,通過單次插值指令碼即可完整模擬測試該連續手勢:duo.py path 1.0 180 140 180。至於該互動握在手掌中的真實物理阻尼感,唯有真機能夠揭曉。

AI 代理極易踩中的暗坑

  • 以佈局決策自身所產生的結果尺寸,反向作為該決策的判斷條件(自回饋死迴圈)。 Flicit 的雙欄元件曾通過讀取自身的全域 Frame,來決定是否將分欄間距對齊摺痕:一旦對齊摺痕,兩欄總寬超出了容器所能給予的空間(633pt);而取消折痕對齊後,測得自身寬度為 538pt,又被判定為過窄無法容納間隙。SwiftUI 會在同一輪佈局中應用幾何回撥引起的狀態變更並立即重新計算,導致元件在“開啟摺痕”與“關閉摺痕”之間無限振盪:主執行緒深陷 UIKit 旋轉流程無法返回,最終被 Watchdog(0x8BADF00D)強殺。務必始終測量外部給予的可用空間:使用同時聲明了最小值與最大值的 Frame(.frame(minWidth: 0, maxWidth: .infinity) 會無條件接受外部空間而不管內部內容要求),且只監聽真正需要的單一幾何維度。排查過程詳見第 3 節。電影詳情頁在橫置展開從票根中喚起時同樣復現了此 Bug:在嵌入 411pt 的詳情列之前,它先以全螢幕寬度試探計算了一輪,選擇了海報獨佔單摺頁的佈局;而該摺頁具備固定的 446pt 寬度,直接將頁面撐得比詳情列還寬。詳情列為了居中過寬的頁面動態改動了 Insets,導致頁面每一幀都在瘋狂重算自身尺寸,CPU 佔滿且 Push 轉場永久卡死在半路。在改為嚴格以分配給它的尺寸為基準(全螢幕時高度同理)度量後,單幀即可收斂穩定。
  • 輕信旋轉過渡幀的單一瞬時讀數。 上述死迴圈只在旋轉至豎向時觸發:因為在某一過渡幀中,UIKit 報告了豎向的尺寸,但側邊欄邊緣和 Safe Area 內邊距仍沿用橫向時的舊值,導致元件誤在一個立向頁面上識別出了一條橫貫水平的摺痕帶。務必結合頁面自身的寬高比核驗摺痕有效性(只有頁面寬大於高時,才允許豎貫摺痕存在),並在各種姿態之間做全量雙向翻轉回歸。
  • 外圍 UI 顯隱導致檢視標識(View Identity)意外替換。 寫成 if height > 0 { content.contentMargins(…) } else { content } 的 Modifier 會在高度從 0 變動時徹底銷燬重建內部整個 ScrollView。Flicit 的 SideRail 在每次開啟搜尋時便會觸發該高度變動,導致底層所有頁面被完整重建,重新播放一次進場動畫,取消搜尋時又重複一遍。必須無條件應用修飾符,高度為 0 時直接傳 0。
  • 從錯誤檢視讀取 Safe Area 內邊距。 忽略 Safe Area 的 GeometryReader 讀出的 Insets 為 0;一次性快取的 Insets 在姿態改變後會失效。必須在實際執行佈局的位置即時讀取。
  • 將所有 Inset 誤判為螢幕物理邊緣。 在 Split View 中,Detail 欄的 Leading Inset 等同於整個 List 的寬度(展開後達 455pt)。直接下穿到 Inset 下方的圖片會被完全遮擋在 List 背後。應將超出工具列常規尺寸的大 Inset 視為頁面旁側的獨立分欄(見第 7 節)。
  • 保留區域資料到達滯後。 首輪佈局時 reservedRegions 為空。僅在 onAppear 中讀取一次摺痕的程式碼永遠無法獲取資料。
  • 手勢僅在變動時才註冊監聽。 若僅在 onChange 時捕獲轉軸資料,直接在展平姿態下開啟的頁面將錯過初始狀態。應使用 onChange(of:initial: true)。
  • 依賴舊版全域狀態 API。 UIScreen.main、interfaceOrientation 以及基於裝置型別的硬編碼正是 app-resizability 需要替換的重構點;直接執行技能完成掃描,避免手動排查。
  • 強行鎖定 Split View 的列寬(min == max)。 使用 navigationSplitViewColumnWidth(min: w, ideal: w, max: w) 強行鎖定跟隨摺痕的寬度,會使分欄檢視陷入 CPU 100% 狂轉並卡死在 Launch Screen。應僅將摺痕起始邊緣作為 ideal width,並調低 Detail 最小寬度(見第 7 節)。
  • 分欄檢視從單欄摺疊棧狀態展開時產生幽靈導覽列。 展開手機或旋轉至橫向時,重新展開的 NavigationSplitView 會為 List 附加一個空白導覽列(含 Sidebar 開關),即使聲明了 .toolbar(.hidden, for: .navigationBar)。SwiftUI 在請求可見性未變時不響應隱藏;需通過 .toolbar(removing: .sidebarToggle) 移除開關,並在尺寸型別轉為 Regular 時,在下一個 Runloop 輪次中將導覽列可見性顯式來回翻轉一次。實機螢幕錄影證明該翻轉過程精準落在系統的模糊交接遮罩之內。
  • 放任 Split View 懸浮遮擋列表。 寬度低於 800pt 時 NavigationSplitView 會浮起列表遮擋詳情,甚至在直向時直接丟棄列表。應在此閾值下強制注入 Compact SizeClass 將其收折為單欄棧(見第 7 節)。
  • 分欄檢視在詳情頁開啟狀態下收折異常。 在 preferredCompactColumn 已為 .detail 時摺疊收攏,系統可能錯誤退回列表,因為收折只有在 Binding 發生變化時才觸發 Push。應在收折時先將繫結設為 List,並在下一主執行緒任務中改回 .detail。此外,隱藏外部 Chrome 的聲明應按 View 例項隔離,避免新舊檢視共用 Identifier 導致舊檢視析構時誤解除了新檢視的隱藏聲明(見第 7 節)。
  • 依據機械轉軸角度進行佈局決策。 設定固定的角度閾值極易在翻折過渡區間產生斷層,導致列寬來回閃爍。應改用 .includeInactive 保留區域(見第 7 節)。
  • 自定義欄假定側邊列完全獨佔空白。 動態島會因 Live Activities 持續向下侵佔側邊列。每次均需動態計算側邊列的遮擋區域並在擁擠時優雅降級(見第 6 節)。
  • 姿態專用佈局意外遺漏核心操作。 在切換為筆電形態等專用佈局時,切勿因更換檢視形態而剝奪了其他姿態下的基礎操作入口(見第 7 節)。
  • 對姿態變換做動畫。 基於姿態繫結的 Spring 動畫,或圍繞每次讀數包裹 withAnimation,會在手機開合或旋轉時、在系統自身的過渡之上,以及啟動首幀時重複觸發。僅對彎折做動畫(第 7 節)。
  • 新佈局首幀影像空白。 為新姿態建置的檢視會從頭載入影像並首先顯示佔位圖,即使該圖片片刻前剛剛顯示在螢幕上。硬切能掩蓋這一問題;淡入淡出則會將其暴露(第 7 節)。
  • 生成截圖後未做有效性校驗。 全黑黑螢幕、閃螢幕頁與空資料狀態均可生成有效 PNG 檔案。必須預先灌入真實測試資料,並人工或通過視覺模型審查每張圖片。

使用 Apple 官方產品邊框生成真機渲染圖

Apple Design Resources 提供了 iPhone Duo 的官方產品邊框素材(包含閉合、橫置展開與縱向立起展開),但並未提供任何半折姿態的素材。工具包中的 duo_mockup.py 負責將平面截圖合成嵌入這些平面邊框之中;而書本形態與筆電形態的 3D 效果則交由配套的 duo3d.py 軟體渲染器生成。duo3d.py 內部將內螢幕建模為“平面—圓弧—平面”的可展曲面(包含 36pt 的轉角圓弧段),沿機身厚度擠出成型,從官方邊框圖中提取物理輪廓,通過正投影針孔相機捕捉,並僅對平面素材未覆蓋的內壁立面實施光照著色。本指南中的書本形態、筆電形態以及撕票根手勢渲染圖均出自該工具。

python3 duo_mockup.py cover.png  closed.png --pose closed
python3 duo_mockup.py open.png   book.png   --pose book --deg 128
python3 duo_mockup.py upright.png laptop.png --pose laptop --deg 110

官方邊框素材需由開發者自行準備。工具包內部未內建任何 Apple 智慧財產權藝術資產:請從 Apple Design Resources 自行下載 iPhone Duo 產品邊框,簽署許可協議後解壓至 ~/Library/Application Support/apple-duo-bezels/(或通過 --bezels 引數顯式指定目錄)。協議允許開發者生成自己 App 的產品宣傳渲染圖,但嚴禁對邊框源 PNG 進行二次分發,這亦是本方案不提供線上網頁版的核心考量:Web 工具勢必需要在公網伺服器託管 Apple 邊框資源。若需要在瀏覽器中製作樣機圖,開發者工具目錄中收錄了 Duo Studio、Duo Mock 與 Fold Preview(採用自建 3D 模型),以及通過命令列渲染的 Frames 工具。

交付給 AI 代理的開發簡報

在 AI 代理觸碰業務工程的第一行原始碼之前,請將以下簡報直接注入其上下文:

  1. 將 DEVELOPER_DIR 指向 Xcode 27.1 建置環境,先確認 SDK 中包含 reservedRegions 符號,再信任後續的編譯產物。
  2. 在動手設計任何佈局之前,必須完整執行 Apple 官方的 app-resizability 技能,包括其前置校驗項。
  3. 使用 duo.py 驅動模擬器的開合與旋轉;機身閉合時,使用 xcode_device.py 模擬點按互動並審查 UI 檢視樹,展開時改用 duoctl。嚴禁在 Duo 模擬器上呼叫 simctl io screenConfig。
  4. 按像素解析度動態匹配並擷取各螢幕畫面,校驗所得 PNG 絕非純黑。機身閉合時外螢幕渲染,雙螢幕展開時內螢幕渲染。
  5. 提前向 App 灌入真實測試資料,語言環境需與目標截圖語言完全吻合。
  6. 任何程式碼改動後,必須完整遍歷全部五種核心姿態:閉合、橫置展開、書本、縱向展開與筆電形態;每個姿態均需等待轉軸完全平息後再設定螢幕方向。在詳情頁開啟狀態下,執行任意兩種姿態間的雙向翻轉驗證:App 必須保持原有的導航狀態並維持流暢響應。人工或呼叫視覺模型審查每張截圖,並通過 duo.py record 錄製姿態切換過程:系統底層的高斯模糊遮罩褪去後仍在跳動的元素,均屬未收斂的佈局缺陷。出現兩姿態畫面衝突時,優先列印相關檢視的全域 Frame,切忌憑空猜測原因。
  7. 恪守系統的 Safe Area 安全邊距。除純背景底圖外,嚴禁任何內容下穿至側邊列下方,且絕不可穿透至 Split View 列表下方。嚴禁為側邊時鐘預留整條通欄空白頂欄。
  8. 若標籤列為自定義繪製,動態讀取 toolbarVerticalEdge 並將標籤項嵌入該側邊列中,置於時鐘與系統常駐元件下方;側邊空間擁擠時優雅降級為水平欄。
  9. 針對高視覺度的窄內容,在留白邊距(Margin)允許範圍內向整塊物理玻璃中心居中對齊,且僅適用於佔據整螢幕的主頁面。
  10. 寬大於高時:堅決採用雙欄跨頁、緊貼內容的卡片容器、設上限的自適應單元格、緊湊型圖表、統一的標題基準高度、豎版海報獨佔單摺頁,且列表右側絕不留空詳情。縱向立起時:堅決收為單欄,堅守內容固有比例(寧可收窄整體寬度留白,也絕不能拉伸變形海報),低於約 800pt 時強制將 Split View 收折為單欄棧。簡單放大拉伸手機佈局是錯誤的,參考 iPad 佈局往往是對的。
  11. 絕對不要人工繪製摺痕。互動控制元件避開摺痕,正文可以跨越摺痕,將雙欄間距與網格縫隙精準對齊摺痕(平放與半折同理),基準完全取自系統報告的未啟用摺痕保留區域,而非機械的轉軸角度。視覺焦點與拇指互動核心避讓摺痕,其附屬說明協同位移。
  12. 將每種硬體姿態視為獨立的專用佈局:書本姿態提供左右雙摺頁,筆電形態提供立起瀏覽螢幕與平放操作檯(各自獨立滾動),覆蓋所有可能在該姿態開啟的頁面。在寬大於高的立螢幕上呈現內容的橫向形態。任何姿態下完整保留所有功能操作。
  13. 轉軸手勢嚴禁在閉合中途或半折常駐時觸發,必須具備二次確認,且必須保留傳統觸控路徑兜底。
  14. 使用條件編譯標誌隔離 27.1 API,保證 Xcode 27.0 工具鏈依然能夠正常編譯,並在其上執行單元測試。對 Mac Catalyst 也要清除該標誌,因為即使在 Xcode 27.1 中,它的 SDK 也沒有這些 API。
  15. 絕對不要依據佈局決策自身改變的尺寸反向作為該決策的判斷條件。務必測量外部賦予的可用空間,並結合頁面自身的寬高比核驗姿態讀數。
  16. 不要試圖在程式碼中檢測 StandBy 姿態:無公開 API 支援,開合與旋轉模擬器也無法觸發(devicectl device settings standby --show 可以,須在鎖定螢幕上)。僅需確保 Widget 與 Live Activities 做好大尺寸與去背景適配。
  17. 僅在自身沒有霸佔側邊列的前提下,將自定義工具列項聲明移入側邊列(axisBehavior(.verticalPreferred) 並垂直排布);單按鈕的模態表單堅決停用豎向工具列。
  18. 通過單元測試主動模擬渲染模擬器無法觸達的視窗尺寸(分割畫面半螢幕以及置頂畫中畫壓縮態),動態調整視窗並執行驗證:確保所有頁面能夠快速收斂穩定並完好保持狀態。
  19. 僅對彎折做動畫:即摺痕在啟用與靜止間切換、同時視窗保持原有尺寸。開合與旋轉均由系統接管。切入的新佈局必須在首幀繪製已解碼的點陣圖,如果頁面的淡入淡出會閃爍,則改用硬切。在修改前後均進行逐幀螢幕錄影。

速查表

目標 可行的方案
定位 Duo 模擬器 duo.py devices;絕不新建或克隆模擬器
驅動開合或旋轉 duo.py fold close|open|book|laptop|0..180(從合上進入半折時會先展平),隨後執行 duo.py orient sideways|upright
進入筆電形態 先執行 orient upright,再執行 fold laptop(約 110°)
擷取展開態內螢幕 duo.py shot、simctl io <udid> screenshot --display=<2007 寬埠 UUID>,或 devicectl device capture screenshot --display-unique-id <LCD-1 的 ID>;校驗絕非純黑
錄製開合過程 duo.py record --out films/ path:1.2:0:180 rec:inner wait:4;單裝置單次僅限錄製單螢幕
模擬點按與讀取 UI 手機閉合時:xcode_device.py start、look、tap "<標籤>"、end;展開時:duoctl tap <x> <y>
回讀轉軸 Xcode 27.1 RC 中讀不回角度(devicectl 沒有 device motion);devicectl device info displays 會標出哪塊螢幕處於 active,即合上還是展開
恢復假死崩潰的模擬器 依次執行 simctl shutdown <udid> 與 simctl boot <udid>
獲取系統豎向欄的停靠側 @Environment(\.toolbarVerticalEdge):首選邊緣 .trailing 或 .leading,即使沒有欄在顯示也有值;使用水平欄時為 nil。同時檢查該側的內邊距
讀取中縫摺痕 proxy.reservedRegions(kind: .division).first(預設僅返回啟用區域)
平放時定位未來摺痕 proxy.reservedRegions(kind: .division, options: .includeInactive):未啟用區域即為未來摺痕軸線
讀取時鐘區與前置鏡頭 proxy.reservedRegions(kind: .occlusion)
雙欄間距對齊摺痕 主欄寬度設定為摺痕 minX,摺痕寬度作為間距;平放時採用未啟用區域
自定義欄避讓動態島 計算側邊列活躍遮擋區域底邊;向下壓縮單元格至 44pt,若依然擁擠則降級為水平欄
自定義工具項嵌入側邊列 聲明 .axisBehavior(.verticalPreferred) 並垂直佈局;聲明 .horizontalOnly 則強制保留在水平欄
單按鈕模態表單最佳化 在內容上修飾 .toolbarVerticalBehavior(.disabled)
覆蓋分割畫面與置頂畫中畫 在測試中將宿主 Window 尺寸動態在 475×669 與 951×420 之間變換;所有頁面必須穩定收斂(devicectl device appResize 能調整真實 App 的尺寸,但沒有 Duo 的側邊列和摺痕)
阻止 Split View 懸浮遮擋 寬度低於約 800pt 時,顯式為其注入 Compact 的 horizontalSizeClass
Split View 對齊摺痕 將摺痕起始邊緣作為 List 的 ideal width,切忌 min == max;摺疊裝置 Detail 最小寬度設為 320pt 並加摺痕寬度的 Leading Padding
分欄展開後重藏列表導覽列 聲明 .toolbar(removing: .sidebarToggle),在 Size Class 轉為 Regular 時將導覽列可見性來回翻轉一次
收折分欄時保持詳情展示 將 preferredCompactColumn 設為 List 並立即在下一主執行緒輪次改回 .detail;Chrome 隱藏權按 View 例項分配
消除佈局自回饋死迴圈 使用 .frame(minWidth: 0, maxWidth: .infinity) 測量外部賦予的寬度(頁面高度同理),且僅監聽必要軸向
定位卡死故障根因 審查 ~/Library/Logs/DiagnosticReports 下的 .ips 崩潰日誌、執行 sample <pid>、插入 _logChanges() 並捕獲 SwiftUI 故障流
展開螢幕展示縱向海報 海報獨佔左摺頁,右摺頁滾動正文;縱向立起時採用大比例通欄橫幅;巢狀於分欄中時嚴格止於列表邊緣
對姿態變換做動畫 僅對彎折(摺痕 啟用 ↔ 靜止,視窗尺寸保持不變)做動畫,在發布 stage 處配合 withAnimation;開啟“減弱動態效果”時不執行動畫
佈局切換後不出現佔位圖 在首幀繪製已快取的點陣圖:優先使用完全匹配的請求,否則使用同張圖片尺寸至少為其一半的已解碼點陣圖
排查螢幕錄影中的跳變 使用 -fps_mode passthrough 逐幀提取,再用 compare -metric RMSE 逐幀比對,按差異降序排列
筆電形態拆分頁面 依據摺痕 minY 與 maxY 劃分上下兩螢幕;上下兩半各自建置獨立 ScrollView
網格元素完整避開摺痕 左右兩摺頁分配相同列數,將中縫摺痕作為分列走廊
在 Duo 上觀察待機模式 在鎖定螢幕上、介面橫向時執行 devicectl device settings standby --show --duration 10;單靠開合與旋轉無法觸發,且它先開啟歡迎頁,duoctl 可以點過去。App 僅能通過最佳化 Widget 與 Live Activities 間接入駐
跨版本 Xcode 相容編譯 條件編譯標誌預設開啟,針對 [sdk=iphonesimulator27.0*] 與 [sdk=iphoneos27.0*] 顯式置空;含 Mac Catalyst 目標時 [sdk=macosx*] 也要置空
獲取 Apple 官方 Duo 技能 在 Xcode 27.1 下執行 xcrun agent skills export,提取 app-resizability
生成官方邊框渲染樣機圖 執行 duo_mockup.py <截圖> <輸出> --pose closed|open|book|upright|laptop,邊框素材需自備

開發者常問的問題

Claude Code 或 Codex 能把 App 適配到 iPhone Duo 嗎?

能,前提是補上 Apple 文件沒有提供的兩樣東西:驅動模擬器的手段,以及每個姿態報告的數值。把本指南的工具包作為技能交給 AI 代理,再加上 Xcode 27.1 自帶的 Apple app-resizability 技能。Flicit 就是這樣藉助 Claude Code 分五輪完成適配的,每張截圖都由人來判斷([第 1 節](#1-把這些交給你的 AI 代理))。

怎樣用命令列開合、旋轉 iPhone Duo 模擬器?

simctl 兩者都做不到,devicectl device orientation set 報告成功卻什麼也沒轉。改為從模擬器內部投遞 Device Hub 自己的 HID 事件:用工具包的 duo.py fold book 和 duo.py orient upright,或者用 duoctl、hinge(第 3 節)。

iPhone Duo 模擬器的截圖為什麼是全黑的?

模擬器有兩塊螢幕,只有一塊在出畫:合上時是外螢幕,展開時是內螢幕。不帶 --display 的 simctl io screenshot 截的是外螢幕,手機一展開就是黑的。按像素尺寸挑選螢幕(2007×2853 是內螢幕),或者用 devicectl device info displays 找出處於 active 的那塊,再用 devicectl device capture screenshot 擷取(截圖)。

能在模擬器裡讀到轉軸角度嗎?

在 Xcode 27.1 RC 中,無法從 App 外部讀取:hinge get 和 duoctl 呼叫的 devicectl device motion hinge-angle,在 651.13.5 版 devicectl 中並不存在。devicectl device info displays 能分辨合上與展開;在 App 內部,onHingeChange 會報告角度,比設定值略小几度(第 3 節)。

Xcode 為什麼報“value of type ‘GeometryProxy’ has no member ‘reservedRegions’”?

建置用的是 Xcode 27.0 的 SDK,它早於摺疊 API。請用 Xcode 27.1 建置;要讓 27.0 繼續可建置,就用一個僅在其 SDK 下清除的條件編譯標誌(第 2 節)。

iPhone Duo 的安全區和摺痕是多少點?

合上時視窗為 466×678,尾側邊緣內邊距 84 點、底部 34 點。展開橫置時為 951×669,內邊距相同;立起時為 669×951,頂部 82 點。摺痕寬 40 點,位於視窗正中,只在半折時處於啟用狀態(第 5 節)。像素資料見螢幕尺寸指南。

模擬器能調出待機模式(StandBy)嗎?

開合與旋轉始終觸發不了。只要鎖定螢幕正在顯示、介面處於橫向,devicectl device settings standby --show --duration 10 就能把它調出來(待機模式)。

能在模擬器裡測試分割畫面嗎?

無頭環境下不行,因為它需要從主畫面指示條往上拖。可以改為在調整尺寸的視窗中按分割畫面的尺寸渲染 App,或者在手機合上時用 devicectl device appResize 調整真實 App 的尺寸(視窗尺寸)。

本指南的侷限

本指南所有資料均基於單一模擬器建置版本測得(iOS 27.1,版本號 24A94232):轉軸角度、遮擋區域矩形、Split View 摺疊閾值、Detail 列最小寬度底線、工具列停靠邊緣、轉軸的滯後,以及旋轉過渡幀中的瞬時佈局,均屬實測經驗總結而非官方承諾規範,在後續新版本中可能發生調整。筆電形態下的橫向票樣以及縱向立起時收窄的日曆排版,均屬 Flicit 特定的設計取捨,並非不可逾越的通用規則。Xcode 的 device-interaction 工具在 Duo 上的視界範圍未來亦可能更新。轉軸手勢的各項引數閾值是通過底層控制滑塊調優得出的;真實機械轉軸的電容讀數與手掌開合的阻尼手感,唯有待 10 月 23 日真機發售才能確證。筆電形態渲染圖是將 Apple 官方平面素材進行對稱彎折的簡化產物,而實體手機在物理上依靠下半部平放支撐。螢幕錄影展示的是模擬器的過渡動效,並非真機表現,且模擬器中摺痕在轉軸停止約一秒後才變為啟用狀態,因此筆記本姿態的淡入淡出是在彎折之後而非彎折途中被錄下的。分割畫面與畫中畫置頂影片是在模擬調整尺寸的視窗中估算驗證的,未在真機多工環境實裝,因此左側 App 將側邊列移至 Leading 邊緣的真實動態仍未在自然狀態下被直接目睹。動態島隨即時動態膨脹是通過 Debug 遮擋區域模擬測試的;Apple 所述平放狀態下的零寬度摺痕在程式碼中已完備處理,但模擬器中實測報告的寬度為 40pt。StandBy 只通過 devicectl 調出過,而且只顯示過系統自帶的小工具,因此待機模式在雙螢幕上的具體小工具呈現邏輯仍屬未知;本文的所有結論均尚未在物理硬體上接受最終檢驗。devicectl 相關結論基於 651.13.5 版,duoctl 的基於 0.2.0 版,引用的 Apple 文件以 10月11日的發布版本為準。

繼續閱讀

開發 · App Store閱讀約 4 分鐘

把你的 iPhone Duo App 提交到 App Store

Apple 於 10 月 5 日開放了 iPhone Duo App 的 App Store 提交。該用哪個版本的 Xcode 建置、新的截圖尺寸、2027 年 4 月的新規定,以及如何告訴 App Store 你的 App 已為摺疊螢幕做好準備。

10月6日閱讀指南

開發 · 佈局閱讀約 8 分鐘

用 React Native、Flutter 和 Web 開發 iPhone Duo

從 React Native、Flutter、網頁和 WebView 的角度看摺疊螢幕:每種技術棧免費得到什麼、哪些橋接庫能暴露轉軸和摺痕,以及哪些仍然需要 Xcode。

10月4日閱讀指南