若要讓變更中的下拉式標籤平順地重新換行,請以每個控制項的理想尺寸進行測量,使用共用的換行演算法放置控制項,並在容器上為選取項目變更加入動畫。本教學使用合成選項建置一個小型 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()

完成後的行為取決於三項具體決定:測量實際控制項、使用相同的列規劃回報尺寸並進行放置,以及在標籤變更時保留控制項識別。這些決定產生正確的最終幾何位置後,由選取項目驅動的動畫就能讓重新換行更容易理解。

來源