SwiftUI Layout 協定:從 sizeThatFits 到 placeSubviews 打造自訂版面
iOS 16 為 SwiftUI 加入了 Layout 協定,這是用來打造自訂容器檢視、參與 SwiftUI 版面流程的公開 API1。在 Layout 出現之前,自訂容器的形狀要嘛得靠 GeometryReader 的奇技淫巧(這會破壞組合性,因為它會請求完整的提案大小),要嘛得寫與系統對抗的自訂 ViewModifier。Layout 才是正解:這是個雙方法協定(sizeThatFits 和 placeSubviews),加上選用的間距與快取擴充,其合約能與 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,其width與height為選用的 CGFloat。nil代表「使用你的理想大小」;有限值是父級的提議;.infinity代表「想用多少就用多少」。Subviews是LayoutSubviews的型別別名,後者是LayoutSubview代理的集合。每個代理都可被查詢在任何提案下的大小,並能放置於任何位置。這些代理是 Layout 與子項互動的唯一途徑。- 自訂版面值透過附在子檢視上的
.layoutValue(...)所綁定的LayoutValueKey型別,從子項向父級流動;在版面方法內可從LayoutSubview的下標讀取。 cache用於在sizeThatFits與placeSubviews之間攤銷計算(每次版面流程都會呼叫兩者,而且通常使用相同的中間值)。把快取定義為一個持有預先計算尺寸的 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 則用於請求貪婪擴張。
自訂 Layout 的 sizeThatFits 應尊重提案:回傳該版面在所提議邊界下實際想要的大小,而不是永遠回傳同一個寫死的值。寫死的尺寸會破壞版面適應不同容器(卡片檢視、清單儲存格、表單)的能力。
透過 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 參數。快取是版面用來在 sizeThatFits 與 placeSubviews 之間攤銷工作之處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 是正確工具的三個訊號:
- 形狀無法用 HStack/VStack/ZStack/Grid 組合表達。 圓餅版面、磚牆網格、自訂的流式換行。內建的基本元件無法組合出這些形狀。
- 逐子項的資訊驅動定位。 子項帶有優先權、權重或類別,父級依此定位它們的版面。
LayoutValueKey是正確的管道。 - 版面的尺寸取決於與子項的協商。 那些會問「能容納最長一行的最小高度是多少?」或「N 個子項要等寬欄位需要多寬?」的版面,必須能存取
subviews.sizeThatFits(...)查詢。
判斷內建組合就夠用的三個訊號:
- 標準的水平/垂直/縱深堆疊。
HStack、VStack、ZStack涵蓋常見情境。 - 規則行列的網格。
Grid與LazyVGrid/LazyHGrid處理大多數網格情境。 - 少量的覆蓋定位。
.overlay、.background、帶對齊的ZStack涵蓋大多數「X 疊在 Y 上」的模式。
經驗法則:不要為內建元件能處理的形狀打造自訂 Layout。當形狀真的超出了內建元件的表達範圍,再動手。
這個模式對 iOS 26+ 應用程式的意義
三個重點。
-
在
sizeThatFits中尊重提案。 不論proposal為何都回傳同一尺寸的版面,並未真正參與 SwiftUI 的版面系統。讀取提案,回傳適合的大小。 -
使用
LayoutValueKey進行結構化的親子溝通。 透過附在檢視修飾器上的鍵傳遞資料,是 SwiftUI 原生的模式。對於專屬於版面層級決策的資料,別動用@Environment或自訂PreferenceKey;LayoutValueKey才是該情境的型別化管道。 -
只在測量昂貴時才打造快取。 預設的
Void快取適用於多數版面。只有當同樣昂貴的計算同時出現在sizeThatFits與placeSubviews時,才動用自訂快取型別。
完整的 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: .infinity 與 proposal: .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
-
Apple Developer Documentation:
Layout. The protocol reference coveringsizeThatFitsandplaceSubviewsrequirements, plus the optionalmakeCache,updateCache,spacing, and explicit-alignment hooks. ↩ -
Apple Developer Documentation:
sizeThatFits(proposal:subviews:cache:)andplaceSubviews(in:proposal:subviews:cache:). The two required methods of theLayoutprotocol. ↩↩↩ -
Apple Developer Documentation:
ProposedViewSize. The two-optional-CGFloat type that carries the parent’s size proposal, with the convention values.unspecified,.zero, and.infinity. ↩ -
Apple Developer Documentation:
LayoutSubview. The proxy type representing a child view insideLayoutmethods, withsizeThatFits(_:)for querying preferred sizes andplace(at:anchor:proposal:)for positioning. ↩ -
Apple Developer Documentation:
LayoutValueKeyandlayoutValue(key:value:). The typed channel for child-to-parent layout-level data, accessed via subscript onLayoutSubview. ↩ -
Apple Developer: Composing custom layouts with SwiftUI. The Apple guide covering caching, alignment guides, and when to reach for
Layoutversus built-in containers. ↩