1つのデリゲートで複数の SwiftUI コンポーネント型を扱えるようにするには、コールバックメソッドをジェネリックにし、コンポーネントをデリゲートの具体型から独立させます。このガイドでは、再帰的な Self 要件を使わずに送信元のコンテンツ型を維持する、小さな macOS タイル選択アプリを作ります。

構文を選ぶ前にコールバックの契約を決める

読者が取り組む課題は明確です。コンテンツ型を変更できる再利用可能な SwiftUI コンポーネントを作り、対応するすべてのコンテンツ特殊化からの選択コールバックを1つのデリゲートが受け取れるようにします。ここでは、テキストと円という2つのサンプルタイルを使い、選択したタイルの識別子を表示します。

Swiftでは、デリゲーションはクラスや構造体が責務を別のインスタンスに委ねるためのプロトコルベースの方法です。言語ガイドのデリゲーション例にあるように、デリゲートは送信元とともにアクションを受け取れます。プロトコル

このコンポーネントで意図する契約は、「Viewに準拠するあらゆるコンテンツ型について、デリゲートはそのコンテンツを含むタイルを受け取れる」です。型パラメーターはコールバックメソッドに置きます。Swiftのジェネリック関数は呼び出しごとに具体的な型引数が決まりますが、関連型はプロトコル準拠によって確定します。これは異なる制約です。ジェネリクス

要件 適したインターフェースの形 結果
1つのデリゲートで対応するすべてのコンテンツ型を扱う ジェネリックなコールバックメソッド コールバックごとに異なるコンテンツ型を扱えます。
各デリゲートが1つの固定されたコンテンツ型を扱う 関連型 各準拠で1つのコンテンツ型を指定します。
コールバックに必要なのがタイル識別子だけ 識別子を受け取るコールバック コールバックは描画型に依存しません。

この表は、これらの言語仕様に基づく設計の手がかりです。型付きの送信元を維持することがAPIの一部なら、最初の行を選びます。送信元の識別子だけで受信側に必要な情報が揃うなら、3行目を選びます。その場合、コンポーネント全体を渡すと不要な依存関係が増えます。

デリゲート自身の型への依存を取り除く

密結合な設計では、コンポーネントをデリゲートに対してジェネリックにし、デリゲートプロトコル内でそのコンポーネントを Component<Self> として参照することがあります。ここでの Self は、最終的にプロトコルに準拠するデリゲート型を意味します。関連型も同様に、その準拠型に属します。宣言

この形では、コールバックの型検査が、デリゲートに依存するコンポーネントの特殊化に依存します。特定の再帰的な宣言が受け入れられるかどうかは、制約全体とコンパイラの診断を確認する必要があります。Swiftが再帰的なプロトコル関係をすべて拒否すると言うのは正確ではありません。SE-0157ではSwift 4.1で、関連型に対する再帰的な制約が追加されました。関連型に対する再帰的制約のサポート

今回の課題では、デリゲートの具体型をコンポーネント型に含める必要はありません。以下の設計では、Tileに Content というジェネリックパラメーターを1つ持たせます。保存するデリゲートは any TileDelegate で、プロトコル要件ではメソッド固有の Content パラメーターを導入します。コールバックは、デリゲートの Self を介さずに送信元のコンテンツ型を維持します。

any という表記は、存在型の値を明示します。保存する値には、準拠するデリゲート型のインスタンスを格納できます。SE-0335で、Swift 5.6からこの表記が導入されました。デリゲートに存在型を使っても、タイルのコンテンツを AnyView に型消去する必要はありません。存在型の any を導入

macOSのタイル選択アプリを完成させる

Xcode 16以降に付属するコンパイラなど、Swift 6コンパイラを使うmacOS SwiftUIアプリのターゲットを用意します。この例で @StateObject による保存を使うため、macOSのデプロイメントターゲットを11.0以降に設定します。Appleのドキュメントによると、StateObject はmacOS 11.0以降で利用できます。StateObject

生成されたアプリ宣言と初期ビューを、この1つのソースファイルに置き換えます。Swiftではプログラムのエントリーポイント方式を1つしか使えないため、ターゲット内の @main アプリ宣言が1つだけであることを確認してください。宣言

import SwiftUI
import Combine

@MainActor
protocol TileDelegate: AnyObject {
    func didChoose<Content: View>(_ tile: Tile<Content>)
}

@MainActor
struct Tile<Content: View>: View {
    let id: String
    let delegate: any TileDelegate
    let content: Content

    init(
        id: String,
        delegate: any TileDelegate,
        @ViewBuilder content: () -> Content
    ) {
        self.id = id
        self.delegate = delegate
        self.content = content()
    }

    var body: some View {
        Button(action: {
            delegate.didChoose(self)
        }) {
            VStack(spacing: 8) {
                content
                    .frame(width: 100, height: 50)
                Text("Choose \(id)")
            }
            .padding(8)
        }
    }
}

@MainActor
final class SelectionModel: ObservableObject, TileDelegate {
    @Published private(set) var selection = "No tile selected"

    func didChoose<Content: View>(_ tile: Tile<Content>) {
        selection = "Selected: \(tile.id)"
    }
}

@MainActor
struct ChooserView: View {
    @StateObject private var model = SelectionModel()

    var body: some View {
        VStack(spacing: 16) {
            HStack(spacing: 16) {
                Tile(id: "text", delegate: model) {
                    Text("Sample")
                }

                Tile(id: "circle", delegate: model) {
                    Circle()
                }
            }

            Text(model.selection)
        }
        .padding(24)
        .frame(minWidth: 340, minHeight: 180)
    }
}

@main
@MainActor
struct DelegateDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ChooserView()
        }
    }
}

