← 所有文章

SwiftUI Layout 協定:從 sizeThatFits 到 placeSubviews 打造自訂版面

iOS 16 為 SwiftUI 加入了 Layout 協定,這是用來打造自訂容器檢視、參與 SwiftUI 版面流程的公開 API1。在 Layout 出現之前,自訂容器的形狀要嘛得靠 GeometryReader 的奇技淫巧(這會破壞組合性,因為它會請求完整的提案大小),要嘛得寫與系統對抗的自訂 ViewModifierLayout 才是正解:這是個雙方法協定(sizeThatFitsplaceSubviews),加上選用的間距與快取擴充,其合約能與 SwiftUI 的「父級提議、子級決定」版面模型乾淨地整合。

本文會對照 Apple 的文件來逐一講解這個協定。論述框架是「Layout 實際上對什麼立下合約」,因為誤用模式(把 Layout 當成座標空間工具,而非尺寸協商工具)會產生在某個螢幕上能跑、換到另一個就壞掉的版面;此系列的 What SwiftUI Is Made Of 一文也曾主張,理解 SwiftUI 架構的最佳途徑就是閱讀其公開協定。

TL;DR

  • Layout 是個協定,有兩個必要方法:sizeThatFits(proposal:subviews:cache:) 會根據父級的提案回傳此版面偏好的大小;placeSubviews(in:proposal:subviews:cache:) 透過呼叫每個子項的 place(at:anchor:proposal:) 方法來定位它們2
  • proposal 參數是一個 ProposedViewSize,其 widthheight 為選用的 CGFloat。nil 代表「使用你的理想大小」;有限值是父級的提議;.infinity 代表「想用多少就用多少」。
  • SubviewsLayoutSubviews 的型別別名,後者是 LayoutSubview 代理的集合。每個代理都可被查詢在任何提案下的大小,並能放置於任何位置。這些代理是 Layout 與子項互動的唯一途徑。
  • 自訂版面值透過附在子檢視上的 .layoutValue(...) 所綁定的 LayoutValueKey 型別,從子項向父級流動;在版面方法內可從 LayoutSubview 的下標讀取。
  • cache 用於在 sizeThatFitsplaceSubviews 之間攤銷計算(每次版面流程都會呼叫兩者,而且通常使用相同的中間值)。把快取定義為一個持有預先計算尺寸的 struct;建構一次,於兩個方法中重複使用。

協定的合約

Layout(通常是)一個 struct,宣告兩個由 Apple 框架在版面流程中呼叫的方法2:

struct DiagonalLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Compute and return the size your layout wants
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // Position each subview by calling subview.place(...)
    }
}

像使用內建容器一樣使用它:

DiagonalLayout {
    Text("First")
    Text("Second")
    Text("Third")
}

框架會以父級的提案大小(一個 ProposedViewSize)呼叫 sizeThatFits,接著以版面所獲配的邊界呼叫 placeSubviews。這兩個方法合在一起描述了版面的行為:它想要多大,以及每個子項在該配額內的位置。

ProposedViewSize:父級的提議

SwiftUI 的版面遵循「父級提議、子級決定」的合約3。父級傳遞一個提案大小;子級回傳其實際大小;父級在自身的邊界內定位子級。Layout 透過 ProposedViewSize 參與此合約:

struct ProposedViewSize {
    var width: CGFloat?
    var height: CGFloat?
}

選用的軸線帶有語意:

  • 某軸的 nil 代表「使用你的理想/自然大小」。對 Text 提議 .zero 會回傳其最小寬度(每行一個字元);提議 nil 則回傳其理想寬度(單行不換行)。
  • 有限值 代表「父級提供這麼多空間;由你決定」。對 Text 提議 100pt 寬度時,它可能換行、可能用更少、也可能剛好用 100。
  • .infinity 代表「想用多少就用多少」。對 Color 提議 .infinity 會佔滿可用空間。

慣例上,ProposedViewSize.unspecified(width: nil, height: nil)用於請求理想大小;ProposedViewSize.zero 用於請求最小大小;ProposedViewSize.infinity 則用於請求貪婪擴張。

自訂 LayoutsizeThatFits 應尊重提案:回傳該版面在所提議邊界下實際想要的大小,而不是永遠回傳同一個寫死的值。寫死的尺寸會破壞版面適應不同容器(卡片檢視、清單儲存格、表單)的能力。

透過 LayoutSubview 讀取子檢視大小

sizeThatFits 內,版面會詢問各子項對於各種提案想要什麼大小。查詢透過 LayoutSubview 代理進行4:

