在 Mac App 的小型標籤列中,使用 ViewThatFits 選出最長且能完整容納的前綴,並將隱藏項目的數量納入考量。本教學會建置這個標籤列,不截斷標籤,也不在版面配置期間更新狀態。

具體任務是在可調整大小的 macOS 視窗中放入六個合成標籤。保留標籤順序,只顯示完整標籤,並以 3 more 之類的徽章顯示省略後綴的數量。此解法假設有足夠的水平空間容納只顯示數量的徽章,也有足夠的垂直空間容納一列內容。

選擇完整列的候選版面

Apple 將 ViewThatFits 說明為:在受限制的軸向上,選取第一個理想尺寸符合所提議尺寸的子視圖。此功能適用於 macOS 13 及以上版本。此處使用 .horizontal,因為任務關乎列寬;周圍的介面必須提供列所需的高度。ViewThatFits

對六個標籤,依序提供七種候選版面:六個標籤全顯示且不含徽章、五個標籤加上 1 more、四個標籤加上 2 more,依此類推,最後只剩 6 more。每種候選版面都是完整的一列。徽章也會參與符合版面的判斷,因此加入數量後,不會意外擠掉最後一個可見標籤。

這採用的是前綴策略:如果第一個標籤很長,即使後面較短的標籤放得下,也可能導致所有標籤都被省略。這是刻意的。如果你的任務允許跳過個別標籤,請先定義不同的選取策略,再開始實作。

對每個候選列套用 fixedSize(),讓標籤與徽章保留理想尺寸。Apple 說明,此修飾子可能讓視圖超出父視圖的邊界;它本身不保證內容一定符合版面。在此,外層的選取容器負責判斷是否符合版面。fixedSize()

建置完整的 macOS 範例

在 Xcode 中建立 macOS SwiftUI App,並將部署目標設為 macOS 13 或以上版本。以以下單檔程式取代產生的 App 宣告與內容視圖。目標中只能有一個標記為 @main 的 App 宣告。

此範例使用明確識別碼,而不以標籤文字作為識別依據。Apple 的 ForEach 文件要求元素具有識別資訊,或提供明確的識別碼 key path;不同的識別碼也允許兩個項目顯示相同標題。ForEach

import SwiftUI

struct DemoItem: Identifiable {
    let id: Int
    let title: String
}

struct ItemChip: View {
    let title: String

    var body: some View {
        Text(title)
            .lineLimit(1)
            .padding(.horizontal, 10)
            .padding(.vertical, 6)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(Color.blue.opacity(0.12))
            )
    }
}

struct PrefixCandidate: View {
    let items: [DemoItem]
    let visibleCount: Int

    var body: some View {
        HStack(spacing: 8) {
            ForEach(items.prefix(visibleCount)) { item in
                ItemChip(title: item.title)
            }

            if visibleCount < items.count {
                Text("\(items.count - visibleCount) more")
                    .lineLimit(1)
                    .padding(.horizontal, 10)
                    .padding(.vertical, 6)
                    .background(
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.gray.opacity(0.15))
                    )
            }
        }
        .fixedSize()
    }
}

struct WholeItemRow: View {
    let items: [DemoItem]

    var body: some View {
        if items.isEmpty {
            Text("No items")
                .fixedSize()
        } else {
            ViewThatFits(in: .horizontal) {
                ForEach(
                    Array((0...items.count).reversed()),
                    id: \.self
                ) { visibleCount in
                    PrefixCandidate(
                        items: items,
                        visibleCount: visibleCount
                    )
                }
            }
        }
    }
}

struct ContentView: View {
    let items: [DemoItem] = [
        DemoItem(id: 1, title: "Sketch"),
        DemoItem(id: 2, title: "Color samples"),
        DemoItem(id: 3, title: "Shapes"),
        DemoItem(id: 4, title: "Paper textures"),
        DemoItem(id: 5, title: "Layout notes"),
        DemoItem(id: 6, title: "Preview")
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Sample labels")
                .font(.headline)

            WholeItemRow(items: items)
                .frame(maxWidth: .infinity, alignment: .leading)

            Text("Resize the window to change how many labels fit.")
                .foregroundStyle(.secondary)
        }
        .font(.system(size: 14))
        .padding(24)
        .frame(minWidth: 320, idealWidth: 680, minHeight: 160)
    }
}

@main
struct WholeItemsDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

候選版面的識別碼代表可能顯示的項目數,從項目總數遞減至零。在每個候選版面中,項目識別碼用來識別標籤。每次迭代都會建立一個 PrefixCandidate,其中包含一個水平列。

外層的彈性框架讓列取得可用的水平空間,同時將選取的內容靠齊前緣。請將 fixedSize() 保留在 ViewThatFits 內的候選版面上。若將它套用到整個選取容器,會改變容器對父視圖寬度提議的回應方式。

這是一個完整的原始碼範例,但此處沒有編譯或執行,因為當時無法使用 Swift 工具鏈。下一節的示意算術已另外檢查;實際文字寬度仍須在 Mac App 中驗證。

計算符合版面時將徽章納入考量

