← すべての記事

SwiftUI Layout プロトコル:sizeThatFits から placeSubviews までカスタムレイアウトを構築する

iOS 16 では SwiftUI に Layout プロトコルが追加され、SwiftUI のレイアウトパスに参加するカスタムコンテナビューを構築するための公開 API となりました1Layout 登場以前は、カスタムコンテナの形状を実現するには GeometryReader のハック(提案された全サイズを要求してしまうため、コンポジションが壊れてしまいます)か、システムと戦うカスタム ViewModifier の作業が必要でした。Layout は正攻法です。2 つのメソッド(sizeThatFitsplaceSubviews)に加え、オプショナルな spacing とキャッシュ拡張を持つプロトコルであり、SwiftUI の「親が提案し子が処理する」レイアウトモデルにきれいに統合される契約を備えています。

本稿では Apple のドキュメントを踏まえてプロトコルを順に見ていきます。フレームは「Layout が実際に契約しているもの」です。なぜなら、誤用パターン(Layout をサイズ交渉のツールではなく座標空間ツールとして扱うこと)は、ある画面では動作するが別の画面では破綻するレイアウトを生み出してしまうからです。本クラスターのWhat SwiftUI Is Made Of では、SwiftUI のアーキテクチャを理解する最良の方法は公開プロトコルを読むことだと論じました。

TL;DR

  • Layout は 2 つの必須メソッドを持つプロトコルです。sizeThatFits(proposal:subviews:cache:) は親の proposal を受けてレイアウトの希望サイズを返します。placeSubviews(in:proposal:subviews:cache:) は各子ビューの place(at:anchor:proposal:) メソッドを呼び出して位置を決定します2
  • proposal パラメータは ProposedViewSize 型で、widthheight をオプショナルな CGFloat として持ちます。nil は「ideal サイズを使用してください」、有限値は親からのオファー、.infinity は「好きなだけ使ってください」を意味します。
  • SubviewsLayoutSubviews の typealias であり、LayoutSubview プロキシのコレクションです。各プロキシに対して任意の proposal でサイズを問い合わせ、任意の点に配置できます。Layout が子と対話できるのはこのプロキシ経由のみです。
  • カスタムレイアウト値は、子ビューに .layoutValue(...) で付与した LayoutValueKey 型を通じて子から親へ流れ、レイアウトメソッド内で LayoutSubview の subscript から読み取れます。
  • cachesizeThatFitsplaceSubviews 間で計算を償却するためのものです(各パスで両メソッドが呼ばれ、しばしば同じ中間値を必要とします)。事前計算したサイズを保持する struct としてキャッシュを型付けし、一度構築して両メソッドで再利用しましょう。

プロトコルの契約

Layout は通常 struct で、レイアウトパス中に Apple のフレームワークが呼び出す 2 つのメソッドを宣言します2

struct DiagonalLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Compute and return the size your layout wants
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // Position each subview by calling subview.place(...)
    }
}

組み込みコンテナと同じように使えます。

DiagonalLayout {
    Text("First")
    Text("Second")
    Text("Third")
}

フレームワークはまず親から提案されたサイズ(ProposedViewSize)を引数に sizeThatFits を呼び出し、続いてレイアウトに割り当てられた bounds を引数に placeSubviews を呼び出します。この 2 つのメソッドが組み合わさって、レイアウトの振る舞い(どれだけのサイズを希望するか、その割り当ての中で各子をどこに配置するか)を表現します。

ProposedViewSize:親からのオファー

SwiftUI のレイアウトは「親が提案し子が処理する」契約に従います3。親が proposed size を渡し、子が実際のサイズを返し、親が自分の bounds 内で子を配置します。Layout はこの契約に ProposedViewSize を介して参加します。

struct ProposedViewSize {
    var width: CGFloat?
    var height: CGFloat?
}