func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) -> CGSize {
    let proposed = ProposedViewSize(
        width: proposal.width.map { $0 / CGFloat(subviews.count) },
        height: proposal.height
    )

    let sizes = subviews.map { $0.sizeThatFits(proposed) }
    let totalWidth = sizes.reduce(0) { $0 + $1.width }
    let maxHeight = sizes.map(\.height).max() ?? 0

    return CGSize(width: totalWidth, height: maxHeight)
}

subviews.map { $0.sizeThatFits(proposal) } 是版面探知子項想要何種尺寸的方式。LayoutSubview 代理的 sizeThatFits(_:) 方法 並不 等同於 Layout 協定的方法;它是代理對子項在某個提案下偏好大小的查詢。兩者同名是因為它們參與同一場協商,但它們屬於合約的不同層次。

想得知子項大小的版面會呼叫 proxy.sizeThatFits(_:)。想定位子項的版面則在 placeSubviews 內呼叫 proxy.place(at:anchor:proposal:)

放置子檢視

placeSubviews 是版面做出定位決策之處2:

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) {
    var x = bounds.minX
    let y = bounds.midY

    for subview in subviews {
        let size = subview.sizeThatFits(.unspecified)
        subview.place(
            at: CGPoint(x: x + size.width / 2, y: y),
            anchor: .center,
            proposal: ProposedViewSize(size)
        )
        x += size.width
    }
}

place(at:anchor:proposal:) 呼叫會放置單一子檢視。三個參數:

  • at:在父級座標空間中的位置。
  • anchor:子檢視的哪一點要對齊到 at.center 把子檢視的中心對齊到 at;.topLeading 則把左上角放在那裡。
  • proposal:子檢視應依此大小渲染。傳入該子項 sizeThatFits 所回傳的大小以尊重其偏好,或傳入自訂提案來限制它。

每次 placeSubviews 呼叫中,每個子檢視都必須恰好被放置一次。漏放某個子項會讓它沒有位置(它會從渲染版面中消失);放兩次則是執行期錯誤。

透過 LayoutValueKey 傳遞自訂版面值

當子項需要向其父級版面傳達某事(優先權、跨距、類別)時,管道就是 LayoutValueKey5:

struct PriorityKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

extension View {
    func layoutPriority(_ value: Int) -> some View {
        layoutValue(key: PriorityKey.self, value: value)
    }
}

// Inside the Layout:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let sortedSubviews = subviews.sorted {
        $0[PriorityKey.self] > $1[PriorityKey.self]
    }
    // ... place sortedSubviews
}

LayoutValueKey 協定為親子溝通提供了型別化的管道。子項透過版面值修飾器附上一個值;父級透過 LayoutSubview 的下標讀取它。每個鍵對沒有明確指定的子檢視都有一個預設值。

這個模式概念上等同於 .layoutPriority(_:) 等內建修飾器所表達的內容。框架透過 LayoutSubview 上的專屬 priority: Double 屬性公開這個特定值,而非透過公開的 LayoutValueKey,所以版面優先權的代理存取是 subview.priority,而不是鍵下標。自訂版面則為它們從子項所需的任何其他結構化資料宣告自己的 LayoutValueKey 型別。

cache 參數

兩個版面方法都會收到 cache: inout 參數。快取是版面用來在 sizeThatFitsplaceSubviews 之間攤銷工作之處6:

struct DiagonalLayout: Layout {
    struct Cache {
        var sizes: [CGSize]
    }

    func makeCache(subviews: Subviews) -> Cache {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return Cache(sizes: sizes)
    }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
        let totalWidth = cache.sizes.reduce(0) { $0 + $1.width }
        let totalHeight = cache.sizes.reduce(0) { $0 + $1.height }
        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
        var x = bounds.minX
        var y = bounds.minY
        for (subview, size) in zip(subviews, cache.sizes) {
            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .topLeading,
                proposal: ProposedViewSize(size)
            )
            x += size.width
            y += size.height
        }
    }
}

cache 的預設型別是 Void。多數版面可以忽略快取;它真正派上用場的時機,是當尺寸計算確實昂貴(遞迴測量、動態尺寸決策),而且同樣的中間值會餵給兩個版面方法時。

makeCache(subviews:) 在每次版面流程執行一次;updateCache(_:subviews:) 則在兩次流程之間子項變動時執行。這個模式能讓版面在子項本身改變時,正確地讓快取狀態失效。

值得自己打造的常見自訂版面

三種值得自己打造的模式:

Flow layout(項目換行)。 當項目超出可用寬度時換到下一列。Apple 的 HStack 不會換行。自訂 Layout 可以做到:測量每個子項,從左到右擺放,當該列寬度超過提案寬度時就換到下一列。

