透過新增受限於具體內容型別的初始化器,為 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 原始碼不相容問題

這個具名包裝型別也讓一般編輯更容易。新增第三行預設內容時,只需修改其實作,不必在便利初始化器中重寫冗長的具體型別。

修改版面配置前先診斷型別契約

如果元件無法編譯,先找出介面中哪個部分缺少型別資訊。修改內距或將所有內容包進另一個堆疊,無法解決缺少泛型特化的問題。

  1. 不帶建構器的呼叫無法推斷 Content。 確認受限的 init(title:) 存在,或預設值初始化器是以 Swift 5.7 或更新版本編譯。
  2. 具體預設內容無法指定給 Content。 確認指定發生在受限擴充內。僅遵循 View 並不會讓 DefaultPanelContent 能取代任何可能的 Content。
  3. 多個自訂運算式遭到拒絕。 確認閉包參數帶有建構器屬性。
  4. 明確型別拒絕自訂內容。 DesktopPanel<DefaultPanelContent> 的型別註記會刻意固定內容型別。若呼叫應推斷出不同的特化版本,請移除該註記。
  5. 工具鏈升級導致以元組為基礎的約束失效。 改用具名預設檢視,避免依賴建構器產生的結構;或依照 Apple 對特定約束提供的遷移文件處理。

最後,讓建構器保持精簡。此實作會在每次初始化面板時執行它;它並不保證只執行一次。Apple 提醒 SwiftUI 可能重新初始化檢視,並建議避免在其初始化器中執行大量工作。宣告自訂檢視

以受限初始化器作為明確的起點:它能清楚呈現預設特化版本,也讓自訂內容容易推斷。當編譯器需求與推斷限制符合你的 API 時,再採用單一帶預設值的初始化器。這兩種做法都能讓 macOS 面板負責共用外觀,同時只要求呼叫端提供所需內容。

來源