オプショナルな各軸は意味を持ちます。

  • nil は「ideal/自然なサイズを使ってください」を意味します。Text.zero を提案すると最小幅(1 行 1 文字)を返し、nil を提案すると ideal な幅(1 行、折り返しなし)を返します。
  • 有限値 は「親はこれだけのスペースを提供します。あなたが決めてください」を意味します。Text に 100pt の幅を提案すると、折り返すかもしれないし、それより少なく使うかもしれないし、ちょうど 100 を使うかもしれません。
  • .infinity は「好きなだけ使ってください」を意味します。Color.infinity を提案すると、利用可能な全空間を占有します。

慣例として、ProposedViewSize.unspecifiedwidth: nil, height: nil)は ideal サイズの要求、ProposedViewSize.zero は最小サイズの要求、ProposedViewSize.infinity は貪欲な拡張の要求を表します。

カスタム LayoutsizeThatFits は proposal を尊重すべきであり、提案された bounds に対してレイアウトが実際に望むサイズを返すべきです。常に同じハードコードされた値を返してはいけません。ハードコードされたサイズは、レイアウトが異なるコンテナ(カードビュー、リストセル、シート)に適応する能力を損ないます。

LayoutSubview を介した子ビューサイズの読み取り

sizeThatFits の内部で、レイアウトは各子に対してさまざまな proposal でどんなサイズを希望するか問い合わせます。このクエリは LayoutSubview プロキシ経由で行われます4

func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) -> CGSize {
    let proposed = ProposedViewSize(
        width: proposal.width.map { $0 / CGFloat(subviews.count) },
        height: proposal.height
    )

    let sizes = subviews.map { $0.sizeThatFits(proposed) }
    let totalWidth = sizes.reduce(0) { $0 + $1.width }
    let maxHeight = sizes.map(\.height).max() ?? 0

    return CGSize(width: totalWidth, height: maxHeight)
}

subviews.map { $0.sizeThatFits(proposal) } というパターンが、レイアウトが子の希望サイズを発見する方法です。LayoutSubview プロキシの sizeThatFits(_:) メソッドは、Layout プロトコルのメソッドとは別物です。これはプロキシが、ある proposal を与えたときの子の希望サイズを問い合わせるためのものです。同じ名前を共有しているのは、両者が同じ交渉に参加しているからですが、契約上の異なる層に位置します。

子のサイズを知りたいレイアウトは proxy.sizeThatFits(_:) を呼び出します。子を配置したいレイアウトは placeSubviews の内部で proxy.place(at:anchor:proposal:) を呼び出します。

子ビューの配置

placeSubviews は、レイアウトが配置の決定を行う場所です2

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) {
    var x = bounds.minX
    let y = bounds.midY

    for subview in subviews {
        let size = subview.sizeThatFits(.unspecified)
        subview.place(
            at: CGPoint(x: x + size.width / 2, y: y),
            anchor: .center,
            proposal: ProposedViewSize(size)
        )
        x += size.width
    }
}

place(at:anchor:proposal:) の呼び出しは単一の子ビューを配置します。3 つのパラメータがあります。

  • at:親の座標空間における位置。
  • anchor:子ビューのどの点が at に来るか。.center は子の中心を at に置き、.topLeading は左上隅をそこに置きます。
  • proposal:子がレンダリングされるべきサイズ。同じ子の sizeThatFits から返されたサイズを渡せばその希望が尊重され、カスタム proposal を渡せば制約をかけられます。

すべての子ビューは placeSubviews の呼び出しごとに正確に 1 度だけ配置されなければなりません。子をスキップするとその子は未配置のまま残り(レンダリングされたレイアウトから消えます)、2 度配置するとランタイムエラーになります。

LayoutValueKey によるカスタムレイアウト値

子が親レイアウトに何か(優先度、span、カテゴリなど)を伝える必要があるとき、そのチャネルは LayoutValueKey です5

struct PriorityKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

extension View {
    func layoutPriority(_ value: Int) -> some View {
        layoutValue(key: PriorityKey.self, value: value)
    }
}

