將呼叫端提供的內容宣告為 (Input) -> Content,並限制 Content 必須符合 View。這讓 macOS 元件能提供具型別的值,同時由呼叫端選擇呈現方式。這項契約要求接受該輸入,但無法要求使用它。

本教學會建立一個小型桌面色票預覽。可重複使用的元件擁有尺寸控制項,並將合成的組態提供給呼叫端的繪製閉包。呼叫端繪製色票,而不必管理元件的內部狀態。

哪種介面能表達這項需求?

使用具名泛型參數表示傳回的檢視,並使用具體型別表示元件的輸入。在下方範例中,核心簽章是 @ViewBuilder content: @escaping (SwatchInput) -> Content,並置於以 Content: View 限制的宣告中。

輸入與輸出各有不同用途。SwatchInput 定義元件提供的值。Content 表示各呼叫端閉包所產生的具體檢視型別。具名泛型參數讓宣告能表達並重複使用這些型別關係,詳見 Swift 的 泛型 章節。

Apple 在 GeometryReader 中採用這種介面模式:其初始器接受型別為 (GeometryProxy) -> Content 的閉包。容器提供代理值,呼叫端則根據它建立內容。對於提供自有組態的自訂元件,這是很好的參考模式。請參閱 Apple 的 GeometryReader 和 init(content:) 文件。

請根據實際任務選擇契約:

需求 合適的介面
呼叫端提供內容,元件不提供輸入 () -> Content
元件提供特定組態 (SwatchInput) -> Content
容器接收的值型別會因使用方式而異 使用具名的 Input 泛型參數,並搭配相符的 (Input) -> Content 閉包
編譯器必須證明呼叫端使用了組態 閉包簽章無法表達這項要求

這些選擇源自 Swift 的函式型別:其參數型別與傳回型別描述可以傳入及傳回的內容,卻不描述實作必須如何使用引數。請參閱 Swift 語言參考中的函式型別一節。

建立完整的 macOS 色票預覽

使用採用 SwiftUI 的 macOS App 目標。此範例使用既有的 @ViewBuilder 寫法,適用於 Swift 5.7 或更新版本,部署目標為 macOS 12 或更新版本。它不使用預覽巨集或較新的內容建構器名稱。研究環境中沒有 Swift 和 Apple SDK,因此本程式僅經檢視,未在此編譯或執行。

在 Xcode SwiftUI App 專案中,以以下完整程式取代產生的 App 宣告。若產生的 ContentView 宣告與此重複,請將其移除。只保留一個標記為 @main 的 App 進入點。Apple 的 Xcode 說明介紹了如何從 SwiftUI 專案開始,或在現有專案中加入 SwiftUI 檔案。

import SwiftUI

struct SwatchInput {
    let title: String
    let side: CGFloat
    let color: Color
}

@MainActor
struct SwatchPanel<Content: View>: View {
    @State private var isLarge = false
    private let content: (SwatchInput) -> Content

    init(
        @ViewBuilder content: @escaping (SwatchInput) -> Content
    ) {
        self.content = content
    }

    private var input: SwatchInput {
        SwatchInput(
            title: "Sample A",
            side: isLarge ? 120 : 64,
            color: .blue
        )
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Toggle("Large sample", isOn: $isLarge)
            content(input)
        }
        .padding(24)
    }
}

@MainActor
struct ContentView: View {
    var body: some View {
        SwatchPanel { input in
            Text(input.title)
                .font(.headline)

            RoundedRectangle(cornerRadius: 12)
                .fill(input.color)
                .frame(width: input.side, height: input.side)

            if input.side == 120 {
                Text("Large preview")
                    .font(.caption)
            }
        }
        .frame(width: 300, height: 280, alignment: .topLeading)
    }
}

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

預期的初始畫面會顯示 Large sample 切換按鈕、標題 Sample A,以及邊長為 64 點的藍色圓角方形。開啟切換按鈕後,提供的邊長會變為 120 點,並顯示 Large preview。這些尺寸與標籤是示意用的本機值,並非測量結果。

元件擁有 isLarge。其計算屬性 input 會封裝目前的選取狀態,而 content(input) 會將該封裝傳給呼叫端。SwiftUI 會管理狀態儲存,並在值變更時更新檢視階層中相依的部分,詳見 State 說明。

呈現方式由呼叫端負責:標題、形狀,以及可選的說明文字。呼叫端可以替換成另一種配置,同時保留相同的具型別輸入。面板和其呼叫端都會實作 body 來組合介面,遵循 Apple 文件所述的 View 模型。

為什麼需要建構器與 escaping 註記?

@ViewBuilder 讓呼叫端能在單一閉包中撰寫多個檢視運算式,以及支援的條件分支。在此範例中,呼叫端提供兩個無條件檢視和一個條件式說明文字。建構器會將這些內容組合成符合元件傳回型別限制的結果。Apple 在 ViewBuilder 中說明多個子檢視與條件式建構。

