カスタムのSwiftUI Layout を使い、最初のセルに2列分の領域を割り当ててから、残りのセルを順に次の行へ配置します。このmacOSの例では、ウィンドウ幅が変わると列数を再計算し、各行の高さを内容に合わせて測定します。

これは、小さなデスクトップ用パレットの例です。導入用タイル1つと、番号付きボタン11個で構成されています。2列以上を配置できる場合、最初のタイルは幅広になります。幅が狭い場合は1列のタイルとなり、コンテンツを縦に並べて表示できます。例のラベルと選択項目はすべてサンプルです。

この配置に対応するSwiftUIのコンテナは?

AppleのGridでは、GridRow内でgridCellColumns(_:)を使って、セルを複数列にまたがらせることができます。ただし、行は自分で指定する必要があります。GridRowの外側でGridに直接配置したビューは、グリッド全体にまたがります。どちらの方法も、横幅に応じてフラットなコレクションを自動的に行へ詰めてはくれません。AppleのGridのドキュメントとgridCellColumns(_:)のドキュメントを参照してください。

LazyVGridは行への自動配置に対応し、子ビューを列方向に並べて、続く行へ配置します。ドキュメントに記載されている列の設定には、個々のセルを複数列にまたがらせるGridRowの仕組みはありません。最初の子ビューを見た目だけ幅広にしても、2列分の割り当てが保証されるわけではありません。AppleのLazyVGridのドキュメントを参照してください。

必要な動作に応じてコンテナを選びます。

要件 適した方法
明示した行と、2列にまたがるセル Grid、GridRow、gridCellColumns(2)
通常のセルを自動配置する行 LazyVGrid
自動配置する行と、2枠にまたがる最初のセル この配置ルールを実装するカスタムLayout

最後のケースでAppleが提供する拡張ポイントはLayoutプロトコルです。主に必要なメソッドは、コンテナのサイズを報告するものと、子ビューを配置するものの2つです。以下の実装は、これらのドキュメント化されたメソッドを使った独自の配置アルゴリズムです。Layout — Apple

レイアウトを書く前にサイズのルールを決める

この例では、通常の列の最小幅を96ポイント、間隔を8ポイントとします。列は利用可能な幅を均等に使うよう拡張されます。最初の子ビューは、2枠分とその間の間隔を占めます。それ以外の子ビューは1枠ずつ占めます。子ビューは既存の順序で行に配置され、各行の高さは最も高い子ビューに合わせます。

利用可能な幅をW、列の最小幅をm、間隔をgとすると、列数はmax(1, floor((W + g) / (m + g)))です。通常の列の実際の幅は(W - (columns - 1) × g) / columnsです。そのため、2枠のタイルの幅は2 × columnWidth + gポイントになります。

たとえば、コンテンツ領域の幅が408ポイントなら、幅96ポイントの列を4つ、8ポイントの間隔を3つ配置できます。導入用タイルの幅は200ポイントです。その横に通常のボタンを2つ配置でき、続くボタンは1行につき4枠ずつ配置されます。これらは選択した定数に基づく計算例であり、実行中のアプリで測定した値ではありません。

レイアウトの柔軟性を調べる過程で、SwiftUIはサイズ提案として未指定、ゼロ、または無限のサイズを渡すことがあります。これらの提案には、意図した応答が必要です。この実装では理想幅に最大4列、最小幅に1列を使い、最大幅を求められた場合はすべての子ビューが1行に並ぶ列数を使います。常に有限の幅を返します。AppleはProposedViewSizeで、これらの提案の意味を説明しています。

macOSアプリにレイアウトを実装する

XcodeでSwiftUIのmacOSアプリを作成し、Layoutを利用できるmacOS 13以降をデプロイメントターゲットに設定します。WideFirstGrid.swiftという名前のSwiftファイルを追加し、以下のレイアウト型全体を記述します。Layout — Apple

import SwiftUI

struct WideFirstGrid: Layout {
    let minimumColumnWidth: CGFloat = 96
    let gap: CGFloat = 8

    struct Plan {
        var size: CGSize
        var frames: [CGRect]
    }