// Inside the Layout:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let sortedSubviews = subviews.sorted {
        $0[PriorityKey.self] > $1[PriorityKey.self]
    }
    // ... place sortedSubviews
}

LayoutValueKey プロトコルは親子間通信のための型付きチャネルを提供します。子は layout-value 修飾子で値を付与し、親は LayoutSubview の subscript で読み取ります。各キーには、明示的に指定しない子のためのデフォルト値があります。

このパターンは、.layoutPriority(_:) のような組み込み修飾子が概念的に表現しているものです。フレームワークはこの特定の値を LayoutSubview 上の専用プロパティ priority: Double として公開しており、公開 LayoutValueKey を介してではないため、レイアウト優先度のプロキシアクセスはキーの subscript ではなく subview.priority となります。カスタムレイアウトは、子から必要とするその他の構造化データのために、独自の LayoutValueKey 型を宣言します。

cache パラメータ

両方のレイアウトメソッドは cache: inout パラメータを受け取ります。キャッシュは sizeThatFitsplaceSubviews の間で作業を償却するためのレイアウト用の場所です6

struct DiagonalLayout: Layout {
    struct Cache {
        var sizes: [CGSize]
    }

    func makeCache(subviews: Subviews) -> Cache {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return Cache(sizes: sizes)
    }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
        let totalWidth = cache.sizes.reduce(0) { $0 + $1.width }
        let totalHeight = cache.sizes.reduce(0) { $0 + $1.height }
        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
        var x = bounds.minX
        var y = bounds.minY
        for (subview, size) in zip(subviews, cache.sizes) {
            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .topLeading,
                proposal: ProposedViewSize(size)
            )
            x += size.width
            y += size.height
        }
    }
}

デフォルトの cache 型は Void です。ほとんどのレイアウトはキャッシュを無視できます。サイズ計算が本当に高価で(再帰的な測定、動的なサイジング判断など)、同じ中間値が両方のレイアウトメソッドで使われる場合に、その存在意義が生まれます。

makeCache(subviews:) はレイアウトパスごとに 1 度実行され、updateCache(_:subviews:) はパス間で子ビューが変化したときに実行されます。このパターンによって、子そのものが変化したときにレイアウトがキャッシュされた状態を正しく無効化できます。

構築する価値のある一般的なカスタムレイアウト

自分で作ってみる価値のある 3 つのパターンを紹介します。

Flow レイアウト(折り返しアイテム)。 利用可能な幅をオーバーフローしたとき、アイテムが複数の行に折り返されるものです。Apple の HStack は折り返しません。カスタム Layout なら可能です。各子を測定し、左から右へ配置し、行幅が proposal の幅を超えたら次の行へ進めます。

斜めスタック。 アイテムが斜めにずらして配置されます(各子は前の子からわずかに右下にオフセット)。カードを重ねた UI、ギャラリーのプレビューレイアウト、視差感のあるスタックに有用です。

円/パイレイアウト。 アイテムを円周上に配置します。ラジアルメニュー、時間ベースの UI、等間隔のカテゴリラベルに有用です。

これらはいずれも sizeThatFits + placeSubviews +(オプションで)カスタムキャッシュで実装できます。フレームワークが「親が提案し子が処理する」交渉を扱い、開発者は配置の数学を扱います。

よくあるレイアウトの失敗

壊れたカスタムレイアウトを生み出す 3 つのパターンです。

proposal を無視するハードコードされたサイズ。 常に CGSize(width: 200, height: 100) を返すレイアウトは、コンテナに適応しません。結果として、シミュレータでは正常に見えても、より小さい画面、別の向き、リサイズ可能なコンテナの中では破綻します。

placeSubviews で子ビューをスキップする。 すべての子ビューは呼び出しごとに正確に 1 度だけ配置されなければなりません。条件によって continue する for ループは、それらの子ビューを未配置のまま残し、レンダリング出力から消滅させます。

