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 紅線

遷移清單裡有兩條來自工程配置的紅線,漏掉任何一條都會在上線前才爆炸:

  1. UIRequiresFullScreen 必須處理。 已棄用,且持它的 App 無法參與縮放。要麼刪掉;如果確實有歷史相容性訴求,用 UIRequiresFullScreenIgnoredStartingWithVersion 顯式宣告從哪個版本起忽略該鍵。遊戲類 App 會因此進入 discrete resizing:每次拖完切換到匹配尺寸的新螢幕配置,保 full-quality 渲染。

  2. 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 下載

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