異種の SwiftUI コンポーネントを表示する macOS ウィンドウでは、各プロバイダーの出力を強い型付けのまま保ち、共通のレンダリングインターフェースで単一の具体的なビュー型が必要になった時点で型消去します。コレクション自体に統一されたラッパーが必要なら、より早い段階でプロバイダーを型消去します。
この記事では、合成テキストと青いスウォッチを含む小さなコンポーネントギャラリーを作成します。異なるプロバイダー実装をまとめて格納し、単一の SwiftUI インターフェースを通じてレンダリングするのが目的です。設計上の判断は、プロバイダーとその出力の関係をどこまで公開するかです。
プロバイダーのコントラクトでは何を保持する必要があるか?
関連出力型を持つプロバイダーは、準拠する各型が独自のビュー型を選ぶことを示します。具体的な実装では some View の背後にその選択を隠せますが、ジェネリックな利用側ではプロバイダーと出力の関係が保たれます。Swift の不透明型は型の同一性を保ち、ボックス化されたプロトコル型は共通インターフェースの背後に異なる基底型を保持できます。不透明型とボックス化されたプロトコル型を参照してください。
以下はコントラクトの一部であり、完全なアプリケーションではありません。
import SwiftUI
@MainActor
protocol ViewProvider {
associatedtype Content: View
var id: String { get }
func makeView() -> Content
}
@MainActor
struct TitleProvider: ViewProvider {
let id: String
let title: String
func makeView() -> some View {
Text(title).font(.headline)
}
}
ここでは、TitleProvider.Content は makeView() の不透明な戻り値型から推論されます。プロトコルは関連型を要求し、実装は推論によってそれを提供します。SE-0244 では、このサポート対象のパターンと、不透明な戻り値型をプロトコル要件として直接宣言できない制約が明記されています。したがって、要件を func makeView() -> some View に置き換えることは、その提案で説明されているサポート対象のコントラクトではありません。不透明な結果型を参照してください。
any ViewProvider の配列を使うと、異種の値を格納できます。Swift 5.7 の SE-0309 により、関連型を持つプロトコルを存在型として利用できますが、個々のメンバー操作には引き続き制約があります。その存在型を通じて出力を読み取ると、関連型がプロトコルの上限型に型消去されることがあります。この場合は any View です。すべてのプロトコルで存在型を使えるようにするを参照してください。
これにより、別々の判断が二つ生じます。混在するプロバイダーをどう格納するか、そしてレンダラーがどの具体的な型を返すかです。Apple は AnyView を View に準拠する具体的な型消去済みビューとして説明しており、二つ目の判断における明示的な選択肢になります。AnyViewを参照してください。
利用側の要件に合わせて境界を選ぶ
プロトコルを変更する前に、次の判断表を使ってください。これらの推奨事項は、文書化された型のコントラクトをギャラリーのタスクに当てはめた設計上の指針であり、測定済みのパフォーマンス結果ではありません。
| 利用側の要件 | 適した設計 | 型情報を隠す場所 |
|---|---|---|
| 再利用可能なコードで一つの具体的なプロバイダーをレンダリングする | ViewProvider に制約されたジェネリックな利用側 |
プロバイダーと出力の関係を利用可能なまま保つ |
| 異なるプロバイダーを格納し、共通のメタデータを保持する | [any ViewProvider] と、AnyView を返すレンダリングヘルパー |
格納時にプロバイダー型、レンダリング時に出力型 |
| 下流コードに単一の具体的なプロバイダーラッパーを公開する | [AnyViewProvider] |
ラッパーの構築時 |
| すべての実装が、すでに型消去されたビューを返すことを要求する | AnyView を返すプロトコル要件 |
各実装の内部 |
| 固定された少数のコンポーネント候補を表示する | 列挙型とビルダーを使った switch |
列挙型で候補を明示的に保つ |
このギャラリーでは、存在型による格納と出力の型消去を選びます。各プロバイダーは独自の出力型を保ち、コレクションは安定した文字列識別子を公開し、一つのヘルパーが AnyView への変換を担います。コンポーネントの内部レイアウトを変更しても、そのコンポーネント内に新たな型消去コードを追加する必要はありません。
ジェネリックなコレクション引数が適するのは別のタスクです。要素型が一つのジェネリックなプロバイダー型である配列では、互いに無関係な複数のプロバイダー型を自動的に表現できません。Swift の暗黙的な存在型のオープンは個々の存在型引数に対して働きます。異種配列に、共通する一つの準拠型を与えるものではありません。暗黙的にオープンされる存在型を参照してください。
完全な macOS コンポーネントギャラリーを作る
この例には、Swift 5.7 以降のコンパイラと macOS SwiftUI アプリケーションのターゲットが必要です。サンプルのデプロイメントターゲットは macOS 12 以降に設定します。SwiftUI を使う Xcode macOS App プロジェクトで、生成されたアプリ宣言を以下の完全なプログラムに置き換え、@main 宣言を正確に一つだけ残してください。
明示的な @MainActor 注釈により、プロバイダーの作成とレンダリングが同じアクター上で行われます。Apple の現行の View 宣言はメインアクター分離されています。また、カスタムビューが body を通じてコンテンツを提供することも、ドキュメントで説明されています。Viewを参照してください。
import SwiftUI
@MainActor
protocol ViewProvider {
associatedtype Content: View
var id: String { get }
func makeView() -> Content
}
@MainActor
struct TitleProvider: ViewProvider {
let id: String
let title: String
func makeView() -> some View {
Text(title)
.font(.headline)
}
}
@MainActor
struct SwatchProvider: ViewProvider {
let id: String
func makeView() -> some View {
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue)
.frame(width: 96, height: 48)
}
}
@MainActor
func erasedView<P: ViewProvider>(for provider: P) -> AnyView {
AnyView(provider.makeView())
}
@MainActor
struct GalleryView: View {
let providers: [any ViewProvider]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
ForEach(providers, id: \.id) { provider in
erasedView(for: provider)
}
}
.padding(24)
.frame(minWidth: 280, minHeight: 180)
}
}
@main
@MainActor
struct ProviderGalleryApp: App {
private let providers: [any ViewProvider] = [
TitleProvider(id: "title", title: "Component gallery"),
SwatchProvider(id: "swatch")
]
var body: some Scene {
WindowGroup {
GalleryView(providers: providers)
}
}
}
意図する結果は、青い角丸長方形の上に Component gallery が表示された一つのウィンドウです。入力値はすべてローカルで生成された合成値です。この例は確認済みですが、利用可能な環境に Swift コンパイラや Apple SDK がないため、ここではコンパイルしていません。
プロバイダーの関連出力型にかかわらず、ヘルパーの戻り値は常に AnyView です。ヘルパー内ではプロバイダーはジェネリックで、その Content との関係が保たれています。Apple は、ここで使うイニシャライザーを、View に制約されたジェネリックな値を受け取り、それを型消去するものと説明しています。AnyView.init(_:)を参照してください。
このコレクションでは id: \.id を使います。プロトコルが id を String に固定しており、別の未知の関連型を復元する必要がないためです。Apple の ForEach のドキュメントでは、要素が識別可能であるか、明示的な識別子のキーパスを指定する方法が認められています。このコレクション内で識別子を一意にし、論理上の各コンポーネントについて更新をまたいで安定させてください。ForEachを参照してください。
ジェネリックなヘルパーは機能するのに、ジェネリックなビューラッパーが機能しないのはなぜか?
erasedView の重要な特性は、戻り値型が具体的であることです。Swift 5.7 で存在型引数の暗黙的なオープンが導入され、呼び出し時にコンパイラが存在型の値の基底型をジェネリック型パラメーターに束縛できるようになりました。ただし、オープンされた型を不変なジェネリックコンテナーを通じて公開するような結果には、引き続き制限があります。SE-0352 には、Swift 6 言語モードで有効になる、より広範なオープンの動作も記載されています。暗黙的にオープンされる存在型を参照してください。
たとえば、次の診断用コードは上記のプログラムにジェネリックなラッパーを追加します。コメントアウトされた呼び出しは問題となる境界を示すもので、追加のアプリケーションエントリーポイントではありません。
@MainActor
struct ProviderRow<P: ViewProvider>: View {
let provider: P
var body: some View {
provider.makeView()
}
}
@MainActor
func inspectBoundary(_ provider: any ViewProvider) {
// Problematic: the constructed result would retain the opened P.
// let row = ProviderRow(provider: provider)
// Supported boundary: the result is the concrete type AnyView.
let row = erasedView(for: provider)
_ = row
}
これにより、ジェネリックなレイヤーを追加するだけではエラーが解消しない場合がある理由が分かります。引数と結果の両方を調べてください。具体的な型消去済みビューを返すヘルパーは、呼び出しの中で変換を完了します。一方、ProviderRow<P> のような結果は、その構造の中にプロバイダー型を保持します。
同様に、some View は、基底型が実行時に任意に変わり得る戻り値の別表記ではありません。実装は一貫した基底型を選び、ジェネリック引数に依存させることもできます。ビルダーは分岐を合成された結果にエンコードできますが、不透明な結果型の宣言だけでは、互いに無関係な戻り値型を統一できません。不透明型とボックス化されたプロトコル型を参照してください。
プロバイダー自体を型消去するのはどんなときか?
下流インターフェースに単一のプロバイダー型を渡す必要がある場合や、格納前に呼び出し側でプロバイダーを正規化したい場合は、具体的なラッパーを選びます。次の宣言を追加すれば、サンプルの既存のプロトコルとプロバイダーで使えます。プロバイダーを保持し、makeView() が呼び出されたときにそのビューを作成します。
@MainActor
struct AnyViewProvider: ViewProvider {
let id: String
private let build: @MainActor () -> AnyView
init<P: ViewProvider>(_ provider: P) {
id = provider.id
build = { AnyView(provider.makeView()) }
}
func makeView() -> AnyView {
build()
}
}
この方法を使うには、完全なプログラムにある二つの格納用 providers プロパティを、どちらも [AnyViewProvider] に変更します。アプリの初期配列を次の式に置き換えてください。
[
AnyViewProvider(
TitleProvider(id: "title", title: "Component gallery")
),
AnyViewProvider(SwatchProvider(id: "swatch"))
]
次に、ギャラリーの ForEach クロージャ内にある erasedView(for: provider) を provider.makeView() に置き換えます。ラッパーがすでに AnyView を返すので、この呼び出しで追加の変換は不要です。この実装は識別子をコピーし、渡されたプロバイダー値をキャプチャします。キャプチャした値を後から置き換える仕組みではありません。
すべての利用側で型消去された出力が必要なら、よりシンプルなプロトコルで makeView() -> AnyView を直接要求できます。これによりアダプターコードは減りますが、型消去の責任が各準拠型に移ります。型消去された出力を意図的なインターフェース上の約束とする場合に選んでください。ジェネリックな利用側が各プロバイダーの出力との関係を利用できるようにするなら、関連型のコントラクトを選んでください。
コンパイラの制約とビューの同一性を保つ
言語機能とビルダーの動作は分けて考えてください。このサンプルは、Swift 5.7 の存在型機能と明示的な AnyView の結果に依存します。Apple の現行ドキュメントでは、Xcode 26 以前と Xcode 27 以降を区別しています。後者は型に依存しないビルダーコンテンツを構築し、その動作には ContentBuilder を推奨しています。条件付き準拠による型安全性についても、引き続き説明されています。ビルダーへの入力チェックが緩和されたからといって、存在型自体があらゆるジェネリックな View 制約を満たすと一般化しないでください。ViewBuilderとContentBuilderを参照してください。
コンポーネントの候補が固定されている場合は、コンポーネントデータを持つケースを列挙型に定義し、それらのケースを切り替えるレンダリングプロパティを検討してください。これにより、サポート対象の候補が明示されます。新しいコンポーネントの追加時に中央のレンダラーを更新する必要がある設計では有用です。準拠する実装がそれぞれ独自のレンダリングを提供すべきインターフェースには、プロバイダー方式が適しています。
型消去には、ドキュメントに記載された同一性への影響もあります。Apple によると、AnyView でラップされた型が変わると、以前の階層が破棄され、新しい階層が作成されます。コレクションの安定した識別子によって、この動作が変わることはありません。コンポーネントが異なるビュー型の間で切り替わる場合は、実際のアプリで状態と遷移の挙動を確認してください。AnyViewを参照してください。
このギャラリーでは、関連型を使うプロバイダーのコントラクトを保ち、混在する値を存在型として格納し、一つのレンダリングヘルパーから AnyView を返します。下流インターフェースで統一された型が必要になったら、型消去を具体的なプロバイダーラッパーに移します。これにより、コレクション、レンダリングコントラクト、コンパイラ設定を変更するときに確認すべき境界が明確になります。
参考資料
- 不透明型とボックス化されたプロトコル型 — The Swift Programming Language
- 不透明な結果型 — Swift Evolution
- すべてのプロトコルで存在型を使えるようにする — Swift Evolution
- 暗黙的にオープンされる存在型 — Swift Evolution
- View — Apple Developer Documentation
- AnyView — Apple Developer Documentation
- init(_:) — Apple Developer Documentation
- ForEach — Apple Developer Documentation
- ViewBuilder — Apple Developer Documentation
- ContentBuilder — Apple Developer Documentation