カスタム Layout の子の中で GeometryReader を使う。 GeometryReader は常にコンテンツに受け取った全空間を提案するため、レイアウトの子ごとの proposal と衝突します。この組み合わせはナンセンスなサイズを生み出します。カスタムレイアウトは内部に GeometryReader を置くべきではありません。子が割り当てられたサイズを知る必要があるなら、レイアウトプロトコルの proposal メカニズムが正しいチャネルです。

いつ Layout に手を伸ばすか(そしていつ伸ばさないか)

カスタム Layout が正しいツールである 3 つのシグナル。

  1. HStack/VStack/ZStack/Grid のコンポジションでは表現できない形状。 パイレイアウト、masonry グリッド、カスタムフロー折り返しなど。組み込みプリミティブはこれらの形状を構成できません。
  2. 子ごとの情報が配置を駆動する。 子に優先度、重み、カテゴリがあり、親がそれを使って配置するレイアウトです。LayoutValueKey が正しいチャネルです。
  3. レイアウトのサイジングが子との交渉に依存する。 「最も長い行に収まる最小の高さは?」「N 個の子に等しい列を与える幅は?」を尋ねるレイアウトには、subviews.sizeThatFits(...) クエリへのアクセスが必要です。

組み込みコンポジションで十分な 3 つのシグナル。

  1. 標準的な水平/垂直/深さ方向のスタッキング。 HStackVStackZStack が一般的なケースをカバーします。
  2. 規則的な行/列を持つグリッド。 GridLazyVGrid/LazyHGrid がほとんどのグリッドケースを処理します。
  3. 少しのオーバーレイ配置。 .overlay.background、alignment 付きの ZStack がほとんどの「Y の上に X」パターンをカバーします。

経験則として、組み込みが扱える形状のためにカスタム Layout を作ってはいけません。形状が組み込みの表現セットを本当に超えているときに作りましょう。

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

3 つの要点です。

  1. sizeThatFits で proposal を尊重する。 proposal に関係なく同じサイズを返すレイアウトは、SwiftUI のレイアウトシステムに適切に参加していません。proposal を読み、それに見合ったサイズを返しましょう。

  2. 構造化された親子間通信に LayoutValueKey を使う。 view-modifier で付与されたキーを介してデータを渡すのが SwiftUI ネイティブのパターンです。レイアウトレベルの判断に特化したデータには、@Environment やカスタム PreferenceKey に手を伸ばさないでください。LayoutValueKey がそのための型付きチャネルです。

  3. 測定が高価なときだけキャッシュを構築する。 デフォルトの Void キャッシュはほとんどのレイアウトで十分です。同じ高価な計算が sizeThatFitsplaceSubviews の両方に現れるときだけ、カスタムキャッシュ型に手を伸ばしましょう。

Apple Ecosystem クラスター全体:型付き App IntentsMCP サーバールーティングの問題Foundation Modelsランタイム vs ツーリング LLM の区別3 つのサーフェスSingle Source of Truth パターン2 つの MCP サーバーApple 開発のための hooksLive ActivitieswatchOS ランタイムSwiftUI の内部構造RealityKit の空間メンタルモデルSwiftData スキーマ規律Liquid Glass パターンマルチプラットフォーム配信プラットフォームマトリクスVision フレームワークSymbol EffectsCore ML 推論Writing Tools APISwift TestingPrivacy Manifestプラットフォーム機能としての AccessibilitySF Pro タイポグラフィvisionOS の空間パターンSpeech フレームワークSwiftData マイグレーションtvOS フォーカスエンジン@Observable の内部書くことを拒否するもの。ハブは Apple Ecosystem シリーズ にあります。AI エージェントを伴う iOS 開発の文脈は、iOS Agent Development ガイド を参照してください。

FAQ

なぜ単に GeometryReader を使わないのですか?

