移除從測量尺寸回傳至產生該尺寸的框架的路徑,即可停止 SwiftUI 幾何迴圈。若 Mac 視窗中有多個共用寬度的標籤,請在 Layout 容器內計算其尺寸,而不要將測量值儲存在狀態中。
本指南會在你自己的 App 視窗中建立一個小型標籤面板。標籤使用合成的本機文字、共用寬度,並會在視窗變窄時換行。完整範例以 macOS 13 或更新版本為目標;後續章節會說明介面包含 AppKit 文字檢視時,如何採用相同的尺寸配置原則。
幾何測量如何形成版面配置迴圈?
SwiftUI 版面配置包含提案與回應:父檢視提出尺寸,子檢視回報自己選擇的尺寸。自訂容器透過 ProposedViewSize 參與這項協商。因此,反覆測量檢視是正常的;光是反覆測量,並不表示有問題。請參閱 Apple 的 ProposedViewSize 文件。
當測量結果改變了正在測量的版面配置輸入值時,就會出現有問題的相依關係。例如,標籤回報加上內距後的寬度,某個動作將該寬度儲存起來,外層框架再使用儲存的值加上更多內距。如此產生的框架會改變下一次測量結果。Apple 明確說明了這種從幾何讀取器回饋至框架的迴圈,並建議此類排列方式應使用版面配置系統。請參閱 使用 SwiftUI 組合自訂版面配置。
簡單計算即可看出為何相等性檢查可能無濟於事。假設某個示意實作測量前一個框架寬度再加上 16 點內距,然後將結果設為下一個框架寬度。從 120 點開始,會依序得到 136、152 和 168 點。每個結果都與前一個不同,因此防止指定未變值的檢查無法停止尺寸持續增長。這些數字只是用來說明相依關係,並非從執行中的 App 測得。
在選擇修正方式之前,先追查自己程式碼中的相依關係:
- 找出幾何讀取器、偏好值處理器或幾何變化動作。
- 找出所有接收其測量結果的狀態值。
- 找出所有相依於這些值的框架、內距、字型選擇或條件式檢視。
- 確認這些選擇是否會影響被測量的檢視或其祖先檢視。
- 暫時將測量值替換為常數。如果反覆變動停止,請先檢查這條相依路徑。
改變測量值的傳遞方式,不一定能消除循環。偏好值仍可能將相同尺寸傳回同一個祖先檢視。延後指定狀態值,則可能只是把回饋移到後續更新。關鍵問題在於:下一次測量是否仍相依於前一次結果。
應該使用哪種尺寸配置機制?
根據你要做的決策選擇機制。先從內建版面配置容器著手;Apple 指出,需要更多控制時可實作自訂 Layout。請參閱 Layout 文件。
| 需要的結果 | 支援的做法 | 關鍵條件 |
|---|---|---|
| 垂直排列一般標籤 | 內建堆疊 | 讓子檢視參與一般尺寸配置。 |
| 選擇寬版或精簡版呈現方式 | ViewThatFits |
依偏好順序提供替代方案。 |
| 讓多個標籤共用依內容決定的寬度 | 自訂 Layout |
在版面配置方法中測量並放置子檢視。 |
| 回應既有檢視的幾何資訊 | onGeometryChange |
只觀察動作所需的值。 |
| 設定內嵌 AppKit 檢視的尺寸 | NSViewRepresentable.sizeThatFits |
根據提案回傳偏好的尺寸。 |
ViewThatFits 會在受限軸上,選取第一個理想尺寸符合條件的子檢視。當你的實際需求是從多種呈現方式中做選擇時,例如水平排列與垂直備用方案,這項功能就很實用。這樣可以省去額外的測量寬度狀態變數。請參閱 Apple 的 ViewThatFits 文件。
下方共用寬度標籤面板的尺寸規則十分明確:採用最寬標籤的理想寬度,但不得超過有限的寬度提案。接著,以選定的寬度測量每個標籤的高度。這樣一來,視窗變窄時,面板可透過文字換行增加高度,而不必將幾何測量值傳入 App 狀態。
不使用幾何狀態建立共用寬度的標籤面板
在 Xcode 中使用 SwiftUI 建立 macOS App 專案,將部署目標設為 macOS 13 或更新版本,然後以這份完整的單檔範例取代產生的 App 與內容檢視宣告。目標中只保留一個 @main App 宣告。我已依據文件檢視範例所使用的 API;由於可用環境是 Linux,因此未在此處編譯或執行。
import SwiftUI
private struct EqualWidthColumn: Layout {
private let gap: CGFloat = 8
private func columnWidth(
proposal: ProposedViewSize,
subviews: Subviews
) -> CGFloat {
let idealWidth = subviews.reduce(CGFloat.zero) { result, view in
max(result, view.sizeThatFits(.unspecified).width)
}
guard let offeredWidth = proposal.width,
offeredWidth.isFinite else {
return idealWidth
}
return min(idealWidth, max(0, offeredWidth))
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let width = columnWidth(proposal: proposal, subviews: subviews)
let childProposal = ProposedViewSize(width: width, height: nil)
let contentHeight = subviews.reduce(CGFloat.zero) { result, view in
result + view.sizeThatFits(childProposal).height
}
let totalGap = gap * CGFloat(subviews.count - 1)
return CGSize(width: width, height: contentHeight + totalGap)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let childProposal = ProposedViewSize(
width: bounds.width,
height: nil
)
var y = bounds.minY
for view in subviews {
let height = view.sizeThatFits(childProposal).height
view.place(
at: CGPoint(x: bounds.minX, y: y),
anchor: .topLeading,
proposal: childProposal
)
y += height + gap
}
}
}
private struct ContentView: View {
private let labels = [
"Amber tile",
"Blue tile with a longer local label",
"Green tile"
]
var body: some View {
ScrollView(.vertical) {
EqualWidthColumn {
ForEach(labels, id: \.self) { label in
Text(label)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.12))
)
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 220, minHeight: 180)
}
}
@main
struct GeometryLoopDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
兩個必要的版面配置方法各自負責不同工作:sizeThatFits 回報容器尺寸,placeSubviews 則定位其子檢視。子檢視代理可用來測量和放置子檢視,而不必直接存取底層檢視。這兩個方法都遵循文件所述的職責。請參閱 Apple 的 Layout 文件。
實作的重點在於寬度提案及其重複使用。每個子檢視都會以確定的寬度和未指定的高度進行測量,讓文字自行決定所需高度。放置時則使用容器實際邊界的寬度和相同的高度規則。從 bounds.minX 和 bounds.minY 開始,也避免假設放置位置始於座標原點。
若沒有子檢視,容器會回傳零。寬度未指定時,容器會選擇理想寬度;寬度有限時,則會限制在該寬度內。對於這些標籤,即使寬度提案是無限大,也會得到有限的理想寬度。這些都是針對此內容尺寸面板刻意制定的規則。Apple 說明,父檢視可在單次版面配置過程中以零、無限、未指定及其他提案探測版面配置。請參閱 sizeThatFits(proposal:subviews:cache:)。
此範例沒有測量尺寸的狀態、幾何回呼,也不會將顯示寬度指定回框架。垂直捲動檢視可容納換行後變高的內容。這項版面配置假設子檢視如範例中的標籤一樣,能合理回應有限的寬度。若固定寬度的子檢視超出可用空間,就需要另外決定呈現方式,例如水平捲動或改用替代檢視。
何時仍適合觀察幾何資訊?
當測量結果只用來驅動有限的回應時,觀察幾何資訊仍然很實用。Apple 的 onGeometryChange API 將轉換與動作分開:轉換會擷取可比較的值,該轉換後的值變更時,動作才會執行。Apple 建議避免觸發大範圍的 App 更新,因為幾何資訊可能頻繁變動。請參閱 onGeometryChange(for:of:action:)。
例如,覆蓋層可以顯示穩定的包含區域是否超過指定寬度。觀察該包含區域,並在轉換中回傳布林值。避免讓覆蓋層影響該區域的尺寸。這種設計遵循文件所述的轉換與動作模型,同時消除循環的尺寸相依關係。
將矩形轉換為布林值可以減少通知,但這本身無法證明版面配置穩定。如果切換至精簡版呈現方式會大幅改變被測量區域,進而切換回原本呈現方式,布林值就可能無限交替。請測量寬度由周圍視窗版面配置決定的區域,或使用 ViewThatFits 在版面配置內決定呈現方式。
可用性也取決於所使用的多載。單一新值的動作在 macOS 13 或更新版本可用,前提是使用包含該 API 的 SDK 編譯;舊值與新值的多載則需要 macOS 15 或更新版本。完整範例只使用 Layout,因此不受此差異影響。在較舊的部署目標中複製回呼簽章之前,請先檢查 Apple 的 幾何變化文件與舊值與新值的多載。
如何將 AppKit 和 TextKit 測量結果納入 SwiftUI 版面配置?
若內容是 AppKit 檢視,請使用 representable 的尺寸配置邊界。NSViewRepresentable.sizeThatFits(_:nsView:context:) 從 macOS 13 或更新版本開始提供,會根據提案回傳偏好的尺寸,而且在單次版面配置過程中可能會以不同提案呼叫多次。回傳 nil 表示要求使用預設尺寸配置演算法。請將它視為尺寸查詢,而不是將測得尺寸發布至 SwiftUI 狀態的地方。請參閱代表檢視的尺寸配置方法。
SwiftUI 會控制所代表的 AppKit 檢視的 frame 和 bounds。Apple 明確警告,不要自行設定這些版面配置屬性,因為這會與 SwiftUI 衝突並產生未定義行為。請在 representable 的更新方法中設定內容,並透過支援的尺寸配置機制回報尺寸。請參閱 NSViewRepresentable。
處理文字時,請先確認檢視使用哪種版面配置引擎。TextKit 2 提供 NSTextLayoutManager.usageBoundsForTextContainer,從 macOS 12 或更新版本開始提供,可用來測量文字容器的使用範圍。Apple 說明可透過 KVO 觀察此屬性。這項觀察可用來支援調整尺寸的請求,但仍須確保寬度和高度的相依關係一致。請參閱 usageBoundsForTextContainer。
對於既有的 TextKit 1 實作,NSLayoutManager.usedRect(for:) 會回報已完成版面配置的字形所佔區域;它不會產生字形或執行版面配置。如果測量需要完成版面配置,請先對相關容器呼叫 ensureLayout(for:),再讀取使用範圍矩形。這兩個 API 從 macOS 10.7 起皆可使用。請參閱 usedRect(for:) 和 ensureLayout(for:)。
不要只是為了取得測量值,就存取 TextKit 2 文字檢視的舊版 layoutManager。Apple 說明,存取舊版版面配置管理器可能會使文字檢視切換至 TextKit 1 相容模式。請先檢查 textLayoutManager,並將舊版測量程式碼放在適當的備用分支中。請參閱 TextKit 與文字檢視的新功能。
對於會換行的文字檢視,請將提案寬度作為文字版面配置的輸入,並根據該設定推導偏好高度。請明確計入文字檢視內縮和容器內距;容器座標中的矩形不會自動等於外層檢視的尺寸。AppKit 介面工作請保持在其 actor 隔離範圍內。Swift 的並行程式設計章節說明了 MainActor 執行方式;將工作移至 task 中,並不會改變尺寸公式中的循環相依關係。
如何確認版面配置現在會穩定下來?
在 Xcode 中執行範例,並緩慢地向兩個方向調整視窗大小。預期行為是標籤共用寬度、視窗變窄時文字換行,以及內容超過可見高度時出現垂直捲動。移除測量狀態的相依關係後,再以實際的檢視結構重複測試。
使用下列檢查來區分預期中的版面配置工作與持續回饋:
- 調整大小後讓視窗保持靜止。確認沒有內容變動時,測量值和狀態指定是否仍持續變更。
- 測試較長的合成標籤和較大的字型。確認面板可以垂直增高,而不會裁切文字。
- 測試空的標籤集合。套用周圍內距之前,版面配置應回傳零。
- 檢查測量與放置時的提案。需要換行的子檢視應採用一致的寬度假設。
- 如果之後加入快取,請將尺寸輸入值納入快取有效性規則;不要重複使用以不同寬度計算的高度。
單次版面配置過程中進行多次尺寸配置呼叫,是文件所述的預期行為。實際目標是在內容和限制條件不變時得到穩定結果。若測量結果會決定排列方式,請將測量留在版面配置協商中;幾何觀察則保留給不會重新建立所觀察尺寸的回應。
來源
- 使用 SwiftUI 組合自訂版面配置 — Apple
- ProposedViewSize — Apple
- Layout — Apple
- ViewThatFits — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- onGeometryChange(for:of:action:) — Apple
- onGeometryChange(for:of:action:) — 舊值與新值 — Apple
- sizeThatFits(_:nsView:context:) — Apple
- NSViewRepresentable — Apple
- usageBoundsForTextContainer — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- TextKit 與文字檢視的新功能 — Apple
- 並行程式設計 — Swift 程式語言 — Swift.org