若要讓 SwiftUI 捲動檢視符合短內容的高度,請測量其內部內容,並將檢視區高度設為該測量值與指定上限兩者中的較小值。較長的內容則會在此上限內捲動。本教學會在你自己的 Mac App 中,為一個小型標籤面板建立這種行為。

為什麼短捲動檢視會留下空白?

首先,請區分兩種尺寸:內容的高度,以及顯示內容的檢視區高度。Apple 將 ScrollView 描述為顯示底層內容的一部分,並在捲動時調整可見部分。只有一小組標籤,本身並不會決定你偏好的檢視區高度。Apple 的 ScrollView 說明文件說明了這項區別。

對於精簡的桌面面板,請明確定義尺寸規則:viewportHeight = min(contentHeight, maximumHeight)。請在內容實際取得的寬度下測量內容,並將任何應隨內容一起捲動的內距也納入測量。將結果套用至外層捲動檢視。Swift 的 min(_:_:) 會傳回較小的值,而 SwiftUI 的 frame(width:height:alignment:) 在提供高度引數時會設定固定高度。Swift min(_:_:)、SwiftUI 固定框架。

舉例來說,假設上限是 240 點。高度為 86 點的內容會產生 86 點高的檢視區;高度為 410 點的內容則會產生 240 點高的檢視區,其餘內容仍可透過捲動存取。這些只是示意輸入值,並非下方範例的實際測量結果。

變更項目 控制的內容 是否會建立所需的精簡高度?
測量內容高度、設定上限並套用至捲動檢視 檢視區高度 是,適用於下方所示的有界限版面配置
.scrollBounceBehavior(.basedOnSize) 相對於內容尺寸的彈跳行為 否
標籤上的 .fixedSize(horizontal: false, vertical: true) 內容的理想垂直尺寸 有助於測量;外層檢視區仍需要高度規則

Apple 將 .basedOnSize 說明為一種彈跳設定,並將 fixedSize(horizontal:vertical:) 說明為沿著選定尺寸將檢視固定在理想大小。兩者都不會設定由內容推算的檢視區高度。捲動彈跳行為、依軸固定尺寸。

建立具有捲動高度上限的精簡面板

請使用目前版本的 Xcode,且其 macOS SDK 提供單值 onGeometryChange(for:of:action:) 多載。Apple 目前說明該多載從 macOS 13.0 起提供。以下完整範例的目標版本為 macOS 13.3 或更新版本,因為它也使用了從 macOS 13.3 起提供的 scrollBounceBehavior(_:axes:)。幾何變更回呼、彈跳修飾子的適用版本。

使用 SwiftUI 建立 macOS App 專案。將產生的 App 宣告和內容檢視替換為以下單檔程式,並確保目標中只保留一個 @main 宣告。所有標籤都是合成的本機字串。「顯示更多標籤」切換鈕會在短內容和長內容之間切換。

import SwiftUI

@main
struct CompactLabelsApp: App {
    var body: some Scene {
        WindowGroup {
            CompactLabelsView()
        }
    }
}

struct CompactLabelsView: View {
    @State private var showMore = false
    @State private var viewportHeight: CGFloat = 240

    private var labels: [String] {
        if showMore {
            return (1...18).map { number in
                "Sample label \(number): a longer description that can wrap when the panel becomes narrower."
            }
        }
        return ["Sample label A", "Sample label B"]
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Show more labels", isOn: $showMore)

            ScrollView(.vertical) {
                VStack(alignment: .leading, spacing: 8) {
                    if labels.isEmpty {
                        Text("No labels to display")
                    } else {
                        ForEach(labels.indices, id: \.self) { index in
                            Text(labels[index])
                        }
                    }
                }
                .font(.body)
                .fixedSize(horizontal: false, vertical: true)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(12)
                .onGeometryChange(for: CGFloat.self) { proxy in
                    min(proxy.size.height.rounded(.up), 240)
                } action: { newHeight in
                    viewportHeight = newHeight
                }
            }
            .frame(height: viewportHeight)
            .scrollBounceBehavior(.basedOnSize, axes: .vertical)
            .border(Color.secondary.opacity(0.4))

            Text("The label panel grows up to 240 points.")
                .font(.caption)
        }
        .padding(20)
        .frame(
            maxWidth: .infinity,
            maxHeight: .infinity,
            alignment: .topLeading
        )
        .frame(minWidth: 300, minHeight: 360)
    }
}

此程式已經過檢視,但由於此環境沒有 Swift 編譯器或 macOS 執行環境,因此未在此處編譯或執行。將它整合至現有介面前,請先在 Xcode 中建置,並執行下方的檢查。

為什麼要在捲動檢視內部測量

幾何回呼會觀察加上內距的內層堆疊。若測量外層捲動檢視,回報的會是你已指定的檢視區尺寸,形成循環的尺寸規則,而不是找出內容所需的高度。

轉換只會傳回經四捨五入且受上限約束的高度。Apple 建議將幾何資訊轉換為所需的值,並避免對頻繁變動的幾何資訊觸發廣泛更新。在此情況下,捲動位置的變化與計算無關,而高於上限的內容仍會得到相同的 240 點結果。Apple 的幾何變更指引。

向上取整可避免刻意將檢視區設為比測量內容略短的高度。初始的 240 點狀態會在測量結果傳入前提供可用的檢視區;這是啟動用的初始值,並非測得的內容高度。之後回呼會以測量結果取代它。