GeometryReader は常に受け取った全サイズをコンテンツに提案します(コンテンツが何を欲しているかについて意見を持ちません)。結果として、GeometryReader の中のあらゆるビューは、reader が制約しない軸に対して infinity を提案され、Text のようなビューは貪欲に自分のサイズを決めてしまいます。コンポジションが自身と戦うことになります。reader はそのまま素通しし、コンテンツは最大サイズを要求し、レイアウトは破綻します。Layout が正しいツールである理由は、開発者が proposed size について子ごとの明示的な判断を下せるからです。

カスタム HStack の代替を書けますか?

書けます。HStack 相当のカスタム Layout は、子の希望サイズを読み、その幅を合計し、最大の高さを取り、左から右に配置します。実際の HStack はもっと多くのこと(spacing、alignment、layout priority の解決)を行いますが、基本的な形状は Layout で簡潔に書けます。この演習はプロトコルの動作を体得するのに有用です。

カスタムレイアウトで .layoutPriority(_:) をサポートするにはどうすればよいですか?

LayoutSubview プロキシの専用プロパティ priority: Double から読み取ります。subview.priority です。SwiftUI は .layoutPriority(_:) を公開 LayoutValueKey を介してではなく、プロキシ上で直接公開しています。デフォルト値は 0 です。余剰スペースを分配するときに(高優先度の子に優先的に与える)、または truncation を行うときに(低優先度の子から先に truncate する)使用しましょう。

proposal: .infinityproposal: .zero の違いは何ですか?

.infinity は各軸に最大サイズを提案します(width: .infinity, height: .infinity)。貪欲な proposal に応答する子(Color など)は利用可能な全空間を取ります。.zero は最小サイズを提案します(width: 0, height: 0)。子は最小サイズを返します(Text は最も長い分割不可能なトークンのサイズを返します)。両者は子のサイジング範囲を測定するための有用な端点です。多くのレイアウトは .unspecified(両方 nil)を使って「ideal なサイズは何ですか?」と尋ねます。

Layout は watchOS、tvOS、visionOS で動作しますか?

動作します。Layout プロトコルは SwiftUI のクロスプラットフォームコアにあります。カスタムレイアウトは iOS、iPadOS、macOS、watchOS、tvOS、visionOS で同じように機能します。本クラスターの Apple Platform Matrix では、プラットフォーム包含は製品判断であると論じていますが、SwiftUI の Layout メカニズム自体は、複数プラットフォームが該当するケースに対してプラットフォーム非依存です。

Layout@Observable モデルとどのように相互作用しますか?

Layout は struct で、observable な状態を直接保持しません。変化を追跡しません。モデルが更新されると、親ビューの body が再評価され、その結果として Layout は body が生成する子で再実行されます。Layout は自身の observation hooks ではなく、それが住む body を介してリアクティブになります。本クラスターの @Observable internals で observation 側をカバーしています。

References


  1. Apple Developer Documentation: Layout. The protocol reference covering sizeThatFits and placeSubviews requirements, plus the optional makeCache, updateCache, spacing, and explicit-alignment hooks. 

  2. Apple Developer Documentation: sizeThatFits(proposal:subviews:cache:) and placeSubviews(in:proposal:subviews:cache:). The two required methods of the Layout protocol. 

  3. Apple Developer Documentation: ProposedViewSize. The two-optional-CGFloat type that carries the parent’s size proposal, with the convention values .unspecified, .zero, and .infinity

  4. Apple Developer Documentation: LayoutSubview. The proxy type representing a child view inside Layout methods, with sizeThatFits(_:) for querying preferred sizes and place(at:anchor:proposal:) for positioning. 

  5. Apple Developer Documentation: LayoutValueKey and layoutValue(key:value:). The typed channel for child-to-parent layout-level data, accessed via subscript on LayoutSubview

  6. Apple Developer: Composing custom layouts with SwiftUI. The Apple guide covering caching, alignment guides, and when to reach for Layout versus built-in containers. 

関連記事

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 分で読める

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 分で読める

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分で読める