これは、インポート、デリゲート、コンポーネント、モデル、エントリーポイントを含む、アプリの完全な例です。利用可能な環境にSwiftコンパイラやmacOS SDKがないため、ここでは内容を確認しましたが、コンパイルや実行はしていません。

Appleの Button APIは、アクションとビューを生成するラベルを受け取ります。この例では、タイルをクリックするとボタンのアクションからデリゲートが呼び出されます。body を評価するとインターフェースが構築されますが、選択イベントは送信されません。Button

Macでアプリをビルドして実行します。最初のステータスは No tile selected と表示されます。Choose text をクリックすると Selected: text、Choose circle をクリックすると Selected: circle と表示されます。これはこの例で期待される結果であり、実行結果の報告ではありません。

この例で維持されるものを理解する

1つ目のタイルの型は Tile<Text>、2つ目は Tile<Circle> です。どちらも同じ SelectionModel インスタンスを使います。そのジェネリックなコールバックは、共通の id プロパティを読み取りながら、どちらのコンテンツ型についてもプロトコル要件を満たします。これが、型パラメーターをメソッドに置く実用上の利点です。

受信側は Content として tile.content にもアクセスできます。ただし、ジェネリック実装内で宣言された制約が保証するのは View の機能だけです。テキスト固有の操作が必要なコードには、別の契約か追加情報が必要です。コールバックでは、すべてのコンテンツ値が Text だとは仮定できません。これはSwiftの制約ベースのジェネリックモデルによるものです。ジェネリクス

コンポーネントはAppleがドキュメントで示すカスタムビューのパターンに従い、some View を使って body を実装します。現在の View 宣言にはメインアクター分離も含まれます。コールバックとモデル更新が同じ分離のもとで行われるよう、この例ではUIに関わるプロトコルと型を明示的に @MainActor としています。View

@StateObject は、包含するビューのアイデンティティが存続する間、安定したモデルの保存領域を提供します。モデルの公開された選択値が変更されると、それに依存するステータスビューが更新されます。モデルの生成を通常の計算プロパティ式に移すと、StateObject による所有を表せなくなります。StateObject

この例では、必須のデリゲートを強参照で保存します。モデルが保存するのは選択文字列だけなので、示したオブジェクトグラフにタイルへの参照はありません。後でモデルに送信元を保持させる場合は、所有関係を見直してください。その関係で必要になる場合、Appleの言語ガイドではクラス制約付きデリゲートを弱参照で保存する例を示しています。プロトコル

関連型がより適切な契約となる場合

テキストタイルだけを受け取るレシーバーを想定します。別のプロトコルで associatedtype Content: View を宣言し、func didChoose(_ tile: Tile<Content>) を要件とする方法があります。これは準拠ごとに1つのコンテンツ型を表します。Content をプライマリ関連型にすると制約構文を簡潔にできますが、あらゆるコンテンツ型を受け取るメソッド要件にはなりません。SE-0346で、Swift 5.7にプライマリ関連型が導入されました。プライマリ関連型に対する軽量な同一型要件

この方法は別バージョンのAPIになります。また、コンポーネントのデリゲートプロパティとイニシャライザーの引数を、any FixedTileDelegate<Content> のような対応する制約付き存在型に変更し、各レシーバーで固定の関連型と、それに対応する非ジェネリックのコールバックを指定する必要があります。テキストだけを扱う準拠では、同じ準拠のまま円形タイルを扱えなくなります。

SE-0353ではSwift 5.7で制約付き存在型が追加され、存在型のプライマリ関連型を指定した型に束縛できるようになりました。1つの関連型を束縛することは、固定された関係を表します。任意のコンテンツ型に対応する一連のコールバック実装を提供するものではありません。制約付き存在型

コンパイラのサポートとデプロイメントのサポートは分けて考えてください。SE-0353では、既存のOSリリースには後方デプロイできないランタイムの変更が明記されています。古いデプロイメントターゲットでこの方法を採用する前に、実際のターゲットで利用可能性に関する診断を確認してください。上記の完全な例は通常のデリゲート存在型を使い、制約付き存在型による保存を必要としません。制約付き存在型

適応に失敗したときの診断手順

  1. コールバックが扱う範囲を確認する。 1つのデリゲートでテキストタイルと円形タイルを扱うなら、その実装はジェネリックメソッドのシグネチャを維持する必要があります。Tile<Text> だけを受け取るメソッドでは、その要件を満たしません。
  2. コンポーネントのジェネリックパラメーターを確認する。 この設計では、Tile は Content に依存します。その型にデリゲートの具体型は含まれません。
  3. アクター分離を確認する。 UIコールバックと、この例の監視対象モデルはメインアクター上に保ちます。別の分離コンテキストから呼び出す場合は、その境界を守る必要があります。
  4. イベントを送る場所を確認する。 body の計算中やビューの初期化時ではなく、ボタンのアクションから選択を送信します。
  5. モデルの所有を確認する。 親ビューでは @StateObject を維持し、コールバック用に2つ目のモデルインスタンスを作らないようにします。

それでも適応したコードが失敗する場合は、タイル1つとデリゲート実装1つに絞り、その後で2つ目のコンテンツ型を戻します。この順序なら、プロトコル要件の実装に関する問題とビューの合成に関する問題を区別できます。異なるコンテンツ型を2つ使うこの例は、契約を確認するのに役立ちます。キャストやコンテンツの型消去なしに、どちらも同じジェネリックコールバックに届く必要があります。

重要な設計上の判断は、型の違いをどこに置くかです。あらゆるコンポーネント特殊化を扱うデリゲートでは、その違いをコールバックメソッドに置き、コンポーネントの型をデリゲートの具体的な識別から独立させます。こうすることで、型付きの送信元を維持しながら、macOSの選択処理を確認や拡張がしやすい小さな形に保てます。

参考資料