建構器不會提供 SwatchInput。一般閉包參數會宣告輸入,而 content(input) 則提供實際引數。如果初始器接受 () -> Content,加入建構器註記也不會讓它變成能接受組態的閉包。

需要 @escaping,因為初始器會將閉包儲存在屬性中,供之後從 body 呼叫。Swift 的閉包章節說明,若閉包能在接收它的函式傳回後呼叫,就稱為逃逸閉包。

如果元件的組態在初始化時已定案,另一種設計是立即呼叫非逃逸的建構器,並儲存產生的 Content 值。這會改變套用組態的時機。在此範例中,保留閉包能明確呈現預期流程:從元件狀態推導目前的輸入,再使用該輸入建立內容。

保留 Xcode 建構器的適用界線

Apple 目前的文件區分 Xcode 26 及更早版本,以及 Xcode 27 及更新版本。對於較早版本的工具,文件說明 ViewBuilder 用來建構檢視。對於 Xcode 27 及更新版本,文件則說明它用於型別無關的建構,並建議使用 ContentBuilder 別名來表示這種更廣泛的行為。請參閱 ViewBuilder。

維護 Xcode 27 或更新版本的程式碼庫時,初始器可以改用 @ContentBuilder。Apple 將其記載為 ViewBuilder 的型別別名,並透過它產生的內容型別強制執行符合性。此元件仍應保留 Content: View:其輸出依然預期會放入檢視階層。不要只因目前的文件列出較早的部署目標,就假設較舊的 SDK 也支援較新的寫法。請參閱 ContentBuilder。

為什麼 some View 無法解決契約的所有部分?

some View 適用於範例中的 body 屬性。它會隱藏具體結果型別,同時保留結果必須是檢視的承諾。儲存的閉包則使用具名的 Content 參數,讓元件能在屬性和初始器中參照相同的結果型別。

Swift 5.7 透過 SE-0341 引入了不透明參數宣告。像 _ value: some View 這樣的直接參數會引入未命名的泛型參數,其具體型別由呼叫端決定。這不表示函式實作可以自行選擇任意檢視,並將它傳到預期為該未命名型別的位置。請參閱不透明參數宣告。

尤其是,寫成 (some View) -> Content 的回呼參數,不屬於 SE-0341 文件所述支援的不透明參數位置。該提案明確禁止在回呼函式型別的輸入位置使用不透明型別。提案中討論可能支援這種語法的內容,列於未來方向,而非已實作的行為。目前的型別參考文件仍保留此限制。

對於這項桌面任務,支援的做法很直接:將元件提供的值設為具體的組態型別。如果另一個可重複使用的容器必須保留呼叫端提供的輸入型別,就以泛型參數命名該型別,並在儲存的值與回呼中一致使用。這兩種設計都不需要不透明的回呼輸入。

編譯器實際上保證了什麼?

此簽章保證可呼叫的形式:一個 SwatchInput 引數,以及符合 Content: View 的結果。它也會推斷閉包參數的型別,因此呼叫端可以存取 input.title、input.side 和 input.color,無須重複標註型別。Swift 在閉包文件中說明了這種根據閉包上下文進行的推斷。

它無法保證這些屬性會影響顯示內容。例如,以下是 ContentView.body 中有效的替代運算式;它刻意接受組態,但不使用它:

SwatchPanel { _ in
    Text("Fixed preview")
}

撰寫元件需求時,這項界線很重要。「呼叫端內容接受 SwatchInput」是型別契約。「呼叫端內容會顯示提供的尺寸」則是行為需求。如果顯示該尺寸對元件至關重要,請將該繪製邏輯放在元件內,並提供較小的自訂區域。

使用以下檢查步驟診斷整合問題。這些是根據介面整理的檢查步驟,並非回報的編譯器測試結果:

問題 檢查項目
呼叫 content 時缺少引數 傳入組態:content(input)。
初始器出現逃逸閉包診斷 儲存回呼時保留 @escaping。
呼叫端的多個同層檢視發生錯誤 在初始器參數上保留適當的建構器註記。
明確標註型別的回呼遭到拒絕 確認其輸入為 SwatchInput,而不是 String 等無關型別。
預覽忽略尺寸切換按鈕 檢查繪製內容是否讀取 input.side。
元件持續顯示初始組態 檢查內容是否只建構並儲存一次,而非使用目前輸入呼叫回呼。

讓契約保持符合任務所需的最小範圍。具體的組態與泛型檢視結果為此色票面板劃出清楚界線:元件提供目前的值,呼叫端能取得具完整型別資訊的值,而呈現方式也容易替換。

來源