macOS SwiftUI の画面で異なる型の完了値を受け取るには、各コンポーネントにジェネリックアダプターまたは型付きクロージャーを渡します。Swift では、画面が異なる関連型で同じプロトコルに複数回適合することはできません。

このチュートリアルでは、ボタンで合成したタイトルと列数を適用する、小さなデスクトッププレビューを作成します。各コンポーネントの完了時の契約をコンパイラーに検査させながら、1つの親ビューで両方の値を受け取れるようにすることが目的です。

1つの型が両方の完了時の契約に適合できないのはなぜですか?

プロトコルの関連型は、その適合に属します。完了プロトコルが Value という関連型を宣言している場合、適合する型はその型に一貫した選択を与える必要があります。完了メソッドをオーバーロードして2つ追加しても、独立した2つの適合にはなりません。Swift 言語ガイドでは、プロトコルへの適合時に関連型がどのように具体型を得るかが説明されています。ジェネリクス — 関連型。

たとえば、画面は1つの完了プロトコルに、Value が TitleChoice の場合と LayoutChoice の場合の2回適合することはできません。宣言を別々の拡張に移しても、この制約は変わりません。SE-0143 は、条件が互いに重ならない条件付き適合も含め、複数回の適合を禁止する規則を明示的に維持しています。条件付き適合。

プライマリ関連型を使っても、この境界はなくなりません。CompletionHandling<TitleChoice> という構文は、関連型に制約を表すものであり、引数ごとに別個のプロトコル識別子を作るものではありません。SE-0346 は、Swift 5.7 でこの制約構文を導入しました。プライマリ関連型に対する軽量な同一型要件。

以下で使うサポート対象の設計では、適合をジェネリックアダプターに移します。適合宣言は1つだけで、アプリケーションは2つのコンポーネント向けに、それぞれ異なる特殊化を適用したアダプター値を作成します。親ビューは完了プロトコルに適合せずに、返された値を含む状態を管理します。

コンポーネントにはどの完了設計が適していますか?

インターフェイスを変更する前に、次の判断材料を使ってください。これらは文書化された型規則から導いた設計上の推奨事項であり、Swift 言語の追加要件ではありません。

コンポーネントの要件 推奨する設計 明示されるもの
インターフェイスを自分で管理でき、コールバックが1つ 型付きクロージャー コールバックの入力型
既存のプロトコルベース API 適合するジェネリックアダプター プロトコルとその関連型
複数の名前付き選択肢を運ぶイベントチャネルが1つ 関連値を持つ列挙型 受信側が処理する各イベントケース
意味の異なる複数の無関係な操作 個別の名前付きプロトコル 各操作固有の要件

この例では、再利用可能なコンポーネントがすでにプロトコルベースのハンドラーを要求しているとします。ジェネリックアダプターを使えばその境界を保ちながら、画面の処理コードを状態更新の近くに置けます。コンポーネントに必要なのがコールバックだけで、プロトコルの要件がない場合は、クロージャーのほうが簡潔です。

ジェネリックな完了メソッドは、異なる約束を表します。complete<T>(_ value: T) のようなメソッドは、制約で許可されるすべての型に対して機能する必要があります。各コンポーネントが特定の結果型を1つだけ返すという、より限定的な要件を表すものではありません。Swift のジェネリクスのドキュメントでは、ジェネリック関数が呼び出し箇所で選択された型をどのように扱うかが説明されています。ジェネリクス — ジェネリック関数。

型付き完了アダプターを作成する

この例では、macOS SwiftUI アプリケーションターゲット、Swift 5.7 以降のコンパイラー、macOS 12 以降のデプロイメントターゲットを使います。コンパイラー要件はプライマリ関連型の宣言に由来します。デプロイメントターゲットは、このチュートリアルの保守的な基準であり、ここで示すすべての API が macOS 12 で初めて登場したという意味ではありません。

以下の3つのファイルで完全な例を構成します。同じアプリケーションターゲットに追加し、生成されたアプリのエントリーポイントとコンテンツビューを置き換えて、ターゲット内の @main 宣言が1つだけになるようにします。パッケージ、アセット、サンプルファイル、外部入力は不要です。

CompletionContracts.swift を作成し、次の宣言を記述します。

import SwiftUI

struct TitleChoice {
    let text: String
}

struct LayoutChoice {
    let columns: Int
}

@MainActor
protocol CompletionHandling<Value> {
    associatedtype Value
    func complete(_ value: Value)
}

@MainActor
struct CompletionAdapter<Output>: CompletionHandling {
    typealias Value = Output

    private let receive: @MainActor (Output) -> Void

    init(_ receive: @escaping @MainActor (Output) -> Void) {
        self.receive = receive
    }

    func complete(_ value: Output) {
        receive(value)
    }
}

@MainActor
struct CompletionButton<Value, Handler: CompletionHandling>: View
where Handler.Value == Value {
    let title: String
    let value: Value
    let handler: Handler

    var body: some View {
        Button(action: {
            handler.complete(value)
        }) {
            Text(title)
        }
    }
}

制約 Handler.Value == Value により、コンポーネントの出力がハンドラーの受け入れる入力に結び付きます。そのため、TitleChoice を持つボタンには、関連型が TitleChoice のハンドラーが必要です。明示的な型エイリアスにより、アダプターの関連型を確認しやすくなります。

アダプターはクロージャーを後で呼び出すために保持するので、イニシャライザーのその引数には @escaping を付けます。Swift のクロージャーに関するドキュメントでは、クロージャーが受け取った関数呼び出しの終了後も存続する可能性がある場合に、この注釈が必要とされています。クロージャー — エスケープクロージャー。