    func makePlan(width proposedWidth: CGFloat?, subviews: Subviews) -> Plan {
        guard !subviews.isEmpty else {
            return Plan(size: .zero, frames: [])
        }

        let totalSlots = subviews.count + (subviews.count > 1 ? 1 : 0)
        let idealColumns = min(4, totalSlots)
        let idealWidth = CGFloat(idealColumns) * minimumColumnWidth
            + CGFloat(idealColumns - 1) * gap
        let maximumWidth = CGFloat(totalSlots) * minimumColumnWidth
            + CGFloat(totalSlots - 1) * gap

        let width: CGFloat
        if let proposedWidth {
            if proposedWidth.isFinite {
                width = max(minimumColumnWidth, proposedWidth)
            } else {
                width = maximumWidth
            }
        } else {
            width = idealWidth
        }

        let columns = max(1, Int(floor(
            (width + gap) / (minimumColumnWidth + gap)
        )))
        let columnWidth = (width - CGFloat(columns - 1) * gap)
            / CGFloat(columns)

        var frames: [CGRect] = []
        var column = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0

        for (index, subview) in subviews.enumerated() {
            let span = index == 0 ? min(2, columns) : 1
            if column + span > columns {
                y += rowHeight + gap
                column = 0
                rowHeight = 0
            }

            let cellWidth = CGFloat(span) * columnWidth
                + CGFloat(span - 1) * gap
            let measured = subview.sizeThatFits(
                ProposedViewSize(width: cellWidth, height: nil)
            )
            frames.append(CGRect(
                x: CGFloat(column) * (columnWidth + gap),
                y: y,
                width: cellWidth,
                height: measured.height
            ))
            rowHeight = max(rowHeight, measured.height)
            column += span
        }

        return Plan(
            size: CGSize(width: width, height: y + rowHeight),
            frames: frames
        )
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        makePlan(width: proposal.width, subviews: subviews).size
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let plan = makePlan(width: bounds.width, subviews: subviews)
        for (subview, frame) in zip(subviews, plan.frames) {
            subview.place(
                at: CGPoint(
                    x: bounds.minX + frame.minX,
                    y: bounds.minY + frame.minY
                ),
                anchor: .topLeading,
                proposal: ProposedViewSize(width: frame.width, height: nil)
            )
        }
    }
}

測定と配置には同じ計画関数を使います。各子ビューは、割り当てられた幅と高さ未指定の提案で測定され、同じ子ビューサイズの提案を使って配置されます。これにより、折り返しテキストと行の高さの計算に一貫性が保たれます。Appleは、サイズ計算と配置の計算に整合性を持たせるよう明示しています。placeSubviews(in:proposal:subviews:cache:) — Apple

配置時には、計算した各位置にbounds.minXとbounds.minYを加えます。親から割り当てられた境界の原点は、必ずしもゼロとは限りません。各子ビューに対する配置呼び出しは1回だけで、LayoutSubviewの配置に関する仕様に従い、placeSubviewsの内側でのみ行われます。

サンプルタイルとボタンのアクションを追加する

生成されたアプリのエントリーファイルを以下のコードに置き換え、プロジェクト内の@main宣言が1つだけになるようにします。WideFirstGrid.swiftと合わせて、これで例全体が完成します。導入用テキストが最初のレイアウト子ビューとなり、各反復でボタンが1つ追加されます。

import SwiftUI

@main
struct WideFirstGridDemoApp: App {
    var body: some Scene {
        WindowGroup {
            PaletteView()
                .frame(minWidth: 240, minHeight: 260)
        }
    }
}

struct PaletteView: View {
    @State private var selectedNumber: Int? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Tile palette")
                .font(.title2)

            Text(selectedNumber.map { "Selected tile: \($0)" }
                 ?? "Choose a numbered tile")

