iOS resizability 設計思想:當 App 不再知道自己跑在什麼裝置上
五條設計原則,逐條落實到程式碼:視窗縮放時代,App 只關心自己有多大空間。含場景配置、幾何回呼、兩級接力、Info.plist 紅線與測試矩陣。
iOS resizability 指的是:應用程式的視窗可以像桌面應用一樣,被使用者抓住邊緣自由拖曳、改變大小。對這個變化,蘋果給出的設計思想可以壓縮成一句話——App 不再關心自己跑在什麼裝置、什麼方向、哪塊螢幕上,只關心自己此刻擁有多大的可用空間。 你負責定義「空間寬時怎麼擺、空間窄時怎麼擺」兩套邏輯,系統負責把它們套到所有裝置、所有視窗尺寸和所有摺疊態上。
這不是一次普通的 API 更新,而是 iOS 誕生以來佈局範式的一次換軌。這篇文章把 resizability 的設計思想拆成五條原則,再逐條落實到 UIKit 與 SwiftUI 的程式碼上,最後給一份能直接照著執行的遷移路線圖和測試矩陣。
文中簽名對照 Xcode 27.1 的 iOS 27.1 SDK,行為依據來自 Apple 官方文件與 WWDC 場次,文末列出來源。本文是轉述與工程化整理,不是官方頁面的譯文。
一、iOS 26 到 27:resizability 是怎麼變成預設的
理解設計思想之前,先看這條時間線。它解釋了為什麼「以前那套」現在不夠用了。
| 階段 | 發生了什麼 | 對開發者的意義 |
|---|---|---|
| iPadOS 15 | Split View / Slide Over / Stage Manager | iPad 開始出現非全螢幕尺寸 |
| iPadOS 26 | iPad 進入自由視窗時代,任意拖曳縮放 | 「iPad = regular」的假設開始鬆動 |
| iOS 26 SDK | UIRequiresFullScreen 被棄用 |
無法再用全螢幕鍵退出縮放 |
| iOS 26 SDK | iPhone App 跑在 iPad 或 Mac 鏡像裡也可縮放 | 「跑在 iPhone 上」不再等於 compact |
| iOS 27 | 最新 SDK 建置的 App 強制採用 UIScene 生命週期 | 場景成為 resizability 的地基 |
| iOS 27 | 標籤頁欄、工具列等系統元件全面適配任意寬高比 | 系統元件成為自適應的主力 |
一句話概括這條線:視窗尺寸從「列舉值」變成了「連續值」。 過去你的 App 面對的是 375、393、414、768、1024 這幾個離散檔位;現在使用者的手指捏住視窗邊緣一拖,寬度可以是 320 到螢幕上限之間的任何一個數。為離散檔位寫死的佈局,在連續值面前必壞。
二、設計思想一:從「裝置思維」到「空間思維」
舊的佈局知識幾乎都建立在裝置思維上:這是 iPhone,橫螢幕了,所以這樣擺;這是 iPad,所以分兩欄。resizability 要求把這套思維整個換掉。
| 裝置思維(舊) | 空間思維(新) | |
|---|---|---|
| 判斷依據 | 裝置類型、方向、螢幕尺寸 | 當前容器可用空間 |
| 佈局數量 | 每個裝置一版 | 一套規則覆蓋所有空間 |
| 新裝置出現 | 可能需要改程式碼、發新版 | 自動套用,零改動 |
| 摺疊螢幕形態 | 每種姿態長一套介面 | 同一套介面跟隨寬度伸縮 |
| 程式碼裡的氣味 | if idiom == .pad、isLandscape |
horizontalSizeClass、容器寬度 |
空間思維最直白的表述來自 WWDC:你不需要為每個平台、每個視窗尺寸單獨設計,你只需定義一次內容「寬時怎麼排、窄時怎麼排」,系統把這份邏輯應用在所有寬高比上。旋轉螢幕只是這份邏輯的一次觸發事件,與拖曳視窗、分屏、摺疊展開沒有本質區別——它們最終都歸結為同一件事:可用空間的尺寸變了。
三、設計思想二:size class 是新的世界語
裝置思維退場後,什麼上來接棒?size class。它是框架對空間做的定性判斷:兩根軸,每根軸兩個字——compact 表示緊張,regular 表示寬裕。
horizontal 軸看左右:compact 就該單欄縱向堆疊,regular 才敢分欄、並排、上側邊欄。
vertical 軸看上下:compact 時上下 chrome(標籤頁欄、工具列)放不下,系統会把橫排標籤頁欄改成豎排;regular 時頂底欄可以同時常駐。
SwiftUI 裡直接讀取:
@Environment(\.horizontalSizeClass) private var hSize // UserInterfaceSizeClass?
@Environment(\.verticalSizeClass) private var vSize
if hSize == .regular {
// 左右寬裕:分欄 / 側邊欄 / 雙列
} else {
// 左右緊張:單欄堆疊
}
UIKit 裡則由 traitCollection.horizontalSizeClass 承擔同樣職責,並且能用 registerForTraitChanges 精確訂閱變化。
接下來是全文最重要的一張表——size class 和裝置、方向都不是一對一關係:
| 場景 | horizontal | vertical | 說明 |
|---|---|---|---|
| iPhone 直式螢幕 | compact | regular | 全 App 的預設形態 |
| iPhone 橫式螢幕 | compact | compact | 高度只剩約 320pt,標籤頁欄豎排 |
| iPhone 視窗拖大(iOS 26+) | regular | regular | 執行期可變,不是啟動時定死 |
| iPad 全螢幕 | regular | regular | 寬度約 1024pt |
| iPad 分屏一半 | regular | regular | 寬度只有約 507pt,仍是 regular |
| iPad Slide Over | compact | compact | iPad 上的 compact 形態 |
| Duo 外螢幕 | compact | compact | 外螢幕約 466pt 寬 |
| Duo 展開內螢幕 | regular | compact | 摺疊態切換,執行期可變 |
把這張表讀三遍,兩個反直覺的結論就浮出來了:「跑在 iPhone 上」不再等於 compact(視窗拖大就是 regular),「跑在 iPad 上」也不再等於 regular(Slide Over 就是 compact)。裝置類型和螢幕方向都回答不了「我現在該用什麼佈局」,只有執行期去問 size class。
這張表還矯正一個老觀念:旋螢幕不是「裝置變了」。旋螢幕只是 (H, V) 組合從 (compact, regular) 切換到 (compact, compact)、外加一次 bounds 變化。佈局程式碼的結構不用動,動的只是輸入。把「方向」當成佈局概念的日子裡,它一直是一個代理變數;現在代理沒了,只剩空間本身。
size class 的斷崖:為什麼還需要第二級
size class 是二值化的,粗到會「說謊」。看這個斷崖:iPad 全螢幕 1024pt 和分屏一半 507pt,都是 (regular, regular)——但前者放得下側邊欄,後者放不下。兩個字回答不了「幾列、多大、限不限寬」這類程度問題。
所以成熟的做法是兩級接力:size class 回答方向性問題(單欄還是分欄),工程策略回答程度問題(幾列、Hero 多大、是否限寬)。 第二級通常按實測容器寬度分檔,比如寬螢幕限寬讓正文不超過舒服的行寬,或者按內容最小尺寸決定網格列數。iOS 17 起 SwiftUI 可以直接用 onGeometryChange 實測容器尺寸參與判斷,不必再依賴 GeometryReader。
四、設計思想三:功能不隨尺寸改變,只改變「可見量」
這是 Human Interface Guidelines 裡一條容易被忽略但極其重要的原則:size class 變化時,功能集合保持不變,變的只是「一次能看到多少」。
寬螢幕上標籤頁欄可以展開成側邊欄,露出二級目標;窄視窗裡低優先權的工具項收進溢出選單,一次點擊仍可到達。收納是一種重排,不是刪除。 如果一個操作在寬螢幕佈局裡可用、在窄螢幕裡憑空消失,這不是自適應,這是功能殘缺。同理,橫直螢幕之間不應該出現「這個頁面只有一個方向能完整操作」的設計——能在直式做的事,橫式要有頭有尾。
對產品設計者的隱含要求是:做資訊架構時就給每個元素標註優先權。分享、主操作這類高優先權項在所有尺寸下常駐;儲存、次級設定這類低優先權項寬時常駐、窄時進溢出選單。系統元件的 Visibility Priority 機制幹的就是這件事。
五、設計思想四:min / max 尺寸是防重疊,不是拒絕縮放
UIKit 允許場景宣告最小、最大尺寸,但語意要說清楚:這兩個值的存在是為了防止元素在小尺寸下互相遮擋、在大尺寸下鬆散失控,不是給使用者設置「不許再拖小」的門檻。 當一個視窗被允許拖到任何尺寸,你的責任是保證任何尺寸下內容都至少可用、可讀、不重疊;如果某個尺寸下必然破碎,那說明你的最小尺寸宣告得太貪心,或者佈局本身還沒有真正自適應。
場景連接時宣告:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// 下限:再小下去元素就開始重疊;上限:再大佈局開始鬆散
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 320, height: 480)
}
配套的一條舊帳:UIRequiresFullScreen 已棄用,持它的 App 無法退出縮放,等於自動放棄新的顯示尺寸。從 iOS 26 SDK 起,App 不再需要為新螢幕重寫程式碼或重新提交,系統會自動適配——前提是你不鎖死自己。
六、設計思想五:讓系統元件替你幹活
resizability 時代最省力的策略,是把重排的活交給系統元件。它們對任意寬高比的處理遠比手寫佈局穩健:
標籤頁欄 / 工具列:視窗變寬時標籤圖示與標籤同行排列保持緊湊;視窗變窄時低優先權工具項自動移入溢出選單。新聞類 App 裡「分享」可以釘在所有尺寸下常駐,「收藏」窄時才進溢出選單——透過 Visibility Priority 宣告,不用寫一行佈局程式碼。
側邊欄:iPadOS 26 起 iPhone App 在空間允許時也能主動選側邊欄佈局,用
sidebar.isAvailable判斷當前空間展示得了再請求。NavigationSplitView:iOS 26 起列會根據空間自動顯隱,不再需要手工管理列可見性。
Liquid Glass 系統材質:在 iOS 27 上獲得改進的漫射與邊緣分離度,不重編譯也能受益。
你自己的 UI 要做的只有一件事:別和這些元件打架。自繪頂欄要避開視窗左上角的關閉/最小化控件(用帶水平角適應的佈局參考線),別假設標籤頁欄永遠在底部、工具列永遠在頂部——它們的形態本身就會隨空間改變。
七、UIKit 落地:場景配置與幾何響應
場景是 resizability 的地基:每個場景獨立管理自己的 UI 實例和狀態,並提供「這塊 UI 正顯示在哪、有多大」的上下文。iOS 27 起,用最新 SDK 建置的 App 必須採用 UIScene 生命週期,這不是可選項。
一份可以直接抄的場景委派骨架:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// 1) 尺寸能力宣告:防重疊,不拒絕縮放
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 320, height: 480)
// 2) 視窗掛在場景上,禁止 UIWindow(frame: UIScreen.main.bounds)
let window = UIWindow(windowScene: windowScene)
window.rootViewController = AdaptiveRootController()
self.window = window
window.makeKeyAndVisible()
}
// 3) 幾何變化回呼:拖曳、分屏、摺疊展開、換外接螢幕都走這裡
func windowScene(_ windowScene: UIWindowScene,
didUpdateEffectiveGeometry previous: UIWindowScene.Geometry) {
let geometry = windowScene.effectiveGeometry
let size = geometry.coordinateSpace.bounds.size
// isInteractivelyResizing 為 true 說明使用者正捏著邊緣拖
// 重活(重建資源、重算圖表)等鬆手後一次做完,別逐幀渲染
if !geometry.isInteractivelyResizing,
size != previous.coordinateSpace.bounds.size {
AssetStore.shared.rebuild(for: size)
}
}
// 4) iPadOS 26+ 視窗按鈕(關閉/最小化)樣式偏好
func preferredWindowingControlStyle(for scene: UIWindowScene)
-> UIWindowScene.WindowingControlStyle {
.unified
}
}
三個要點值得展開:
isInteractivelyResizing 是拖曳效能的生命線。 使用者拖視窗的過程中系統會連續上報幾何變化,遊戲和應用如果對每個中間尺寸都重建渲染資源,幀率會直接崩。標準姿勢是拖曳中只做輕量更新(改約束、改字級),鬆手後一次性做重活。
取螢幕、取尺寸永遠不要經過 UIScreen.main。 iPhone 鏡像和外接螢幕場景下 main screen 是錯的。正確來源是 view.window?.windowScene?.screen、場景的 effectiveGeometry,或視圖本地的 view.bounds。
視窗控件避讓。 iPadOS 26 起場景左上角有關閉/最小化控件,自繪頂欄要約束到 containerView.layoutGuide(for: .margins(cornerAdaptation: .horizontal)),否則使用者點不到你的第一個按鈕。
八、視圖層:刪掉三個舊模式
自適應的 UIKit 視圖程式碼,本質是一次「三個替換」:
| 刪掉 | 換成 | 原因 |
|---|---|---|
UIScreen.main.bounds |
view.bounds / effectiveGeometry / windowScene?.screen |
分屏與鏡像下 main screen 不是你的視窗 |
userInterfaceIdiom == .pad 分支 |
traitCollection.horizontalSizeClass |
裝置類型不含佈立意義 |
interfaceOrientation 相關分支 |
size class + 本地 bounds | 方向是佈局的結果,不是佈局的原因 |
替換之後,trait 變化驅動佈局:
class AdaptiveRootController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// size class 變化時重排;自動追蹤覆蓋不到的快取失效同樣用註冊
registerForTraitChanges([UITraitHorizontalSizeClass.self,
UITraitVerticalSizeClass.self]) { (vc: Self, _) in
vc.applyLayout()
}
}
private func applyLayout() {
switch (traitCollection.horizontalSizeClass, traitCollection.verticalSizeClass) {
case (.regular, _): useSidebarLayout()
case (.compact, _): useStackedLayout()
default: useStackedLayout()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 需要比 size class 更細的判斷時,用本地可用尺寸
fineTune(for: view.bounds.size)
}
}
注意 viewDidLayoutSubviews / layoutSubviews 是持續生效的:size class 觸發的是「結構重排」,bounds 變化觸發的是「細粒度微調」。一次性的 onAppear / viewDidLoad 裡讀一次尺寸存起來,是開始縮放後最先過時的程式碼。
九、SwiftUI 對照:多數自動,關鍵處兜底
用 SwiftUI 寫的 App 在 resizability 上佔便宜:用 Xcode 27 重新編譯,視窗縮放支援自動生效,沒有新 API 要採納,Liquid Glass 新材質也自動繼承。但「自動」不等於「免檢」,以下機制構成 SwiftUI 側的兜底工具箱:
@Environment(\.horizontalSizeClass)/verticalSizeClass:對應 UIKit trait,回答方向性問題。onGeometryChange(for:of:action:):實測容器尺寸,回答程度性問題。它只讀尺寸、不招惹GeometryReader的貪婪佔位問題,且只有派生值真正變化時才回呼,天然過濾拖曳過程中的中間態。ViewThatFits:「哪個裝得下用哪個」的宣告式寫法,適合內容在不同寬度下換排列方式的場景。containerRelativeFrame():按容器比例而非絕對點數定尺寸,寫背景延展區域時尤其省心。NavigationSplitView:iOS 26 起列寬與列可見性自適應,不要在它外面再包一層手工列管理。.windowResizability(.contentMinSize):SwiftUI 場景宣告最小尺寸的入口,等價於 UIKit 的sizeRestrictions?.minimumSize。
一個典型的兩級接力寫法:
struct DashboardView: View {
@Environment(\.horizontalSizeClass) private var hSize
@State private var columnCount = 1
var body: some View {
ScrollView {
MetricGrid(columns: columnCount)
}
// 第一級:size class 決定單欄還是分欄的世界觀
.onChange(of: hSize) { _, new in
usesSidebar = (new == .regular)
}
// 第二級:實測寬度決定網格幾列,覆蓋同為 regular 的 507 與 1024
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { _, width in
columnCount = AdaptiveWindowLayoutPolicy.tier(for: width).columns
}
}
}
十、Info.plist 與 Launch Screen 紅線
遷移清單裡有兩條來自工程配置的紅線,漏掉任何一條都會在上線前才爆炸:
UIRequiresFullScreen必須處理。 已棄用,且持它的 App 無法參與縮放。要麼刪掉;如果確實有歷史相容性訴求,用UIRequiresFullScreenIgnoredStartingWithVersion顯式宣告從哪個版本起忽略該鍵。遊戲類 App 會因此進入 discrete resizing:每次拖完切換到匹配尺寸的新螢幕配置,保 full-quality 渲染。Launch Screen 成為硬性要求。 iOS 27 SDK 起,沒有配置 Launch Screen 的 App 無法通過 App Store 提交(TN3208)。SwiftUI 工程裡這通常意味著補一個最小的啟動屏配置,而不是繼續裸奔。
十一、反模式速查表
改程式碼前先全文搜一遍這些模式,搜到的每一處都是待還的債:
| 反模式 | 為什麼錯 | 換成 |
|---|---|---|
UIScreen.main.bounds |
分屏/鏡像下不是你的視窗 | 本地 view.bounds 或場景幾何 |
UIDevice.current.userInterfaceIdiom 分支 |
裝置類型無佈局語義 | horizontalSizeClass |
interfaceOrientation / 方向通知 |
報裝置方向,不報視窗形狀 | size class + 本地 bounds |
size.width > 600 自造斷點 |
繞過 trait 語義,漏掉分屏中間態 | 工程分檔策略 + onGeometryChange |
onAppear 裡讀一次尺寸 |
縮放是持續的,快取立刻過期 | trait / 幾何回呼 |
| 只為 iPad 寫寬佈局 | Slide Over 的 iPad 行為像手機 | 寬窄兩套邏輯都用空間觸發 |
寫死 .frame(height: 480) 裝地圖/頭圖 |
矮視窗下正文被頂出首屏 | 高度按容器比例或自適應 |
十二、遷移路線圖
按風險和收益排序的四步走:
Phase 1 · 拆雷(先行,阻塞上線):刪 UIRequiresFullScreen、補 Launch Screen、確認 UIScene 生命週期在用。這三項是硬性門檻,與設計品質無關,漏了直接卡審核或卡建置。
Phase 2 · 清反模式:按第十一節的表全文替換 UIScreen.main、idiom / 方向分支、自造寬度斷點。替換的目標不是「能跑」,而是把佈局的輸入源統一到空間語義上。
Phase 3 · 補兩級分檔:給內容型頁面建立「size class 定方向 + 實測寬度定程度」的策略層,收斂所有裸閨值到同一處。寬螢幕注意正文限寬,避免一行拉到 85 字元以上。
Phase 4 · 系統化測試:按第十三節的矩陣逐尺寸驗證,把結果固化為回歸項。
十三、測試矩陣
resizability 的測試不按裝置清單做,按空間形態做。每種形態都要回答:功能是否完整、內容是否被裁、chrome 是否可點。
| 空間形態 | 重點驗證 |
|---|---|
| 最窄可用寬度(接近 minimumSize) | 元素是否重疊、主要按鈕是否可點 |
| 標準手機寬度 | 預設佈局基線 |
| 手機橫螢幕 | vertical compact 下標籤頁欄豎排、首屏內容完整 |
| 手機視窗拖大到 regular | 分欄是否接管、狀態是否保持 |
| 分屏一半 / Slide Over | 同為 iPad 的兩種空間行為 |
| 又寬又扁 / 又高又窄的極端比例 | 背景延展是否露餡、內容是否被拉爆 |
| Duo 外螢幕與展開內螢幕 | 摺疊切換時佈局與狀態是否連續 |
| iPhone 鏡像 / 外接螢幕 | 取屏與取尺寸是否正確 |
工具側:Xcode 27 的 Device Hub 與 Previews 提供視窗 resize 模式,拖邊緣即可連續改尺寸;IDE 層面做真機驗證補鏡像與外接螢幕。
十四、常見問題
為什麼我的 iPhone App 突然能被拖大視窗了?
這不是 bug,是 iOS 26 SDK 起的新行為:iPhone App 在 iPad 上和 Mac 的 iPhone 鏡像裡執行時,視窗可自由縮放。對應的佈局信號是 horizontal size class 從 compact 變成 regular。之前的 if idiom == .phone 程式碼在這類視窗裡會給出錯誤佈局,改問 size class。
size class 夠用嗎,為什麼還要實測寬度?
不夠,且斷崖真實存在。iPad 全螢幕和分屏一半都是 (regular, regular),寬度卻相差一倍,一個放得下側邊欄一個放不下。size class 定方向(單欄/分欄),實測寬度定程度(幾列/多大)。兩者是接力,不是二選一。
宣告了 minimumSize,使用者還能把視窗拖更小嗎?
minimumSize 設定的是「保證你的內容不亂疊」的下限,使用者拖到這個下限就拖不動了——這是防重疊機制,不是拒絕縮放的功能門禁。真正該保證的是:從 minimumSize 到 maximumSize 之間任何尺寸,App 都可用。做不到就說明自適應沒寫完,而不是調大最小值。
UIRequiresFullScreen 刪了會怎樣?
刪了之後 App 參與視窗縮放,這是 iOS 26+ 的預設預期。擔心舊版相容可以用 UIRequiresFullScreenIgnoredStartingWithVersion 指定忽略版本;擔心遊戲渲染品質則會進入 discrete resizing,切到匹配尺寸的螢幕配置,反而更保畫質。
SwiftUI 應用需要做什麼?
重新編譯即獲得縮放支援,沒有新 API 要採納。要自查的是:有沒有依賴 UIScreen 類舊假設的程式碼、尺寸判斷是否只用 size class 而沒兜底實測、固定 frame 的元件在極端寬高比下是否破裂。框架管宏觀,破裂的元件通常是你自己寫死的那個。
參考與延伸閱讀
官方來源:WWDC25 session 282「Make your UIKit app more flexible」、WWDC26 session 278「Modernize your UIKit app」、Human Interface Guidelines 的 Layout 與 Designing for iPhone Duo、UISceneSizeRestrictions 與 UIWindowScene.Geometry 文件、技術說明 TN3192(從棄用的 UIRequiresFullScreen 鍵遷移) 與 TN3208(Launch Screen 要求)。
站內延伸:想看到這套思想在一台具體裝置上的完整落地,可以讀為 iPhone Duo 做介面:從佈局到程式;想理解「資料也要隨空間收縮」的一面,看個人基線指南;更多產品與適配問題見常見問題。
繼續閱讀
在 Stress HRV 裡體驗這些想法
App Store 下載這些筆記分享個人健康工具的產品思考,不構成醫療建議。