← すべての記事

SwiftUIを構成するもの

SwiftUIは、Swift言語の3つの機能の上に成り立っています。result builders、opaque return types、そして値型のviewツリーです。基盤が見えれば、開発者を驚かせるSwiftUIの部分(AnyViewGroupViewBuilder@ViewBuilderパラメータ、悪名高いsome View vs any Viewエラー)は、もはや謎ではなくなります。

SwiftUIのviewは、ただ1つの要件を持つ単一のプロトコルに準拠する値型です。フレームワークの残りの部分は、SwiftUIの外に存在するSwift言語の機能、すなわちresult builders、opaque types、制約付きジェネリクス、property wrappersの上に構築されています。これらの言語機能を理解していれば、フレームワークは普通のSwift APIのように読めます。理解していなければ、フレームワークは時々噛みついてくる魔法のように見えるでしょう。

この記事では、その基盤を順を追って見ていきます。ここにLiveActivityManagerは登場しませんし、Get Bananasのスクリーンショットもありません。ポイントはプロジェクトではなくフレームワークそのものです。フレームワークが読めるようになれば、クラスター内のすべての実装コード解説記事もより明快に読めるようになります。

TL;DR

  • SwiftUIのviewは、Viewに準拠するSwiftの値型です。プロトコルの要件はただ1つ、var body: some View { get }です。それ以外はすべてSwift言語機能の上に構築されています。
  • @ViewBuilderはresult builderです。すべてのViewのbodyは1つのresult builderです。Result buildersは、コンマで区切られた式を、コンパイラが合成する呼び出しを通じて単一の戻り値に変換します。
  • some Viewはopaque return typeです。コンパイラは具体的な型を知っていますが、呼び出し側は知りません。Opaque typeこそが、view bodyをコンパイル時・実行時に高速にする要因です。AnyViewは、不透明性が機能しない場合のための型消去された脱出口です。
  • GroupEmptyViewTupleView_ConditionalContentは、result buildersが合成する実装型です。ドキュメント化されてはいますが、手書きで書かれることはほとんどありません。

すべてが始まるプロトコル

Viewプロトコルの要件はただ1つです。1

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}

このプロトコルの2つの部分が、SwiftUIの残りを理解する上で重要となります。

関連型Body : View viewのbodyは、それ自体がviewです。この再帰こそが、フレームワークを構成可能(composable)にしているのです。すべてのViewはそのbodyから別のViewを返し、それを繰り返して、最終的にチェーンはフレームワークのプリミティブviewTextColorImageEmptyViewなど、そのBodyNeverであるもの)の1つで終端します。プリミティブviewはツリーの葉であり、あなたが書くviewは枝となります。

bodyに付与された@ViewBuilder属性。 すべてのbodyはresult-builderクロージャです。Result buildersはSwift言語機能で、SE-0289(Swift 5.4で@resultBuilderとして正式化)に文書化されており、一連の式を持つクロージャを、合成されたメソッド呼び出しを通じてコンパイラが単一の戻り値に変換できるようにするものです。2 この変換こそが、SwiftUIのbody内で動作する、コンマなしのステートメント形状の構文を可能にしているのです。

このプロトコルの形状は、2つの理由で異例です。

第一に、要件はメソッドではなくcomputed propertyです。viewのbodyは、SwiftUIがviewのstateが変更されたと判断するたびに、毎回のレンダーパスで再計算されます。フレームワークはbodyを呼び出しコストの低いものとして扱います。body内での長時間の計算がアンチパターンであるのは、レンダーごとに実行されるからです。

第二に、Self.Bodyは関連型であり、消去されてはいません。viewの具体的なbody型は、コンパイル時にそのシグネチャの一部となります。Text("Hello")のbody型はNeverであり、カスタムviewのbody型は@ViewBuilderがbody用に合成したものとなります。この関連型による設計こそが、コンパイラがランタイムの型チェックなしにviewツリーを最適化できる理由です。また、これがカスタムviewが条件付きコンテンツを返す際にsome View要件を生み出す理由でもあります。

Result Builders:コンマなしのDSL

Result builderはSwift言語機能で、コンパイラが合成するメソッド呼び出しを挿入することで、クロージャを単一の戻り値に変換します。@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 buildersは、コンパイル前にクロージャを書き換えます。@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)>を返す静的メソッドです。bodyはその単一のtuple-view値を返します。古いSwiftUIは1、2、3、…10個までの子要素に対する固定されたbuildBlockオーバーロードのセットを提供していましたが、現在のSwiftUIはSwiftのvariadic-genericsサポート(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")
        }
    }
}

