← 所有文章

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. 

相關文章

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 分鐘閱讀

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 分鐘閱讀

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分鐘閱讀