為什麼寬度和修飾子順序很重要

讓水平尺寸保持彈性。範例使用 .fixedSize(horizontal: false, vertical: true),讓標籤能使用可用寬度,同時保留理想的垂直尺寸。Apple 提醒,固定理想尺寸可能會讓檢視超出父檢視界限;因此,對於狹窄面板中的換行標籤,預設不應同時固定兩個尺寸。Apple 的 fixedSize(horizontal:vertical:) 說明文件。

12 點的內距位於測量修飾子之前,因此會納入內容高度的測量。邊框則位於外層高度設定之後,用來標示檢視區邊界。這種安排能更容易看出未使用的空間是屬於捲動面板,還是周圍的視窗。

檢查短內容、換行與縮小行為

請依照以下步驟檢查範例中的行為;將面板放入 App 的實際容器後,也請再次執行這些檢查:

  1. 關閉「顯示更多標籤」後啟動。邊框應圍住兩個標籤及其內距,標題說明文字應緊接在下方。
  2. 開啟「顯示更多標籤」。面板應擴展至 240 點的上限。捲動以確認能看到最後一個標籤。
  3. 縮窄並加寬視窗。換行標籤的高度應隨之改變,且不會遺失最後幾行。
  4. 再次關閉切換鈕。面板應縮回短內容的高度。
  5. 暫時將短標籤陣列改為 []。明確顯示的「沒有可顯示的標籤」訊息應決定內容高度。
  6. 使用實際面板中的文字樣式和周圍控制項重複檢查。

空狀態是刻意的介面選擇:此範例在陣列為空時仍保留訊息和內距。若希望完全移除面板,請在沒有標籤時有條件地省略整個捲動檢視。這是與測量非空面板不同的產品決策。

周圍範例視窗會保留其最小高度。程式會設定標籤檢視區的尺寸;移除標籤時,它不會要求 Mac 視窗跟著縮小。標題說明文字下方的任何空間都屬於外層版面配置,不在有邊框的捲動檢視內。

排查剩餘空隙或遭裁切的標籤

如果面板仍維持過高,先檢查測量修飾子的位置。它應觀察加上內部內距後的內容堆疊。接著檢查祖先檢視是否設定了明確高度或最小高度,因而為面板周圍保留空間。Apple 說明,提供的框架高度會固定尺寸,因此即使內層檢視區縮小,獨立的外層框架仍可能保留空間。SwiftUI 框架尺寸。

如果內容本身仍高得出乎預期,請移除受測子樹中的垂直延展設定。對於這種小型標籤模式,請避免使用垂直間隔器,或使用最小高度取決於檢視區的內容框架。這些設定會使內容高度依賴檢視區,破壞尺寸規則所需的獨立測量。

如果底部內距遭到裁切,請確認測量前已納入內距。如果整合後文字換行方式不同,請檢查新父檢視實際提供的寬度。不要將在某一寬度下測得的高度,視為另一寬度下的固定高度。

請讓 240 點上限符合外層介面的需求。範例提供的視窗最小尺寸足以容納控制項、內距、標題說明文字和達到上限的面板。較短的容器需要更小的高度預算或不同的周圍版面配置;這個固定上限不會自動計算其他控制項下方剩餘的空間。

此方法適用於少量且會立即排版的標籤。Apple 將 LazyVStack 描述為只在需要於螢幕上呈現項目時才建立它們。因此,不要假設將內層堆疊替換為惰性堆疊後,仍能沿用相同的完整內容測量策略。對於大型集合,採用穩定的檢視區搭配惰性內容,通常是更合適的設計。Apple 的 LazyVStack 說明文件。

何時適用 AppKit 和 TextKit 測量?

對於這個 SwiftUI 標籤面板,請測量呈現後的 SwiftUI 子樹。若使用 AppKit 文字測量,就會引入另一套必須與實際顯示標籤相符的版面配置設定。

如果 App 已經使用 NSScrollView,請保留文件與檢視區之間的相同區別。Apple 將 documentView 定義為正在捲動的檢視,而 contentSize 是捲動檢視內容檢視的尺寸。儘管名稱如此,contentSize 並不是完整文件的測量值。AppKit documentView、AppKit contentSize。

對於使用 NSLayoutManager 建立的既有 TextKit 版面配置,usedRect(for:) 會回報文字容器中已完成排版之字形的邊界矩形;Apple 明確指出,查詢此值不會產生字形,也不會執行版面配置。若需要完整的容器版面配置,Apple 文件指出應先使用 ensureLayout(for:) 強制完成版面配置。TextKit usedRect(for:)、TextKit ensureLayout(for:)。

即使如此,字形矩形也不是完整的文字檢視框架。Apple 說明,文字容器周圍還有 textContainerInset 所設定的額外空間,容器內也有行片段內距。請依照實際文字檢視的設定來計算,而不要直接將字形高度複製到捲動檢視區。這些方法描述的是 NSLayoutManager 路徑,並非適用於所有文字元件的通用尺寸計算方式。AppKit textContainerInset。

對於小型 SwiftUI 標籤面板,實用的解法是讓寬度保持彈性、測量加上內距的內部內容,並限制外層檢視區高度。請以短、長兩種內容長度確認結果,接著在實際父版面配置中測試,確保周圍的限制條件也支援相同的精簡行為。

來源