@ViewBuilderbuildEither(first:) / buildEither(second:)呼び出しを通じてこれを書き換え、_ConditionalContent<Text, Text>を生成します。任意のレンダー時に1つの分岐しか実行されなくても、コンパイラはコンパイル時に結果の型を把握しています。

if letswitch、optional unwrapping、その他いくつかの構文は、result builderの様々なbuildXxx静的メソッドによって処理されます。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 buildersなのです。

some Viewと不透明性の問題

カスタムviewのbodyは通常some Viewを返します。このキーワードはopaque return typeと呼ばれ、Swift 5.1で追加されました。4

some Viewは次のように言っています。「私はViewに準拠する特定の型を返しますが、それがどれかは教えません」。コンパイラは最適化のために内部的に具体的な型を追跡し、viewの呼び出し側はプロトコル witnessしか見ません。このパターンによって、viewのbodyはVStack<TupleView<(Text, Image, Spacer)>>のような複雑な型を、ソースコードにその型を書く必要なく返せるのです。

some Viewについて新しいSwiftUI開発者を混乱させる2つのこと。

some Viewは、異なるものを返す場合でも1つの特定の型です。 if condition { Text("A") } else { Image("b") }という式が@ViewBuilder body内で許可されているのは、result builderが両方の分岐を_ConditionalContentでラップし、単一の具体的な型を生成するからです。しかし、if condition { return Text("A") } else { return Image("b") }という式をresult builderの外で使うとコンパイルエラーになります。2つの分岐は異なる具体的な型を返すのに、some Viewは1つしか要求しないからです。Result buildersこそが条件付きの戻り値の形を機能させるものであり、明示的なreturnはresult-builder変換を失わせます。

some Viewany Viewと同じではありません。 some Viewは不透明(1つの特定の型、隠されている)であり、any Viewはexistential(任意の準拠型を保持できる箱、ランタイムオーバーヘッドあり)です。フリー関数やpropertyは合法的に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階層は破棄され、新しい階層がその代わりに作成されます。これは、stateの喪失、アニメーションの再開、アイデンティティの喪失を意味します。同じ具体的な型を再ラップしてもその破棄は引き起こされませんが、フレームワークが好む静的型駆動の差分計算もどちらにせよ利用できなくなります。

正しいルールはこうです。条件付きview(ifswitchfor)には@ViewBuilder result-builderの分岐を優先し、可変型にはパラメータ化されたviewを優先し、どちらも機能しない場合にのみAnyViewに手を伸ばします。AnyViewを返すfunc viewForKindは通常、viewForKindsome Viewを返すようにし、result-builderクロージャ内にswitchを入れるべきだというサインです。

GroupEmptyViewTupleView:実装型

Result builderは特定の具体的なview型を合成します。そのうち3つを認識しておくと役立ちます。6

Groupは透明なコンテナです。最大10個のviewをコンテンツとして受け入れ、それらを親レイアウトに対する兄弟として提示します。コンテナ自体は視覚的な構造を加えません。コンテンツは個別にレンダーされるのと正確に同じようにレンダーされます。ユースケースは、単一のviewを期待するコンテキスト(.if修飾子、条件付きreturn、「1つのview」を生成する関数)で複数のviewをラップすることです。Group { Text("A"); Text("B") }は2つを含む単一のviewであり、result buildersが暗黙的に行うことを明示的に表現したものとなります。

EmptyViewは何もレンダーしないviewです。Result builderは、ifelseがない場合の条件付きfalse分岐としてこれを使用します。自身のコードからEmptyView()を返すことは、関数の戻り値の型を変えることなくレンダリングをオプトアウトする方法となります。

TupleViewは、bodyに複数の兄弟viewがある場合にresult buildersが生成する具体的な型です。この記事の冒頭で3つの兄弟viewを返す式は、実際にはTupleView<(Text, Text, Image)>を返しています。TupleViewを直接書くことはほとんどありません。エラーメッセージの中で読むことになるでしょう。

_ConditionalContent(先頭にアンダースコア付き)は、if/else分岐を処理する型です。この型はViewBuilderの公開サーフェスに現れますが、アンダースコア付きの名前は「これに対して気軽にコードを書かないでください」という合図です。手作業で構築するのではなく、if/elseからresult builderに合成させましょう。

自分の関数に@ViewBuilderを付ける

Result buildersは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自身のVStackHStackZStackListFormSectionGroupNavigationStackが複数の子要素を受け入れる仕組みです。これらの型はすべて@ViewBuilder content: () -> Contentパラメータを取ります。このパターンを認識していれば、フレームワークと同じエルゴノミクスで、特別なコンパイラサポートなしに自分のコンテナviewを書けます。