Diagonal stack(斜向堆疊)。 項目斜向錯置(每個子項相對前一個略微往右下偏移)。適用於堆疊式卡片 UI、相簿預覽版面、有視差感的堆疊。

Pie/circle layout(圓餅/環狀版面)。 項目沿著圓周排列。適用於放射式選單、時間軸 UI、等距類別標籤。

以上每一種都能用 sizeThatFits + placeSubviews 加(選用的)自訂快取實作。框架負責處理「父級提議、子級決定」的協商;開發者負責放置位置的數學計算。

常見的版面失敗模式

三種會產生壞掉的自訂版面的模式:

忽略提案的寫死尺寸。 永遠回傳 CGSize(width: 200, height: 100) 的版面無法適應其容器。結果是:版面在模擬器中看起來沒問題,但在較小螢幕、不同方向或可調整大小的容器內就壞了。

placeSubviews 中漏放子項。 每次呼叫,每個子項都必須恰好被放置一次。一個 for 迴圈如果在某個條件下 continue,那些子項就沒有位置;它們會從渲染輸出中消失。

在自訂 Layout 的子項內使用 GeometryReader GeometryReader 永遠把它收到的完整空間提案給內容,這會與版面的逐子項提案對抗。組合起來會產生荒謬的尺寸。自訂版面不該把 GeometryReader 放進自身內部;若子項需要知道它被分配到的尺寸,版面協定的提案機制才是正確的管道。

何時該動用 Layout(以及何時不該)

判斷自訂 Layout 是正確工具的三個訊號:

  1. 形狀無法用 HStack/VStack/ZStack/Grid 組合表達。 圓餅版面、磚牆網格、自訂的流式換行。內建的基本元件無法組合出這些形狀。
  2. 逐子項的資訊驅動定位。 子項帶有優先權、權重或類別,父級依此定位它們的版面。LayoutValueKey 是正確的管道。
  3. 版面的尺寸取決於與子項的協商。 那些會問「能容納最長一行的最小高度是多少?」或「N 個子項要等寬欄位需要多寬?」的版面,必須能存取 subviews.sizeThatFits(...) 查詢。

判斷內建組合就夠用的三個訊號:

  1. 標準的水平/垂直/縱深堆疊。 HStackVStackZStack 涵蓋常見情境。
  2. 規則行列的網格。 GridLazyVGrid/LazyHGrid 處理大多數網格情境。
  3. 少量的覆蓋定位。 .overlay.background、帶對齊的 ZStack 涵蓋大多數「X 疊在 Y 上」的模式。

經驗法則:不要為內建元件能處理的形狀打造自訂 Layout。當形狀真的超出了內建元件的表達範圍,再動手。

這個模式對 iOS 26+ 應用程式的意義

三個重點。

  1. sizeThatFits 中尊重提案。 不論 proposal 為何都回傳同一尺寸的版面,並未真正參與 SwiftUI 的版面系統。讀取提案,回傳適合的大小。

  2. 使用 LayoutValueKey 進行結構化的親子溝通。 透過附在檢視修飾器上的鍵傳遞資料,是 SwiftUI 原生的模式。對於專屬於版面層級決策的資料,別動用 @Environment 或自訂 PreferenceKey;LayoutValueKey 才是該情境的型別化管道。

  3. 只在測量昂貴時才打造快取。 預設的 Void 快取適用於多數版面。只有當同樣昂貴的計算同時出現在 sizeThatFitsplaceSubviews 時,才動用自訂快取型別。

完整的 Apple Ecosystem 系列:型別化的 App Intents;MCP 伺服器;路由問題;Foundation Models;執行期 vs 工具 LLM 的區別;三種介面;單一資料源模式;兩個 MCP 伺服器;Apple 開發的 hooks;Live Activities;watchOS 執行期;SwiftUI 內部機制;RealityKit 的空間心智模型;SwiftData 結構紀律;Liquid Glass 模式;多平台出貨;平台矩陣;Vision 框架;Symbol Effects;Core ML 推論;Writing Tools API;Swift Testing;Privacy Manifest;輔助使用作為平台;SF Pro 字體系統;visionOS 空間模式;Speech 框架;SwiftData 遷移;tvOS 焦點引擎;@Observable 內部機制;我拒絕寫的主題。樞紐位於 Apple Ecosystem Series。若想了解更廣泛的 iOS 與 AI 代理脈絡,請參閱 iOS Agent Development guide

FAQ

為什麼不直接使用 GeometryReader?

