SwiftUI の Layout プロトコル(iOS 16+)でカスタムレイアウトを作る

iOS 16 で導入された Layout プロトコルを使うと、VStack や HStack では実現できない独自のレイアウトロジックを構築できます。子ビューのサイズ提案から配置まで、すべてのプロセスを自分でコントロールできる仕組みです。

Layout プロトコルの構造

Layout に準拠する型は、sizeThatFits と placeSubviews の 2 つのメソッドを実装します。

struct SimpleHStack: Layout {
    var spacing: CGFloat = 8
    
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let width = sizes.reduce(0) { $0 + $1.width }
            + spacing * CGFloat(subviews.count - 1)
        let height = sizes.map(\.height).max() ?? 0
        return CGSize(width: width, height: height)
    }
    
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        var x = bounds.minX
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            subview.place(
                at: CGPoint(x: x, y: bounds.midY - size.height / 2),
                proposal: ProposedViewSize(size)
            )
            x += size.width + spacing
        }
    }
}

sizeThatFits はコンテナ自体のサイズを返し、placeSubviews は各子ビューの配置座標を指定します。この 2 つを実装するだけで、SwiftUI のレイアウトシステムに完全に統合されたカスタムコンテナが完成します。

sizeThatFits

親から「このくらいのスペースがある」と提案を受け、子ビューを配置するのに必要な全体サイズを計算して返す

placeSubviews

確定した領域(bounds)の中で、各子ビューを具体的な座標に配置する。サイズ提案もここで行う

使い方

定義したカスタムレイアウトは、VStack や HStack と同じように使えます。

SimpleHStack(spacing: 12) {
    Text("Apple")
        .padding(8)
        .background(Color.red.opacity(0.2))
        .cornerRadius(8)
    Text("Banana")
        .padding(8)
        .background(Color.yellow.opacity(0.2))
        .cornerRadius(8)
    Text("Cherry")
        .padding(8)
        .background(Color.pink.opacity(0.2))
        .cornerRadius(8)
}

実践例:FlowLayout(折り返しレイアウト)

Layout プロトコルの実用的な例として、タグ一覧のように要素を横に並べて自動的に折り返す FlowLayout を実装してみましょう。

struct FlowLayout: Layout {
    var spacing: CGFloat = 8
    
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let result = arrange(
            subviews: subviews,
            in: proposal.width ?? 0
        )
        return result.size
    }
    
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let result = arrange(
            subviews: subviews,
            in: bounds.width
        )
        for (index, position) in result.positions.enumerated() {
            subviews[index].place(
                at: CGPoint(
                    x: bounds.minX + position.x,
                    y: bounds.minY + position.y
                ),
                proposal: ProposedViewSize(
                    subviews[index].sizeThatFits(.unspecified)
                )
            )
        }
    }
    
    private func arrange(
        subviews: Subviews,
        in width: CGFloat
    ) -> (size: CGSize, positions: [CGPoint]) {
        var positions: [CGPoint] = []
        var x: CGFloat = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0
        var maxWidth: CGFloat = 0
        
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x + size.width > width, x > 0 {
                x = 0
                y += rowHeight + spacing
                rowHeight = 0
            }
            positions.append(CGPoint(x: x, y: y))
            rowHeight = max(rowHeight, size.height)
            x += size.width + spacing
            maxWidth = max(maxWidth, x - spacing)
        }
        
        return (
            CGSize(width: maxWidth, height: y + rowHeight),
            positions
        )
    }
}

この FlowLayout は、横幅に収まらない要素を次の行に自動的に折り返します。タグクラウドやチップ UI の実装にそのまま使えるレイアウトです。

FlowLayout(spacing: 8) {
    ForEach(tags, id: \.self) { tag in
        Text(tag)
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(Color.blue.opacity(0.1))
            .cornerRadius(16)
    }
}

cache で計算結果を再利用する

Layout プロトコルの cache パラメータを活用すると、sizeThatFits と placeSubviews の間で計算結果を共有できます。上の FlowLayout では同じ arrange 関数を 2 回呼んでいますが、cache を使えば 1 回の計算結果を使い回せます。

struct CachedFlowLayout: Layout {
    struct CacheData {
        var size: CGSize
        var positions: [CGPoint]
    }
    
    func makeCache(subviews: Subviews) -> CacheData {
        CacheData(size: .zero, positions: [])
    }
    
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) -> CGSize {
        let result = arrange(subviews: subviews,
                             in: proposal.width ?? 0)
        cache.size = result.size
        cache.positions = result.positions
        return result.size
    }
    
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) {
        // cache から位置を取得して配置
    }
}

子ビューの数が多い場合はキャッシュの効果が大きくなります。パフォーマンスが気になるレイアウトでは積極的に活用しましょう。

Layout プロトコルは学習コストがやや高めですが、一度理解すれば SwiftUI の標準コンテナでは実現できなかったレイアウトを自在に作れるようになります。FlowLayout や放射状レイアウトなど、定型的なものはライブラリとして再利用可能な形で定義しておくと便利です。