init(content:)ではなくinit(@ViewBuilder content:)と書く理由は、パラメータ上の属性が、呼び出し側が渡すクロージャ body内でresult-builder変換を有効化するからです。属性がなければ、Card { Text("A"); Text("B") }はコンパイルエラーになります。クロージャに2つのステートメントがあるのに、それらを変換する@ViewBuilderがないからです。

State、Bindings、そしてProperty Wrapper層

ここまでの内容はすべて、viewツリーの形状に関するものです。SwiftUIのもう半分はstateであり、その半分はSwiftのproperty wrappersの上に構築されています。7

view作成に最も関連するproperty wrappersは次のとおりです。

@Stateは、単一のview内部で値型のstateの一部分を所有します。propertyを読むと基底のストレージから読み取り、代入するとviewの再レンダーをトリガーします。このwrapperはシンプルでview-localなstate(トグルのオン/オフ、テキストフィールドの下書き文字列など)に適しています。

@Bindingは、別のviewのstateへの双方向参照です。親のstateを読み書きする必要のある子viewは、Binding<T>パラメータを取ります。親は$state@Stateに対するドル記号プロジェクション)を通じてbindingを構築します。

@Observable(iOS 17+)は、古いObservableObject準拠パターンを置き換えるマクロです。クラスに適用されたこのマクロは、Observation framework追跡を生成し、クラスのpropertyがbody内で読み取られた後に変更されたときにviewの再レンダーをトリガーします。@Observableクラスのview側の所有権は@StateObjectから普通の@Stateへ移行し、双方向ハンドルを必要とする下流のviewは@ObservedObjectの代わりに@Bindableを使用します。

@Environmentは、environmentチェーンから依存性注入された値を読み取ります。SwiftUIは組み込みのenvironment key(locale、color scheme、dismiss action)を提供しており、アプリはドメイン固有の依存性注入のためにカスタムkeyを追加します。

Property wrapper層こそが、stateが変更されたときにviewのbodyを再実行可能にする仕組みです。SwiftUIは2つの異なるメカニズムを通じてbody内の読み取りを追跡します。古いproperty-wrapperパス向けのAttributeGraph(Appleの非公開の依存性グラフで、@State@Binding@Environmentを支えるもの)と、@Observable型向けの標準ライブラリのObservation framework(withObservationTracking、iOS 17+で公開)です。8 追跡対象のpropertyが変更されると、対応するbodyが再実行され、差分計算機構が最小限のviewツリー変更を計算します。