            ScrollView {
                WideFirstGrid {
                    Text("Choose a tile from this sample collection")
                        .font(.headline)
                        .padding(12)
                        .frame(
                            maxWidth: .infinity,
                            minHeight: 64,
                            alignment: .leading
                        )
                        .background(
                            Color.blue.opacity(0.12),
                            in: RoundedRectangle(cornerRadius: 10)
                        )

                    ForEach(1...11, id: \.self) { number in
                        Button {
                            selectedNumber = number
                        } label: {
                            Text("Tile \(number)")
                                .padding(12)
                                .frame(maxWidth: .infinity, minHeight: 64)
                                .background(
                                    Color.gray.opacity(0.12),
                                    in: RoundedRectangle(cornerRadius: 10)
                                )
                        }
                        .buttonStyle(.plain)
                    }
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .padding(16)
    }
}

固定された数値コレクションにより、id: \.selfを使って各ボタンに一意で安定した識別子を割り当てます。レイアウトはサブビュープロキシを使って位置を計算し、選択状態はビューの状態として保持され、ボタンのアクション内でのみ変更されます。Appleのドキュメントによると、ForEachには識別可能な要素、または明示的な識別子が必要です。ForEach — Apple

追加の行を表示できるよう、ScrollViewを使います。このレイアウトは、提案された高さに合わせて行を圧縮せず、コンテンツの自然な高さを計算します。テキストラベルが長いとその行の高さが増し、それに合わせて後続の行も下へ移動します。同じ行の短い子ビューは上端に揃います。

サイズ変更、狭い幅、レイアウトのライフサイクルを確認する

macOSターゲットをビルドして実行し、ウィンドウの幅を変えます。次の結果は、周囲のパディングを除いた、グリッドで利用できるコンテンツ幅に基づく予想値です。導入用タイル1つとボタン11個、合計12個の子ビューを想定しています。

グリッドのコンテンツ幅 列数 最初のセルの幅 行数
96ポイント 1 96ポイント 12
200ポイント 2 200ポイント 7
304ポイント 3 200ポイント 5
408ポイント 4 200ポイント 4

ウィンドウの最小幅があるため、96ポイントのケースは、別の狭幅テスト用コンテナを用意するか、一時的にウィンドウの最小幅を下げる必要があります。また、空のレイアウト、導入用タイルだけを含むレイアウト、さらに長い導入用ラベルもテストします。空のコンテンツはサイズがゼロになり、最初のタイルだけの場合は、別の子ビューがなくても利用可能な2枠を占めることができます。

上記4つの幅で、重複する枠や列からのはみ出しも確認しながら、決定的なPython計算で配置の算術を検証しました。調査環境にはSwiftコンパイラがなく、SwiftUIアプリの確認はしましたが、コンパイルも実行もしていません。例を応用する前に、Xcodeで見た目の動作を確認してください。

サイズを決定するまでに、SwiftUIは異なる提案を使ってsizeThatFitsを何度も呼び出す場合があります。測定中に選択状態の変更、子ビューの配置、その他のアプリケーション処理を行わないでください。この例ではデフォルトの空のキャッシュを使って計画を再計算するため、以前の幅による古い行位置が残ることはありません。Appleはこのような繰り返しの測定動作をsizeThatFits(proposal:subviews:cache:)で説明しています。

拡張する前に制約を理解する

96ポイントの最小幅は、意図的に選んだレイアウト方針です。親がそれより狭い幅を提示した場合も、レイアウトは96ポイントを報告するため、割り当てられた領域からはみ出す可能性があります。指定したウィンドウの最小幅により、この状況を防いでいます。さらに狭いコンテナに対応するには、列の最小幅を下げ、その幅でラベルがどう見えるか確認してください。

サイズ提案は要求であり、子ビューが別のサイズを選ぶ場合があります。指定したタイルは、割り当てられた幅を受け入れるよう設計されています。固定幅をそれより広く取ろうとするコンテンツに置き換えると、重なりが発生する可能性があるため、そのような子ビューを加える前にサイズ方針を見直してください。place(at:anchor:proposal:) — Apple

この実装は、左から右への物理的な配置を使い、すべての子ビューを測定します。方向に応じた順序変更や遅延生成には対応していません。大規模なコレクションでは、別のコンテナを選ぶ前にプロファイルを確認してください。プロファイルで十分な性能向上が見込める場合は、通常のグリッドから遅延グリッドへの変更をAppleは推奨しています。その場合も、セルをまたがらせる要件を改めて検討する必要があります。Grid — Apple

小規模なMac用パレットなら、計画関数を共有することで動作を簡単に調整できます。列の最小幅や間隔を変更し、測定と配置の一貫性を保ち、1列へのフォールバックを維持します。これにより、スペースがあれば最初のタイルが幅広になり、残りのボタンは次に空いている枠へ順に配置されます。

参考資料