プロトコル、アダプター、ビューはすべて @MainActor を使い、保持するコールバックも関数型でその分離を維持します。これにより、UI の想定実行コンテキストが契約の一部になります。SE-0316 では、グローバルアクターによる分離と、グローバルアクター修飾関数型の両方が説明されています。グローバルアクター。

Apple のドキュメントでは、Button はアクションとラベルを受け取ると説明されています。ここでは、アクションがコンポーネントの型付け済みの値を使ってハンドラーを呼び出し、Text(title) が画面に表示されるラベルを指定します。Button。

2つの結果型を1つの SwiftUI 画面に接続する

ContentView.swift を作成します。タイトルとレイアウトの結果には別々の状態の保存先を用意し、それぞれのアダプタークロージャーが対応する保存先だけを更新します。

import SwiftUI

@MainActor
struct ContentView: View {
    @State private var previewTitle = "Sample Board"
    @State private var columnCount = 1

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Component Preview")
                .font(.headline)

            Text(previewTitle)
            Text("Columns: \(columnCount)")

            HStack {
                CompletionButton(
                    title: "Apply title",
                    value: TitleChoice(text: "Pebble Board"),
                    handler: CompletionAdapter<TitleChoice> { choice in
                        previewTitle = choice.text
                    }
                )

                CompletionButton(
                    title: "Apply layout",
                    value: LayoutChoice(columns: 3),
                    handler: CompletionAdapter<LayoutChoice> { choice in
                        columnCount = choice.columns
                    }
                )
            }
        }
        .padding(24)
        .frame(minWidth: 420, minHeight: 180)
    }
}

アプリケーション唯一のエントリーポイントとして、TypedCompletionsApp.swift を作成します。

import SwiftUI

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

親ビューは初期値を非公開の @State プロパティに保持します。Apple のドキュメントによれば、SwiftUI がこのストレージを管理し、値が変わると依存するビューを更新します。また、Xcode 27 以降でビルドする場合は State マクロを使うと、現在のドキュメントで説明されています。ここに示した宣言スタイルは、それ以前のプロパティラッパーを使う基準に沿っています。State。

WindowGroup は画面のビュー階層をホストします。Apple のドキュメントによれば、このグループが作成する各ウィンドウには独立した状態ストレージが用意されるため、この例の状態はプレビューの各ウィンドウに属します。WindowGroup。

特殊化されたアダプター型は2つあります。CompletionAdapter<TitleChoice> と CompletionAdapter<LayoutChoice> です。どちらも1つのジェネリックアダプター宣言から作られています。ContentView が適合するのは View だけで、2つの結果は画面が渡すクロージャーを通じて受け取ります。

動作を確認し、不一致を診断する

調査環境には Swift コンパイラーがインストールされていないため、これらのファイルは確認しましたが、コンパイルも実行もしていません。お使いの Mac でアプリケーションターゲットをビルドし、次の手順で意図した動作を確認してください。

  1. アプリを起動します。プレビューに Sample Board と Columns: 1 が表示されます。
  2. Apply title をクリックします。タイトルが Pebble Board に変わり、列数は変わりません。
  3. Apply layout をクリックします。列数が Columns: 3 に変わり、選択したタイトルは変わりません。
  4. どちらかのボタンをもう一度クリックします。同じ合成値が再び適用されます。

型の境界を確認するには、1つ目のボタンのハンドラーの特殊化を一時的に CompletionAdapter<LayoutChoice> に変更し、クロージャーの本体を空にします。そのボタンの値は TitleChoice のままにします。この場合、コンポーネントは Handler.Value == Value を介して互換性のない型どうしを同一とする必要があるため、コンパイルに失敗するはずです。その後、元のハンドラーに戻してください。

コンパイラーが複数回の適合を報告する場合は、同じ名義型を CompletionHandling に適合させている別の宣言を探してください。ジェネリックアダプターの適合宣言は1つに保ち、出力ごとに別のアダプター値を作成します。同じ適合を宣言する2つの制約付き拡張も、Swift の複数回適合の制約を受けます。条件付き適合 — 複数の適合。

コンパイラーがプロトコルのプライマリ関連型構文を拒否する場合は、コンパイラーのバージョンを確認してください。古いコンパイラーでも、通常の関連型宣言を使えばアダプター設計を維持できます。これはプロトコル宣言だけを置き換えるコードです。それ以外の宣言は変更しないでください。

@MainActor
protocol CompletionHandling {
    associatedtype Value
    func complete(_ value: Value)
}

この変更により、この例が Swift 5.7 構文に依存しなくなります。Swift 5.5 で導入された @MainActor のコンパイラー要件はなくならず、チュートリアルのデプロイメントターゲットも変わりません。コンポーネントはすでにジェネリックな where 節を使っているため、使用箇所でプライマリ関連型構文は必要ありません。軽量な同一型要件、グローバルアクター。

この完了時の契約で保証されることは何ですか?

このサンプルは、各コンポーネントの境界でペイロード型が一致することを保証します。実装は各ボタンの操作につき、ハンドラーを同期的に1回呼び出します。非同期処理、キャンセル、永続化、一度だけ完了するライフサイクルは実装していません。そうした機能には、追加の動作と明示的な契約が必要です。

このサンプルでは、既知のローカル値も使っています。将来、コンポーネントが任意の列数を受け付ける場合は、プレビューを更新する前に入力を検証し、拒否をどう通知するかを定義してください。Swift の型が一致するだけでは、その整数が列数として有用であることまでは保証されません。

このデスクトップ向けタスクでは、型付きの各エンドポイントに1つずつアダプターを用意し、親ビューが返された状態を管理するようにします。そうすることで、再利用可能な1つの適合宣言を使いながら、2つのコンポーネントに明確な完了時の契約を与えられます。また、各結果の保存先を呼び出し箇所で確認できます。

参照元