GeometryReader 永遠把收到的完整大小提案給內容(它對內容想要什麼毫無意見)。結果是:GeometryReader 內任何檢視在它不約束的軸線上都會被提案 infinity,而像 Text 這類檢視會貪婪地把自己撐大。組合自相對抗:reader 原封不動地傳遞,內容要求最大尺寸,版面就壞了。Layout 之所以是正確工具,是因為它讓開發者能對提案大小做出明確的逐子項決策。

我可以寫一個自訂的 HStack 替代品嗎?

可以。等同於 HStack 的自訂 Layout 會讀取子項的偏好大小、加總寬度、取最大高度,並由左至右擺放它們。實際的 HStack 還做了更多事(間距、對齊、版面優先權解析),但基本形狀在 Layout 中很直觀。這項練習是內化此協定運作方式的好方法。

我要如何在自訂版面中支援 .layoutPriority(_:)?

透過 LayoutSubview 代理的專屬 priority: Double 屬性讀取:subview.priority。SwiftUI 直接在代理上公開 .layoutPriority(_:),而非透過公開的 LayoutValueKey。預設值為 0。在分配額外空間時(優先給高優先權的子項),或在截斷時(先截斷低優先權的子項)使用此優先權。

proposal: .infinityproposal: .zero 有何差異?

.infinity 在每個軸都提案最大尺寸(width: .infinity, height: .infinity)。對貪婪提案有反應的子項(如 Color)會佔滿可用空間。.zero 則提案最小尺寸(width: 0, height: 0)。子項回傳其最小大小(Text 會回傳其最長不可斷裂 token 的大小)。兩者是測量子項尺寸範圍的有用端點;許多版面則使用 .unspecified(兩者皆 nil)來詢問「你的理想大小是什麼?」。

Layout 在 watchOS、tvOS、visionOS 上能用嗎?

可以。Layout 協定位於 SwiftUI 的跨平台核心。自訂版面在 iOS、iPadOS、macOS、watchOS、tvOS 與 visionOS 上的運作方式相同。系列中 Apple Platform Matrix 一文主張平台納入是項產品決策;對於多平台適用的情境,SwiftUI 的 Layout 機制本身與平台無關。

Layout 如何與 @Observable 模型互動?

Layout 是個 struct,本身不持有可觀察狀態;它不追蹤變更。當模型更新時,父檢視的 body 會重新求值,進而導致 Layout 以該 body 產出的子項重新執行。Layout 是透過它所在的 body 而具有反應性,而非透過自身的觀察 hook。系列中 @Observable internals 一文涵蓋了觀察面的內容。

References


  1. Apple Developer Documentation: Layout. The protocol reference covering sizeThatFits and placeSubviews requirements, plus the optional makeCache, updateCache, spacing, and explicit-alignment hooks. 

  2. Apple Developer Documentation: sizeThatFits(proposal:subviews:cache:) and placeSubviews(in:proposal:subviews:cache:). The two required methods of the Layout protocol. 

  3. Apple Developer Documentation: ProposedViewSize. The two-optional-CGFloat type that carries the parent’s size proposal, with the convention values .unspecified, .zero, and .infinity

  4. Apple Developer Documentation: LayoutSubview. The proxy type representing a child view inside Layout methods, with sizeThatFits(_:) for querying preferred sizes and place(at:anchor:proposal:) for positioning. 

  5. Apple Developer Documentation: LayoutValueKey and layoutValue(key:value:). The typed channel for child-to-parent layout-level data, accessed via subscript on LayoutSubview

  6. Apple Developer: Composing custom layouts with SwiftUI. The Apple guide covering caching, alignment guides, and when to reach for Layout versus built-in containers. 

相關文章

iOS 26 上的 HealthKit + SwiftUI:授權、樣本類型,以及兩款上架 App 的跨平台模式

來自 Water(飲水追蹤、HKQuantitySample)與 Return(正念冥想、HKCategorySample)的真實生產級模式。權限體驗、async 包裝、watchOS 變體,以及務必避開的陷阱。

5 分鐘閱讀

SwiftUI 中的 Liquid Glass:在 iOS 26 上推出 Return 學到的三種模式

Apple 的 Liquid Glass 是一行 SwiftUI API。Return 的三種模式超越了 .glassEffect():透過 Core Text 字形路徑將玻璃套用於文字、鏡面反射,以及 HUD 疊加層。

7 分鐘閱讀

迴圈工程:在驗證成本低廉之處,迴圈才能取勝

以 Boris Cherny 的完整逐字稿驗證迴圈工程:他點名的每一個迴圈,驗證成本都很低廉。這項限制決定了什麼適合自動化。

4 分鐘閱讀