實用的判斷規則是:選取整個候選版面都符合版面的最大可見項目數。隱藏項目數永遠等於項目總數減去該候選版面的可見項目數。不需要另外估算並保持同步。

考慮一列示意用的四個項目,其寬度分別為 72、96、64 和 88 點,並已計入項目內距。假設間距為 8 點,每當隱藏項目數不為零時,徽章寬度為 60 點。這些是虛構的計算輸入值,並非範例 App 的實際測量結果。

四個項目全顯示時,所需寬度為 72 + 96 + 64 + 88 + 3 × 8 = 344 點。顯示三個項目時,徽章會取代被省略的後綴,因此寬度變為 72 + 96 + 64 + 3 × 8 + 60 = 316 點。由於候選版面包含三個項目和一個徽章,因此需要三個間距。

可用寬度 可見項目 隱藏項目 選取候選版面寬度
344 點 4 0 344 點
343 點 3 1 316 點
300 點 2 2 244 點
220 點 1 3 140 點
60 點 0 4 60 點

在程式中,每個候選版面都包含實際的徽章文字,因此能配合不同徽章字串的差異,不必依賴示意中的固定寬度假設。候選版面的寬度不必以固定幅度變化;可見項目數遞減即可表達選取偏好。

只顯示數量的候選版面會決定可用寬度的下限。若連該徽章都放不下,依照這些規則,就不可能在可用寬度內顯示完整數量。請提供足夠的最小寬度、允許不同的排列方式,或將數量移至其他位置。不要把裁切視為成功顯示完整項目的結果。

此範例為短小的合成徽章提供最小內容寬度和適中的明確字型。如果你更改文字、字型、內距或周圍版面,請再次確認只顯示數量的情況。為此範例選定的最小寬度,並非適用所有情況的徽章尺寸。

將版面選取與視圖生命週期分開

隱藏數量屬於每個候選版面的不可變輸入。程式不會從測量方法寫入繫結值、不會排程非同步數量更新,也不會透過外觀回呼推斷可見狀態。選取哪一列,就會一併選取相應的徽章。

讓重複的候選內容保持簡單。在不同候選版面中重複標籤,並不代表該標籤在所有候選版面中共享同一個識別資訊。Apple 說明,狀態儲存與視圖識別資訊相關,而識別資訊改變可能會取代狀態。Demystify SwiftUI

如果日後將標籤改為可編輯控制項,請將共用值保存在共同的祖先視圖或模型中,並將適當的輸入值或繫結傳給各種呈現方式。Apple 建議將共用狀態放在需要它的視圖之最低共同祖先中。不要依賴候選版面本身的狀態在呈現方式改變後仍然保留。Managing user interface state

此解法會在選取的列中顯示隱藏數量,但不會將選取數量提供給同層視圖或 App 模型。Apple 文件中的 ViewThatFits 初始設定式接受受限制的軸向與候選內容,並未提供選取數量的繫結。請將匯出該數量視為額外的設計需求。init(in:content:)

知道何時需要以不同方式設計自訂版面

當你需要特殊的幾何配置時,自訂 Layout 就很合適。Apple 將其核心職責定義為回報複合尺寸,並透過版面代理指派子視圖位置。這些代理提供測量與放置操作,無法直接存取底層視圖。Layout

不要只在最後一個放得下的項目之後停止放置,藉此實作省略功能。Apple 的 LayoutSubview 文件指示你每個子視圖都要放置一次。略過放置並非文件所述的隱藏機制,而且版面代理也不提供通用的內容改寫操作,無法將另一個子視圖改成新的數量標籤。LayoutSubview

如果你要建置自訂版面,請確保測量與放置的結果一致,包括間距和使用的尺寸提議。Apple 明確要求這些計算必須一致。即使版面會測試多種可能的排列方式,仍須遵守這項規範。placeSubviews(in:proposal:subviews:cache:)

此候選版面方法有意控制在有限範圍內。六個項目會形成七種候選列,並在這些列中出現 21 次項目。一般而言,項目出現次數會依照 n × (n + 1) / 2 增長。這是結構上的計數,並非效能基準,也不表示每次更新都會呈現所有候選版面。請用於小型標籤集合;若要擴展到大型集合或耗費資源的內容,請先進行效能分析。

在列的臨界寬度驗證顯示結果

執行 App,並慢慢調整視窗大小。在調整元件之前,先確認以下情況:

  • 視窗夠寬時,所有標籤都會顯示,且不會出現 0 more 徽章。
  • 視窗變窄時,標籤會從尾端消失,不會顯示不完整標籤或省略號。
  • 可見項目數加上徽章中的隱藏數量,始終等於六。
  • 第一個標籤特別長時,只顯示數量的候選版面仍可使用。
  • 陣列為空時,列會顯示 No items。
  • 標題重複但識別碼不同時,兩個項目都會計入數量。
  • 更改字型或徽章文字後,只顯示數量的候選版面仍能在支援的最小寬度內放下。

對於小型且有順序的列,完整候選版面讓符合版面的規則一目了然:每個候選版面都包含標籤、間距和確切的隱藏數量。明確設定最小寬度,並讓重複內容保持輕量,這樣列就能隨視窗大小調整,不會出現不完整項目,也不需要額外的數量更新週期。

來源