若要讓變更中的下拉式標籤平順地重新換行,請以每個控制項的理想尺寸進行測量,使用共用的換行演算法放置控制項,並在容器上為選取項目變更加入動畫。本教學使用合成選項建置一個小型 macOS 控制面板。
將換行與動畫分開處理
讀者的任務很明確:建置一個桌面面板,選擇較長的下拉式標籤時,鄰近的控制項可能會移到另一列。控制項的位置變動時應保留其識別,而面板也應將新的高度回報給周圍的介面。
這裡有兩項不同的職責。自訂的 Layout 會計算目的尺寸與位置。動畫則會告訴 SwiftUI 如何在新舊幾何位置之間移動。Apple 說明,Layout 是擴充點,適用於需要比內建容器更複雜行為的排列方式。它的兩個必要方法會回報容器尺寸並放置子視圖。Layout
在這個範例中,每個下拉式控制項都是一個 Menu,其動作會更新選取項目。Apple 支援使用自訂標籤建構器的選單,因此顯示的標籤可以同時包含類別和所選選項。這個版面會在視窗中排列已收合的控制項;本範例不會嘗試控制選單展開內容的幾何位置。Menu
這項設計採用三項明確政策:
- 控制項保留理想尺寸,不會為了維持同一列而壓縮標籤。
- 當下一個控制項會超出提議的寬度時,控制項會依照原有順序換行。
- 寬於可用寬度的控制項會獨佔一列,並使版面回報較寬的尺寸。
最後一項政策很重要。將一組控制項換行,無法讓單一不可分割的控制項變窄。如果你的應用程式必須讓任意長度的標籤都能放進狹窄面板,也需要另外制定標籤政策,例如刻意限制標籤設計的寬度。Apple 指出,fixedSize() 會保留理想尺寸,因此可能使視圖超出父視圖的邊界。fixedSize()
使用同一套計算進行測量與放置
SwiftUI 的版面系統會沿著階層向下傳遞尺寸提議。未指定尺寸的提議會要求視圖提供理想尺寸;這和包含零或無限大的提議不同。在此,理想尺寸測量結果決定每個控制項佔用的空間,而父視圖提議的寬度則決定換行位置。ProposedViewSize
關鍵在於一致性:容器預留的空間必須與放置演算法使用的空間相同。Apple 明確要求,尺寸計算與放置計算必須一致,包括間距以及對提議的回應方式。Apple 也說明,放置邊界使用父視圖的座標空間,因此放置時必須考量 bounds.minX 和 bounds.minY。placeSubviews(in:proposal:subviews:cache:)
在 Xcode 中建立一個 macOS SwiftUI App,並將部署目標設為 macOS 14 或更新版本。本範例使用 macOS 13 起提供的 Layout,以及 macOS 14 起提供的 geometryGroup()。將以下版面宣告,以及下一節的 App 程式碼放在同一個 Swift 檔案中,取代產生的 App 進入點。這兩個程式碼區塊合起來就是完整範例。Layout availability,geometryGroup() availability
import SwiftUI
struct WrappingControls: Layout {
var spacing: CGFloat = 8
struct Plan {
var size: CGSize
var origins: [CGPoint]
}
func plan(
proposal: ProposedViewSize,
subviews: Subviews
) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, origins: [])
}
let gap = max(0, spacing)
let limit: CGFloat
if let width = proposal.width, width.isFinite {
limit = max(0, width)
} else {
limit = .infinity
}
var origins: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var usedWidth: CGFloat = 0
var rowHasItems = false
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let nextX = rowHasItems ? x + gap : 0
if rowHasItems && nextX + size.width > limit {
y += rowHeight + gap
x = 0
rowHeight = 0
rowHasItems = false
}
if rowHasItems {
x += gap
}
origins.append(CGPoint(x: x, y: y))
x += size.width
rowHeight = max(rowHeight, size.height)
usedWidth = max(usedWidth, x)
rowHasItems = true
}
let width = limit.isFinite ? max(limit, usedWidth) : usedWidth
return Plan(
size: CGSize(width: width, height: y + rowHeight),
origins: origins
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
plan(proposal: proposal, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = plan(proposal: proposal, subviews: subviews)
for (index, subview) in subviews.enumerated() {
let origin = result.origins[index]
subview.place(
at: CGPoint(
x: bounds.minX + origin.x,
y: bounds.minY + origin.y
),
anchor: .topLeading,
proposal: .unspecified
)
}
}
}
兩個版面方法都會呼叫 plan,並使用 SwiftUI 傳入的提議。測量與放置也都使用相同的子視圖提議 .unspecified。Apple 的子視圖測量 API 會回傳子視圖針對指定提議所選擇的尺寸;本範例使用該回應,而不是根據字元數估算寬度。sizeThatFits(_:)
若提議的寬度為有限值,版面至少會填滿該寬度,並如實回報任何超出寬度的子視圖。若寬度未指定或為無限大,則會為這些尺寸有限的控制項回傳有限的單列理想尺寸。高度由內容決定;版面會刻意忽略提議的高度,並回報各列所需的空間。
這個實作使用 Void 快取,並重新計算小型版面規劃。Apple 建議只有在效能分析證明有好處時才加入快取。如果之後要快取測量結果,請確保重新建立快取不會改變版面結果。makeCache(subviews:)
將變更中的選取項目連結至穩定的控制項
這個合成面板有三個類別:Shape、Pattern 和 Arrangement。每個類別都會保存自己的選取字串,範例中也定義了每個選項。標籤會在既有的 Menu 中變更;程式不會在選取項目變更時取代選單。
將這段程式碼接在版面宣告後面。專案中只保留一個 @main App 宣告。
struct ControlChoice: Identifiable, Equatable {
let id: Int
let title: String
let options: [String]
var selected: String
}
struct DropdownPanel: View {
@State var choices: [ControlChoice] = [
ControlChoice(
id: 1,
title: "Shape",
options: ["Circle", "Rounded rectangle"],
selected: "Circle"
),
ControlChoice(
id: 2,
title: "Pattern",
options: ["Solid", "Alternating diagonal lines"],
selected: "Solid"
),
ControlChoice(
id: 3,
title: "Arrangement",
options: ["Rows", "Evenly spaced columns"],
selected: "Rows"
)
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Layout options")
.font(.headline)
WrappingControls(spacing: 8) {
ForEach(choices) { choice in
Menu {
ForEach(choice.options, id: \.self) { option in
Button(option) {
select(option, for: choice.id)
}
}
} label: {
Text("\(choice.title): \(choice.selected)")
.lineLimit(1)
}
.fixedSize()
.geometryGroup()
}
}
.animation(
.easeInOut(duration: 0.25),
value: choices.map(\.selected)
)
Text("Choose a longer option, then narrow the window.")
.foregroundStyle(.secondary)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 320, minHeight: 240)
}
func select(_ option: String, for id: Int) {
guard let index = choices.firstIndex(where: { $0.id == id }),
choices[index].options.contains(option) else {
return
}
choices[index].selected = option
}
}
@main
struct RewrappingMenusApp: App {
var body: some Scene {
WindowGroup {
DropdownPanel()
}
}
}
動畫修飾器會監看一組選取字串。Apple 要求被監看的值遵循 Equatable;這個陣列符合該要求。監看選取值也表示,單純因為視窗寬度變更時,這個修飾器不會要求播放新動畫。當提議變更時,版面仍會重新計算換行方式。animation(_:value:)
穩定的類別 ID 會在更新期間連結每個選單。ForEach 會根據具識別資料建立視圖,而變更視圖明確指定的 .id 會重設其識別與相關狀態。因此,加入 .id(choice.selected) 會妨礙本範例所需的連續性。每個合成類別中的選項字串都不重複,因此 id: \.self 適合用於這些選單動作。ForEach,id(_:)
Apple 的自訂版面範例說明,保留結構識別可讓 animation(_:value:) 為版面變更加入動畫。本範例將這項原則套用到同一個版面型別中的尺寸變更。選取字串會立即變更;預期的動畫是隨之發生的位置移動與尺寸調整,而不是在兩個標籤的字元之間進行插值。Composing custom layouts with SwiftUI
geometryGroup() 修飾器會為每個完整選單建立幾何邊界。Apple 說明,這個邊界會先解析父視圖的幾何位置並為其加入動畫,再將其傳遞給子視圖。此處的用法遵循該文件所述機制,讓每個控制項的內部內容一起移動;它不會決定換行位置,也無法修正錯誤的換行計算。geometryGroup()
微調動畫前先檢查換行位置
透過一個小範例,就能檢視換行政策。以下尺寸僅供說明,並非 macOS 控制項的實測值:假設有三個控制項,寬度分別為 80、100 和 90 點,高度都是 24 點,間距為 8 點,可用寬度為 300 點。
初始列需要 286 點:80 + 8 + 100 + 8 + 90。如果中間的控制項變寬至 150 點,整列就需要 336 點。前兩個控制項仍能放下,總共使用 238 點,因此第三個控制項會移到下一列。新的內容高度為 56 點:24 + 8 + 24。
| 條件 | 此演算法的預期結果 |
|---|---|
| 總寬度剛好等於上限 | 控制項留在該列。 |
| 下一個控制項會超出上限 | 在該控制項之前開始新的一列。 |
| 控制項高度不同 | 依已完成列中最高的控制項增加列高。 |
| 單一控制項超出提議的寬度 | 讓它獨佔一列,並回報較寬的必要尺寸。 |
| 寬度未指定或為無限大 | 依控制項合計的理想寬度回傳單列。 |
| 沒有控制項 | 回傳零尺寸。 |
我們另外使用確定性的合成尺寸檢查了排列計算,包括較長的標籤、超寬控制項和未指定寬度。本環境未編譯或目視測試 SwiftUI App。請在 macOS 上建置並執行,以確認原生控制項外觀與動畫。
在執行中的 App 裡,先在固定視窗寬度下選擇較長的選項。調整視窗,直到選取項目變更會跨越換行位置,接著在短選項與長選項之間切換。確認控制項下方的說明文字會移動以容納新的一列,並且切回短標籤後會移除多餘的垂直空間。
接著,在選取項目不變的情況下調整視窗大小。這能區分換行問題與動畫問題:最終位置錯誤表示測量或放置有問題;最終位置正確但移動效果不理想,則應檢查動畫範圍與幾何分組。
診斷其他可能的失敗情況
如果標籤變更了,但換行位置沒有改變,請檢查版面實際測量的內容。每個選單外都包上固定寬度的容器,可能會讓短標籤和長標籤回報相同的外部寬度。在這個實作中,每個選單都刻意保留理想尺寸,而版面測量的是完整控制項,不是另外估算的文字寬度。fixedSize(),sizeThatFits(_:)
如果控制項重疊,或下一個區段蓋住第二列,請先比較尺寸計算與放置計算。兩者必須使用相同的間距、子視圖提議、換行條件和列高計算。不要以一種寬度計算尺寸,卻用另一種寬度進行放置。共用的 plan 函式就是為了降低引入這類差異的可能性。placeSubviews(in:proposal:subviews:cache:)
如果控制項在選取項目變更時消失又出現,請先檢查識別,再加入更多動畫修飾器。保持類別 ID 穩定,只更新其選取項目。Apple 的文件指出,變更明確指定的視圖 ID 會重設視圖識別,因此用會變更的標籤作為持續存在之控制項的識別鍵並不合適。id(_:)
若部署目標是 macOS 13,請移除 .geometryGroup(),並保留 Layout 實作與由選取項目驅動的動畫。如此仍可保留換行方式,但會移除 macOS 14 起提供且有文件說明的幾何邊界;請在支援的系統上評估移動效果。Layout,geometryGroup()
完成後的行為取決於三項具體決定:測量實際控制項、使用相同的列規劃回報尺寸並進行放置,以及在標籤變更時保留控制項識別。這些決定產生正確的最終幾何位置後,由選取項目驅動的動畫就能讓重新換行更容易理解。
來源
- Layout — Apple Developer Documentation。
- ProposedViewSize — Apple Developer Documentation。
- placeSubviews(in:proposal:subviews:cache:) — Apple Developer Documentation。
- sizeThatFits(_:) — Apple Developer Documentation。
- makeCache(subviews:) — Apple Developer Documentation。
- Menu — Apple Developer Documentation。
- fixedSize() — Apple Developer Documentation。
- animation(_:value:) — Apple Developer Documentation。
- ForEach — Apple Developer Documentation。
- id(_:) — Apple Developer Documentation。
- Composing custom layouts with SwiftUI — Apple Developer Documentation。
- geometryGroup() — Apple Developer Documentation。