SwiftUIを構成するもの
ジャンル: フレームワーク解説。本記事では、SwiftUIが基盤としている仕組み、すなわちresult builder、不透明な戻り型、そして値型のviewツリーについて解説します。この基盤が見えるようになれば、開発者を驚かせるSwiftUIの諸要素(AnyView、Group、ViewBuilder、@ViewBuilderパラメータ、そしてあの恐ろしい some View vs any View エラー)はもう謎ではなくなります。
SwiftUIのviewは、たった1つの要件を持つ単一のプロトコルに準拠する値型です。フレームワークの残りの部分は、SwiftUIの外に存在するSwift言語機能、つまりresult builder、不透明型、制約付きジェネリクス、property wrapperの上に構築されています。これらの言語機能を理解していれば、フレームワークは普通のSwift API のように読めます。理解していなければ、フレームワークは時折噛みついてくる魔法のように見えるでしょう。
本記事では、その基盤を辿っていきます。ここには LiveActivityManager も登場しなければ、Get Bananasのスクリーンショットも出てきません。要点はプロジェクトではなくフレームワークそのものです。フレームワークが読めるようになれば、クラスター内の出荷コード系の記事すべてがよりクリアに読めるようになります。
TL;DR
- SwiftUIのviewは
Viewに準拠するSwiftの値型です。プロトコルの要件はただ1つ、var body: some View { get }です。それ以外はすべてSwift言語機能の上に構築されています。 @ViewBuilderはresult builderです。すべてのViewのbodyはこれによって構成されます。result builderは、コンマ区切りの式群を、コンパイラが合成する呼び出しを通じて単一の戻り値に変換します。some Viewは不透明な戻り型です。コンパイラは具体型を知っていますが、呼び出し側は知りません。この不透明型こそが、viewのbodyをコンパイル時にも実行時にも高速にしているのです。AnyViewは不透明性が機能しない場合の型消去の脱出口です。Group、EmptyView、TupleView、_ConditionalContentはresult builderが合成する実装型です。ドキュメント化されてはいますが、手書きすることはほとんどありません。
すべての始まりとなるプロトコル
View プロトコルの要件はただ1つです。1
public protocol View {
associatedtype Body : View
@ViewBuilder var body: Self.Body { get }
}
このプロトコルのうち、SwiftUIの残りを理解する上で重要な部分が2つあります。
関連型 Body : View。 viewのbodyはそれ自体がviewです。この再帰こそがフレームワークをコンポーザブルにしています。すべての View はそのbodyから別の View を返し、それが繰り返されていき、最終的にはフレームワークの プリミティブview(Text、Color、Image、EmptyView など、Body が Never であるもの)のいずれかで連鎖が終わります。プリミティブviewはツリーの葉であり、開発者が書くviewは枝にあたります。
body に付与された @ViewBuilder 属性。 すべてのbodyはresult builderクロージャです。result builderはSE-0289で文書化されたSwift言語機能で(Swift 5.4で @resultBuilder として正式化)、一連の式を持つクロージャを、合成されたメソッド呼び出しを通じて単一の戻り値にコンパイラが変換することを可能にします。2 この変換こそが、SwiftUIのbody内部のコンマなしで文のような構文を成立させているのです。
このプロトコルの形は、2つの理由で珍しいものとなっています。
第1に、要件はメソッドではなくcomputed propertyです。viewのbodyは、SwiftUIがそのviewの状態が変化したと判断するたびに、毎回のレンダーパスで再計算されます。フレームワークは body の呼び出しを安価なものとして扱います。body 内部での重い計算がアンチパターンなのは、それが毎回のレンダーで実行されるからです。
第2に、Self.Body は消去されておらず関連型として保持されます。viewの具体的なbodyの型は、コンパイル時のシグネチャの一部となります。Text("Hello") のbody型は Never ですが、カスタムviewのbody型は @ViewBuilder がそのbodyのために合成したものになります。この関連型による設計こそが、コンパイラに実行時の型チェックなしでviewツリーを最適化させているのです。同時に、これがカスタムviewが条件付きコンテンツを返すときに some View を要求する理由にもなっています。
Result Builder:コンマなしのDSL
result builderはSwift言語機能の1つで、コンパイラが合成するメソッド呼び出しを挿入することで、クロージャを単一の戻り値に変換します。@ViewBuilder はresult builderです。すべてのSwiftUI viewのbodyは、そのクロージャとして機能します。2
次のviewを見てみましょう。
struct ExampleView: View {
var body: some View {
Text("Title")
Text("Subtitle")
Image(systemName: "star")
}
}
このbodyには区切り文字なしの3つの文があります。通常のSwiftであれば、これはコンパイルエラーです。クロージャは1つの値しか返せないからです。result builderはコンパイル前にクロージャを書き換えます。@ViewBuilder の展開後にコンパイラが実際に見るコードは、おおよそ次のようになります。
struct ExampleView: View {
var body: some View {
ViewBuilder.buildBlock(
Text("Title"),
Text("Subtitle"),
Image(systemName: "star")
)
}
}
ViewBuilder.buildBlock(_:_:_:) は3つのviewを受け取って TupleView<(Text, Text, Image)> を返すstaticメソッドです。bodyはこの単一のtuple-view値を返します。古いSwiftUIでは1個から10個までの子要素に対応する buildBlock の固定オーバーロードを出荷していました。現在のSwiftUIではSwiftの可変長ジェネリクスサポート(buildBlock<each Content>)を使っているため、11個以上の兄弟viewを持つbodyももはや特殊ケースではありません。
同じパターンは制御フローも処理します。if 文を含むview bodyは次のようになります。
struct ConditionalView: View {
let isActive: Bool
var body: some View {
if isActive {
Text("Active")
} else {
Text("Inactive")
}
}
}
@ViewBuilder はこれを buildEither(first:) / buildEither(second:) の呼び出しを通じて書き換え、_ConditionalContent<Text, Text> を生成します。コンパイル時に結果型がわかっているので、ある時点のレンダーで実行されるのは片方の分岐だけであっても問題ありません。
if let、switch、optionalのアンラップ、その他いくつかの構文は、result builderの様々な buildXxx staticメソッドによって処理されます。3 繰り返しコンテンツについては、言語機能としては buildArray でサポートされていますが @ViewBuilder では非対応という、注目すべき例外があります。body 内部で生の for ループを使うと、“closure containing control flow statement cannot be used with result builder ‘ViewBuilder’.” というエラーで失敗します。SwiftUI流の答えは ForEach で、これは RandomAccessCollection とコンテンツクロージャを受け取り、繰り返しを単一の値型viewとして合成します。このDSLは独自実装ではなく、view向けに構成されたSwiftのresult builderそのものなのです。
some View と不透明性の問題
カスタムviewのbodyは通常 some View を返します。このキーワードは 不透明な戻り型(opaque return type) と呼ばれ、Swift 5.1で追加されました。4
some View は次のことを意味します。「私は View に準拠する特定の型を返しますが、それがどの型かはお伝えしません」。コンパイラは最適化のために具体型を内部で追跡しますが、viewの呼び出し側にはプロトコルのwitnessしか見えません。このパターンこそが、VStack<TupleView<(Text, Image, Spacer)>> のような複雑な型をソースコードに書き出すことなく、viewのbodyから返せるようにしているのです。
新しいSwiftUI開発者を混乱させる some View の特徴が2つあります。
some View は異なるものを返しているように見えても、1つの特定の型である。 if condition { Text("A") } else { Image("b") } という式が @ViewBuilder のbody内部で許容されるのは、result builderが両方の分岐を _ConditionalContent でラップして単一の具体型を生成するからです。しかし、result builderの外で if condition { return Text("A") } else { return Image("b") } と書くとコンパイルエラーになります。2つの分岐は異なる具体型を返しており、some View は1つの型を要求するからです。result builderこそが条件付きの戻り形を機能させているのです。明示的な return を使うと、result builderの変換が失われます。
some View は any View と同じではない。 some View は不透明(1つの特定の型を隠蔽)であり、any View は存在型(実行時オーバーヘッドを伴って、準拠する任意の型を保持できる箱)です。フリー関数やプロパティであれば any View を返すことは合法 です。しかし View プロトコルの body は不可能です。プロトコルが associatedtype Body: View を要求しており、any View 自体は View に準拠していないため、var body: any View ではプロトコルを満たせず、コンパイラは some View を提案してきます。実用的なルール:viewのbodyには some View を使い、実行時に型が変わるviewには AnyView(型消去ラッパー)に手を伸ばすこと。エラーメッセージ “function declares an opaque return type but the return statements in its body do not have matching underlying types” は、ほぼ必ず some View 関数から異なる具体型を返そうとしたことを意味し、result builderによる分岐か AnyView のどちらかが必要です。
AnyView:脱出口
AnyView は型消去されたviewラッパーです。構築は AnyView(myView) で行います。このラッパーは準拠する任意のviewを保持し、SwiftUIは View が期待される場所でこれを受け入れます。5
脱出口としての使用例は、実行時データに基づいて異なる具体型を返す関数で、result builderによる分岐では表現できないケースです。
func viewForKind(_ kind: Kind) -> AnyView {
switch kind {
case .text: return AnyView(Text("hello"))
case .image: return AnyView(Image("photo"))
case .custom: return AnyView(MyCustomView())
}
}
AnyView のコストは、その下にある型がviewの静的アイデンティティの一部にならないことです。Appleのドキュメントはこの帰結を直接的に説明しています。AnyView の中にラップされた型がレンダー間で変化すると、既存のview階層は破棄され、その場所に新しい階層が作成されます。これはつまり、状態の喪失、アニメーションの再開、アイデンティティの喪失を意味します。同じ具体型で再ラップするだけならこの破棄は発生しませんが、フレームワークが好む静的型ベースの差分検出は、いずれにせよもう使えません。
正しい原則はこうです:条件付きviewには @ViewBuilder のresult builder分岐(if、switch、for)を優先し、型が変わる場合はパラメータ化されたviewを優先し、どちらも機能しないときだけ AnyView に手を伸ばすこと。AnyView を返す func viewForKind は、たいていの場合 viewForKind を some View を返すようにし、result builderクロージャの中に switch を入れるべきだというサインです。
Group、EmptyView、TupleView:実装型
result builderは特定の具体的なview型を合成します。そのうち認識しておくと有用なものが3つあります。6
Group は透明なコンテナです。最大10個のviewをコンテンツとして受け入れ、それらを親レイアウトに対して兄弟として提示します。コンテナ自体は視覚的構造を一切追加しません。コンテンツは個別に置いた場合とまったく同じようにレンダリングされます。用途は、単一のviewを期待するコンテキスト(.if モディファイア、条件付きreturn、「1つのview」を生成する関数)の中で複数のviewをラップすることです。Group { Text("A"); Text("B") } は2つのviewを含む単一のviewであり、result builderが暗黙的に行うことの明示的な形です。
EmptyView は何もレンダリングしないviewです。result builderは、if に else がない場合の条件false分岐としてこれを使用します。自分のコードから EmptyView() を返すのは、関数の戻り型を変えずにレンダリングをオプトアウトする方法です。
TupleView は、bodyが複数の兄弟viewを持つときにresult builderが生成する具体的な型です。本記事冒頭で3つの兄弟viewを返している式は、実際には TupleView<(Text, Text, Image)> を返しています。TupleView を直接書くことはほぼなく、エラーメッセージの中で読むことになります。
_ConditionalContent(先頭にアンダースコア付き)は if/else 分岐を扱う型です。この型は ViewBuilder の公開サーフェスに現れますが、アンダースコア付きの名前は「これに対して気軽にコードを書いてはいけない」というサインです。手で構築するのではなく、if/else からresult builderに合成させましょう。
自分の関数に @ViewBuilder を使う
result builderは body のためだけのものではありません。任意の関数やクロージャパラメータに @ViewBuilder を付与でき、その内部で同じDSL構文が合法になります。2
struct Card<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
content
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// Usage: callers get the result-builder DSL inside the closure.
Card {
Text("Title")
Text("Subtitle")
Image(systemName: "star")
}
このパターンは、SwiftUI自身の VStack、HStack、ZStack、List、Form、Section、Group、NavigationStack が複数の子要素を受け入れる仕組みです。これらの型はすべて @ViewBuilder content: () -> Content パラメータを取っています。このパターンを認識すれば、特別なコンパイラサポートなしに、フレームワークと同じエルゴノミクスで自前のコンテナviewを書けるようになります。
init(content:) ではなく init(@ViewBuilder content:) と書く理由は、パラメータ上の属性こそが、呼び出し側が渡すクロージャbody内部でのresult builder変換を有効化するからです。属性がなければ、Card { Text("A"); Text("B") } はコンパイルエラーになります。クロージャに2つの文があり、それらを変換する @ViewBuilder がないからです。
State、Binding、そしてProperty Wrapperの層
ここまでの内容はすべてviewツリーの 形 に関するものでした。SwiftUIのもう半分は state で、こちらはSwiftのproperty wrapperの上に構築されています。7
view作成において最も関連性の高いproperty wrapperは次のとおりです。
@State は単一のview内部で値型のstateを所有します。プロパティの読み取りは下にある記憶領域を読み、それへの代入はviewの再レンダーをトリガします。このwrapperは、シンプルでview内ローカルなstate(トグルのオン/オフ、テキストフィールドの下書き文字列)に適しています。
@Binding は別のviewのstateへの双方向参照です。親のstateを読み書きする必要のある子viewは Binding<T> パラメータを取ります。親は $state(@State 上のドル記号プロジェクション)を通してbindingを構築します。
@Observable(iOS 17+)は、古い ObservableObject 準拠パターンを置き換えるマクロです。クラスに適用されたこのマクロは、Observation frameworkのトラッキングを生成し、クラスのプロパティがbody内部で読み取られた後に変更されたとき、viewの再レンダーをトリガします。@Observable クラスのview側での所有は、@StateObject から普通の @State に移ります。双方向ハンドルが必要な下流のviewは、@ObservedObject の代わりに @Bindable を使います。
@Environment は環境チェーンから依存性注入された値を読み取ります。SwiftUIには組み込みのenvironmentキー(locale、color scheme、dismissアクション)があり、アプリはドメイン固有の依存性注入のためにカスタムキーを追加します。
property wrapperの層こそが、stateが変化したときにviewの body を再実行させるものです。SwiftUIは2つの異なる仕組みを通してbody内部の読み取りを追跡しています。古いproperty wrapperの経路には AttributeGraph(Apple非公開の依存グラフで、@State、@Binding、@Environment を支えています)、@Observable 型には標準ライブラリのObservation framework(withObservationTracking、iOS 17+で公開)です。8 追跡対象のプロパティが変更されると、対応するbodyが再実行され、差分検出機構が最小のviewツリー変更を計算します。
この2つの半分(viewツリー層とstate層)は緩く結合しています。viewツリーは値型で再計算が高速です。state層は参照型(@Observable の場合)か、storage pointer付き値型(@State の場合)であり、読み取りを追跡します。両者が組み合わさって、フレームワークの「stateの関数として画面に表示すべきものを記述すれば、フレームワークが差分を計算する」というモデルが生み出されています。
エラーメッセージで認識できるようになるもの
基盤が見えるようになると、SwiftUIのコンパイルエラーは次のように読めます。
“Function declares an opaque return type, but the return statements in its body do not have matching underlying types.” some View 関数内に異なる具体型を返す2つのreturn文があります。修正方法:result builderが両方を _ConditionalContent でラップするように @ViewBuilder を使うか、両方のreturnを AnyView でラップしましょう。
“The compiler is unable to type-check this expression in reasonable time.” 多くのモディファイアを持つ長いbodyチェーンが型チェッカーを枯渇させています。修正方法:bodyを小さなcomputed propertyやサブviewに分割しましょう。それぞれの some View を返すピースが推論作業を簡素化します。
“Cannot convert value of type ‘TupleView<…>’ to expected type ‘some View’.” 1つのviewを期待する関数が、@ViewBuilder なしの複数文bodyの結果を受け取りました。修正方法:複数文コンテンツを受け取るクロージャパラメータに @ViewBuilder を追加しましょう。
“Generic parameter ‘Content’ could not be inferred.” カスタムコンテナが @ViewBuilder content: () -> Content を取っていて、呼び出し側で空のクロージャが渡されました。修正方法:result builderは Content を推論するために少なくとも1つの式を必要とします。空のクロージャの場合、呼び出し側で明示的に EmptyView() を提供すればフォールバックします。
エラーメッセージが不親切なのは、基盤が見えないからです。基盤を見えるようにして読めば、そのほとんどは「なるほど、result builderはこれを変換できないのか」または「なるほど、分岐か AnyView のどちらかが必要なのか」に変わります。
基盤の外に手を伸ばすべきとき
基盤がうまく扱えないパターンがいくつかあります。
可変長の具体型。 分岐ごとに異なる View 型を返す関数で、result builderによる分岐ではラップできない場合は、AnyView が必要です。コスト(差分検出の喪失、アニメーションなし)を受け入れ、呼び出し側にコメントを残しましょう。
クロスプラットフォームの条件付きview。 コンパイル時の #if os(iOS) は @ViewBuilder のbody内部で動作しますが、result builderの分岐数を制限します。マルチOSの条件付きbodyは時に「expression too complex」の制限に達します。修正方法は、プラットフォーム別のサブviewをそれぞれ別関数に抽出し、各関数が some View を返すようにすることです。
命令的なview構築。 フレームワークはviewを式として扱うことを期待しており、構築してから変更するオブジェクトとしては扱いません。UIKitスタイルの「ラベルを作成し、テキストを設定し、subviewに追加する」というやり方は通用しません。SwiftUIの等価物は、bodyから返される値型の Text("...") です。命令的な構築を必要とするパターンは、たいていの場合、UIViewRepresentable ブリッジでUIKitに任せるべき仕事だというサインです。
iOS 26+で出荷するアプリにとってこのパターンが意味するもの
要点は3つです。
-
SwiftUIはSwiftであり、魔法ではない。 result builder、不透明な戻り型、property wrapperはすべてSwift言語リファレンスに記載されています。フレームワークを特別なDSLとしてではなく、Swiftのコードとして読めば、驚かされる部分も予測可能なものに変わります。
-
some ViewとAnyViewは異なる問題を解決する。 不透明な戻り型がデフォルトで、型消去は脱出口です。AnyViewに手を伸ばすのは稀なケースであるべきで、some Viewプラスresult builderによる分岐に手を伸ばすのが普通であるべきです。 -
result builderがDSL全体である。 関数やパラメータが
@ViewBuilderであるところでは、コンマなしで文のような構文が利用可能になります。VStackと同じエルゴノミクスで自前のコンテナviewを書くには、属性1つとクロージャパラメータ1つあれば十分です。
本記事は、クラスター内の出荷コード系シリーズと併せて読んでください。クロスプラットフォームのSwiftUI出荷(Returnは1つの共有SwiftUIコアで5つのプラットフォーム上で動作)、Liquid Glassビジュアル層、iOS上のLive Activitiesステートマシン、Apple Watch上のwatchOSランタイム契約。ハブはApple Ecosystem Seriesにあります。より広範なAIエージェントを伴うiOSの文脈については、iOS Agent Developmentガイドを参照してください。
FAQ
SwiftUIのViewプロトコルとは何ですか?
View プロトコルにはたった1つの要件があります:var body: some View { get } です。すべてのSwiftUI viewは View に準拠するSwiftの値型で、別のview(あるいは Text、Color、Image、EmptyView のようなプリミティブviewの場合は Never)を返す body computed propertyを持ちます。bodyには @ViewBuilder が付与されているため、SwiftUIのコンマなしDSL構文を使えます。
some View とは何を意味しますか?
some View は不透明な戻り型(Swift 5.1+)です。コンパイラは具体型を知っていますが、呼び出し側にはプロトコルのwitnessしか見えません。不透明型を使うことで、VStack<TupleView<(Text, Image, Spacer)>> のような複雑な型を綴り出すことなくview bodyから返せるようになり、なおかつコンパイル時の最適化を維持できます。some View は呼び出し側からは見えなくても、1つの特定の型 なのです。
AnyViewはいつ使うべきですか?
AnyView を使うべきなのは、@ViewBuilder のresult builder分岐(if、switch、for)でも、パラメータ化されたジェネリクスでも問題が解決しない場合だけです。ラップされた具体型がレンダー間で変化すると、既存のview階層は破棄され、新しいものがその場所に作成されます。これがアニメーションが再開し、viewのstateがリセットされる瞬間です。同じ具体型で再ラップするだけならこの破棄は発生しませんが、フレームワークが好む静的型ベースの差分検出は、いずれにせよもう使えません。AnyView に頻繁に手を伸ばしているのに気づいたら、変更すべきパターンは上流にあります。パラメータ化されたviewを使うか、条件分岐をresult builderのbodyに押し込みましょう。
@ViewBuilder とは何で、どこで使えますか?
@ViewBuilder はresult builder(Swift言語機能)です。複数の式を持つクロージャを、コンパイラが合成する buildBlock、buildEither、buildOptional などの呼び出しを挿入することで、単一の戻り値に変換します。すべてのSwiftUI viewのbodyはデフォルトで @ViewBuilder です。任意の関数やクロージャパラメータに @ViewBuilder を適用すれば、呼び出し側に同じDSL構文を提供できます。VStack、Card、Section はすべて、複数の子要素を受け入れるためにこの同じパターンを使っています。
想定していないのにview bodyが再レンダーされるのはなぜですか?
SwiftUIは、bodyが読み取るstateプロパティが変更されるたびに body を再実行します。property wrapper(@State、@Binding、@Observable、@Environment)は読み取りを追跡し、書き込み時に再レンダーをトリガします。想定外の再レンダーは、たいていの場合、親viewのstateの変化、environment値の変化、または @Observable オブジェクトの読み取り済みプロパティの変更に辿り着きます。フレームワークの差分検出はそこから最小のツリー変更を計算します。
参考文献
-
Apple Developer, “View” and “Configuring views”. The
Viewprotocol,Bodyassociated type, and the@ViewBuilderattribute onbody. ↩ -
Swift Evolution, “SE-0289: Result builders”. The language proposal that formalized result builders (introduced as
_functionBuilderin 5.1, formalized as@resultBuilderin 5.4). DefinesbuildBlock,buildEither,buildOptional,buildArray,buildExpression,buildFinalResult, and friends. ↩↩↩ -
Apple Developer, “ViewBuilder” and “ForEach”. The result-builder type SwiftUI uses for view bodies (variadic-generic
buildBlock,buildEither, optional unwrapping).ViewBuilderdoes not exposebuildArray, soForEachis the iteration primitive for repeating a view over a collection. ↩ -
Swift Evolution, “SE-0244: Opaque result types”. The
somekeyword for opaque return types, added in Swift 5.1. ↩ -
Apple Developer, “AnyView”. Type-erased view wrapper, construction, and the diffing trade-off. ↩
-
Apple Developer, “Group”, “EmptyView”, and “TupleView”. Implementation types that result builders synthesize. ↩
-
Apple Developer, “State and Data Flow”. The property-wrapper layer:
@State,@Binding,@Observable,@Environment. SwiftUI’s observation system and the iOS 17+@Observablemacro. ↩ -
Apple Developer, “Observation” and “Migrating from the Observable Object protocol to the Observable macro”. The standard-library Observation framework, including
withObservationTracking(_:onChange:), plus the iOS 17 migration path fromObservableObjectto@Observable. ↩