透過新增受限於具體內容型別的初始化器,為 SwiftUI 泛型元件設定預設外觀。保留另一個獨立的建構器初始化器供自訂使用。本教學會為 macOS 視窗建立可重複使用的面板,並說明 Swift 5.7 的預設引數推斷何時能簡化介面。
定義元件應支援的兩種呼叫方式
讀者的任務很明確:建立一個桌面面板,只傳入標題時就能呈現完整外觀,同時也允許其他畫面提供自己的內容。兩種情況下,外層標題、間距與邊框都應保持一致。
預期的介面是 DesktopPanel(title: "Workspace"),用於預設呈現;以及 DesktopPanel(title: "Workspace") { Text("Three sample items") },用於自訂內容。這兩種呼叫會建立同一個泛型元件的不同特化版本。
Swift 的泛型參數代表具體型別。Content: View 這類約束要求型別遵循協定;Content == DefaultPanelContent 這類相同型別需求則會指定某一個特定型別。語言參考資料說明了這兩種需求,以及透過約束進行的初始化器多載解析。泛型參數與引數
如此一來,元件便有兩個明確職責:其 body 負責面板共用的外觀,而初始化器則決定面板中放入的內容。預設內容會使用具名檢視型別,讓便利初始化器能參照穩定且易讀的特化版本。
建立完整的 macOS 範例
本教學使用 macOS SwiftUI App 目標,部署目標為 macOS 11 或更新版本,並使用 Swift 5.7 或更新版本的編譯器。macOS 最低版本可支援下方使用的 App 與 WindowGroup 結構;Apple 將 WindowGroup 說明為視窗檢視階層的場景容器。WindowGroup
將產生的 App 進入點替換為以下完整原始碼檔案;如果產生的 ContentView.swift 也宣告了另一個 ContentView,請將它移除。目標中只保留一個 @main App 宣告。以下顯示的所有值都是合成的本機範例。
import SwiftUI
struct DefaultPanelContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Sample workspace")
.font(.headline)
Text("Choose a layout to begin.")
.foregroundColor(.secondary)
}
}
}
struct DesktopPanel<Content: View>: View {
private let title: String
private let content: Content
init(
title: String,
@ViewBuilder content: () -> Content
) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.title2)
Divider()
content
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray, lineWidth: 1)
)
}
}
extension DesktopPanel where Content == DefaultPanelContent {
init(title: String) {
self.init(title: title) {
DefaultPanelContent()
}
}
}
struct ContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 20) {
DesktopPanel(title: "Default panel")
DesktopPanel(title: "Custom panel") {
HStack {
Text("Sample items")
Spacer()
Text("3")
.font(.headline)
}
Text("Layout: Compact")
.foregroundColor(.secondary)
}
}
.padding(24)
.frame(minWidth: 420, minHeight: 300)
}
}
@main
struct PanelExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
建置並執行 macOS 目標。預期結果是兩個外觀相同的面板:第一個包含預設工作區文字,第二個則包含一列計數,後面接著版面配置說明。此環境沒有 Swift 編譯器或 Apple SDK,因此我檢視了範例,但無法在此處編譯。
Apple 的自訂檢視指南也支持這種組合方式:宣告一個遵循 View 的型別、實作其 body,再將內建與自訂檢視組合成階層。指南也說明修飾符會回傳經過改變的檢視,因此共用樣式應套用在面板內容外層。宣告自訂檢視
為何受限初始化器能解析泛型型別
自訂初始化器會接收一個回傳 Content 的閉包。呼叫 content() 會建立並存入面板的值。此實作會在初始化期間使用該閉包,因此不會儲存閉包,也不需要 @escaping。
對於不帶建構器的呼叫,這個擴充會在 Content 為 DefaultPanelContent 時,專門提供 init(title:)。此需求補足了缺少的型別資訊。其實作接著會委派給建構器初始化器,讓屬性只需在一個地方初始化。
產生的預設值型別為 DesktopPanel<DefaultPanelContent>。自訂面板的內容型別則會從閉包推斷。呼叫端不必明確寫出可能很複雜的型別。
一個實用的結果是,預設實例已經完成特化。即使兩個值都會顯示面板,你也不能只因為如此,就將內容型別不同的面板指定給同一個變數。如果介面需要替代呈現方式,請在外層檢視中加入以建構器為基礎的分支。
建構器屬性可讓範例中的多陳述式自訂閉包運作。它允許 HStack 和後續的 Text 參與內容建構;但它不會取代元件本身的 Content: View 需求。Apple 說明了建構器如何處理多個運算式與條件式內容。ViewBuilder
何時可用一個帶預設值的建構器初始化器取代多載?
Swift 5.7 實作了 SE-0347,允許從具體的預設運算式推斷未提供引數的型別。對這個元件來說,這提供了更精簡的替代方案。SE-0347:從預設運算式推斷型別
若使用 Swift 5.7 或更新版本的編譯器,請將 DesktopPanel 內的建構器初始化器替換為以下片段,並移除整個受限擴充。保留完整範例中的匯入、儲存屬性、DefaultPanelContent、body 與 App 程式碼。
// Replacement initializer inside DesktopPanel.
init(
title: String,
@ViewBuilder content: () -> Content = {
DefaultPanelContent()
}
) {
self.title = title
self.content = content()
}
現在省略閉包時,會從 DefaultPanelContent() 推斷 Content。若提供閉包,則會從該引數推斷。這是從預設運算式推斷型別,不是在元件宣告中設定預設型別引數。
此提案對這種推斷設有限制:受影響的泛型參數只能在參數清單中出現一處,且相關的相同型別約束必須符合其規則。在此例中,Content 只出現在建構器參數中。若在另一個初始化器參數中重複使用它,這種做法可能就不成立。SE-0347 詳細設計
若預設內容需要初始化器輸入值,請選用多載。例如,可在 init(title:) 中建立由 title 推導出的預設副標題,再傳給建構器初始化器。預設引數運算式無法像在初始化器本體中建立的閉包那樣,捕捉傳入的 title。
| 元件需求 | 適用介面 |
|---|---|
| 固定的預設內容,且編譯器為 Swift 5.7 或更新版本 | 使用帶預設值的建構器參數,語法較精簡。 |
| 根據其他初始化器輸入值建立預設內容 | 使用受限初始化器,並在其中建立該值。 |
| 多個具名預設呈現方式 | 使用標籤與約束明確的初始化器多載。 |
| 預設特化版本的明確儲存型別 | 寫出 DesktopPanel<DefaultPanelContent>,或建立型別別名。 |
不要試圖將此介面寫成 struct DesktopPanel<Content: View = DefaultPanelContent>。目前的 Swift 泛型參數文法並未提供這種預設型別語法。此處支援的做法是受限初始化與預設運算式推斷。泛型參數與引數
在不同版本的建構器之間維持介面
Apple 目前的文件區分 Xcode 26 及更早版本與 Xcode 27 及更新版本。較新的工具鏈會使用 ViewBuilder 建立與型別無關的內容;Apple 將 ContentBuilder 引入為 ViewBuilder 的型別別名,作為這種統一行為的寫法。此差異取決於建置工具鏈,而非單純取決於 App 的部署目標。ViewBuilder、ContentBuilder
範例保留 @ViewBuilder,以相容較早期的工具鏈。在 Xcode 27 或更新版本改用新寫法時,請將該參數屬性改為 @ContentBuilder。保留 Content: View:面板仍需要能放入檢視階層的內容。
避免將預設特化版本寫成巢狀堆疊與元組型別。Apple 的遷移說明記載,多運算式建構器中的型別會從 TupleView 改為 TupleContent,這可能導致名稱中包含這些產生型別的約束失效。此範例改為將擴充限制在 DefaultPanelContent;其內部版面配置則留在 body: some View 中。TN3211:解決 State 與 ContentBuilder 的 SwiftUI 原始碼不相容問題
這個具名包裝型別也讓一般編輯更容易。新增第三行預設內容時,只需修改其實作,不必在便利初始化器中重寫冗長的具體型別。
修改版面配置前先診斷型別契約
如果元件無法編譯,先找出介面中哪個部分缺少型別資訊。修改內距或將所有內容包進另一個堆疊,無法解決缺少泛型特化的問題。
- 不帶建構器的呼叫無法推斷
Content。 確認受限的init(title:)存在,或預設值初始化器是以 Swift 5.7 或更新版本編譯。 - 具體預設內容無法指定給
Content。 確認指定發生在受限擴充內。僅遵循View並不會讓DefaultPanelContent能取代任何可能的Content。 - 多個自訂運算式遭到拒絕。 確認閉包參數帶有建構器屬性。
- 明確型別拒絕自訂內容。
DesktopPanel<DefaultPanelContent>的型別註記會刻意固定內容型別。若呼叫應推斷出不同的特化版本,請移除該註記。 - 工具鏈升級導致以元組為基礎的約束失效。 改用具名預設檢視,避免依賴建構器產生的結構;或依照 Apple 對特定約束提供的遷移文件處理。
最後,讓建構器保持精簡。此實作會在每次初始化面板時執行它;它並不保證只執行一次。Apple 提醒 SwiftUI 可能重新初始化檢視,並建議避免在其初始化器中執行大量工作。宣告自訂檢視
以受限初始化器作為明確的起點:它能清楚呈現預設特化版本,也讓自訂內容容易推斷。當編譯器需求與推斷限制符合你的 API 時,再採用單一帶預設值的初始化器。這兩種做法都能讓 macOS 面板負責共用外觀,同時只要求呼叫端提供所需內容。
來源
- 泛型參數與引數 — Swift.org
- 從預設運算式推斷型別 — Swift Evolution
- 宣告自訂檢視 — Apple 開發者文件
- ViewBuilder — Apple 開發者文件
- ContentBuilder — Apple 開發者文件
- WindowGroup — Apple 開發者文件
- TN3211:解決 State 與 ContentBuilder 的 SwiftUI 原始碼不相容問題 — Apple 開發者文件