若要讓 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 的實際容器後,也請再次執行這些檢查:
- 關閉「顯示更多標籤」後啟動。邊框應圍住兩個標籤及其內距,標題說明文字應緊接在下方。
- 開啟「顯示更多標籤」。面板應擴展至 240 點的上限。捲動以確認能看到最後一個標籤。
- 縮窄並加寬視窗。換行標籤的高度應隨之改變,且不會遺失最後幾行。
- 再次關閉切換鈕。面板應縮回短內容的高度。
- 暫時將短標籤陣列改為
[]。明確顯示的「沒有可顯示的標籤」訊息應決定內容高度。 - 使用實際面板中的文字樣式和周圍控制項重複檢查。
空狀態是刻意的介面選擇:此範例在陣列為空時仍保留訊息和內距。若希望完全移除面板,請在沒有標籤時有條件地省略整個捲動檢視。這是與測量非空面板不同的產品決策。
周圍範例視窗會保留其最小高度。程式會設定標籤檢視區的尺寸;移除標籤時,它不會要求 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 標籤面板,實用的解法是讓寬度保持彈性、測量加上內距的內部內容,並限制外層檢視區高度。請以短、長兩種內容長度確認結果,接著在實際父版面配置中測試,確保周圍的限制條件也支援相同的精簡行為。
來源
- ScrollView — Apple
- min(::) — Apple
- frame(width:height:alignment:) — Apple
- scrollBounceBehavior(_:axes:) — Apple
- fixedSize(horizontal:vertical:) — Apple
- onGeometryChange(for:of:action:) — Apple
- LazyVStack — Apple
- documentView — Apple
- contentSize — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- textContainerInset — Apple