この2つの半分(viewツリー層とstate層)は緩く結合しています。viewツリーは値型で、再計算が高速です。state層は参照型(@Observableの場合)または値型ストレージポインタ付き(@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文が異なる具体的な型を持っています。修正方法は、@ViewBuilderを使用してresult builderに両方を_ConditionalContentでラップさせるか、両方の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 buildersが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("...")です。命令型構築を必要とするパターンは通常、その作業がUIKitへのUIViewRepresentableブリッジに属するというサインです。

このパターンがiOS 26+で出荷するアプリにとって意味するもの

3つのポイントがあります。

  1. SwiftUIはSwiftであり、魔法ではありません。 Result builders、opaque return types、property wrappersはすべてSwift言語リファレンスにあります。フレームワークを特殊なDSLとしてではなくSwiftコードとして読むことで、驚くような部分が予測可能になります。

  2. some ViewAnyViewは異なる問題を解決します。 Opaque return typesがデフォルトで、型消去は脱出口です。AnyViewに手を伸ばすのはまれなケースであるべきで、some Viewプラスresult-builderの分岐に手を伸ばすのが一般的なケースであるべきです。

  3. Result buildersがDSL全体です。 関数やパラメータが@ViewBuilderであるあらゆる場所で、コンマなしのステートメント形状の構文が利用可能です。VStackと同じエルゴノミクスで自分のコンテナviewを書くには、1つの属性と1つのクロージャパラメータがあれば十分なのです。

この記事をクラスターの実装コードシリーズと併せてお読みください。クロスプラットフォームSwiftUI shipping(Returnは1つの共有SwiftUIコアで5つのプラットフォーム上で動作)、Liquid Glassビジュアル層、iOS上のLive Activitiesステートマシン、Apple Watch上のwatchOS runtimeコントラクトです。ハブはApple Ecosystem Seriesにあります。AIエージェントを伴うiOSのより広いコンテキストについては、iOS Agent Development guideをご覧ください。

FAQ

SwiftUIのViewプロトコルとは何ですか?

Viewプロトコルの要件はただ1つ、var body: some View { get }です。すべてのSwiftUI viewはViewに準拠するSwift値型で、body computed propertyが別のview(またはTextColorImageEmptyViewのようなプリミティブviewではNever)を返します。bodyには@ViewBuilderが注釈されているため、SwiftUIのコンマなしDSL構文が使用できます。

some Viewは何を意味しますか?

some Viewはopaque return type(Swift 5.1+)です。コンパイラは具体的な型を知っていますが、呼び出し側はプロトコルwitnessしか見ません。Opaque typesによって、view bodyはVStack<TupleView<(Text, Image, Spacer)>>のような複雑な型を、それを書き出すことなく返せ、コンパイル時の最適化を維持できます。some Viewは呼び出し箇所からは見えなくても、1つの特定の型なのです。

AnyViewはいつ使うべきですか?

@ViewBuilder result-builderの分岐(ifswitchfor)もパラメータ化されたジェネリクスも問題を解決しないときにのみAnyViewを使用します。ラップされた具体的な型がレンダー間で変わると、既存のview階層は破棄され、新しいものがその代わりに作成されます。これがアニメーションが再開しviewのstateがリセットされる瞬間です。同じ具体的な型を再ラップしてもその破棄は引き起こされませんが、フレームワークが好む静的型駆動の差分計算もどちらにせよ利用できなくなります。AnyViewに頻繁に手を伸ばしているなら、変えるべきパターンはより上流にあります。パラメータ化されたviewを優先するか、条件分岐をresult-builder bodyに押し込みましょう。

@ViewBuilderとは何で、どこで使えますか?

@ViewBuilderはresult builder(Swift言語機能)です。複数の式を持つクロージャを、コンパイラが合成するbuildBlockbuildEitherbuildOptionalなどの呼び出しを挿入することで、単一の戻り値に変換します。すべてのSwiftUI viewのbodyはデフォルトで@ViewBuilderです。任意の関数やクロージャパラメータに@ViewBuilderを適用することで、呼び出し側に同じDSL構文を提供できます。VStackCardSectionは同じパターンを使用して複数の子要素を受け入れています。

想定外のときにviewのbodyが再レンダーされるのはなぜですか?

SwiftUIは、bodyが読み取るstate propertyが変更されるたびにbodyを再実行します。Property wrappers(@State@Binding@Observable@Environment)は読み取りを追跡し、書き込み時に再レンダーをトリガーします。想定外の再レンダーは通常、親viewのstateの変更、environment値の変更、または@Observableオブジェクトの読み取りpropertyの変更にたどり着きます。その後、フレームワークの差分計算が最小限のツリー変更を計算します。

References


  1. Apple Developer, “View” and “Configuring views”. The View protocol, Body associated type, and the @ViewBuilder attribute on body

  2. Swift Evolution, “SE-0289: Result builders”. The language proposal that formalized result builders (introduced as _functionBuilder in 5.1, formalized as @resultBuilder in 5.4). Defines buildBlock, buildEither, buildOptional, buildArray, buildExpression, buildFinalResult, and friends. 

  3. Apple Developer, “ViewBuilder” and “ForEach”. The result-builder type SwiftUI uses for view bodies (variadic-generic buildBlock, buildEither, optional unwrapping). ViewBuilder does not expose buildArray, so ForEach is the iteration primitive for repeating a view over a collection. 

  4. Swift Evolution, “SE-0244: Opaque result types”. The some keyword for opaque return types, added in Swift 5.1. 

  5. Apple Developer, “AnyView”. Type-erased view wrapper, construction, and the diffing trade-off. 

  6. Apple Developer, “Group”, “EmptyView”, and “TupleView”. Implementation types that result builders synthesize. 

  7. Apple Developer, “State and Data Flow”. The property-wrapper layer: @State, @Binding, @Observable, @Environment. SwiftUI’s observation system and the iOS 17+ @Observable macro. 

  8. 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 from ObservableObject to @Observable

関連記事

watchOSのランタイムは契約であり、バックグラウンドタスクではない

watchOSにはiOSのようなバックグラウンドはありません。WKExtendedRuntimeSessionが契約であり、これがなければ手首を下ろした瞬間にアプリは停止します。Returnで実装したパターンを紹介します。

2 分で読める

3つのサーフェス:人間、Apple Intelligence、エージェント

iOSアプリのすべての機能は、人間、Apple Intelligence、エージェントという3つのサーフェスに向き合っています。それぞれ異なる責務、レンダリング、レイテンシ、信頼姿勢を持ちます。

2 分で読める

クリーンアップレイヤーこそが本当のAIエージェント市場である

Charlie Labsはエージェント構築から、エージェントの後始末をする側へとピボットしました。AIエージェント市場は生成から証明へと移行しています。クリーンアップこそが永続的なレイヤーなのです。

2 分で読める