사용자 지정 SwiftUI Layout을 사용해 첫 번째 셀에 열 두 칸을 할당한 다음, 나머지 셀을 연속된 행에 채워 넣습니다. 이 macOS 예제는 창 너비가 바뀔 때 열을 다시 계산하고, 각 행의 높이를 콘텐츠에 맞춰 측정합니다.

이 작업은 작은 데스크톱 팔레트입니다. 소개 타일 하나와 번호가 매겨진 버튼 열한 개로 구성됩니다. 열이 두 개 이상 들어갈 때 첫 번째 타일은 더 넓어집니다. 좁은 너비에서는 콘텐츠가 세로로 이어질 수 있도록 단일 열 타일이 됩니다. 예제의 모든 레이블과 선택 항목은 임의로 만든 것입니다.

이 배치를 지원하는 SwiftUI 컨테이너는 무엇인가요?

Apple의 Grid는 GridRow 안에서 gridCellColumns(_:)을 사용해 셀이 여러 열에 걸치도록 지원합니다. 하지만 행은 직접 지정해야 합니다. GridRow 바깥에서 Grid 안에 직접 배치한 뷰는 그리드 전체에 걸칩니다. 어느 방식도 평면 컬렉션을 너비에 따라 자동으로 행에 채워 넣지는 않습니다. Apple의 Grid 문서와 gridCellColumns(_:) 문서를 참고하세요.

LazyVGrid는 자동 행 채우기를 제공합니다. 자식을 열을 따라 배치하고 다음 행으로 이어갑니다. 문서화된 열 구성에는 개별 셀이 여러 열에 걸치도록 하는 GridRow 방식이 없습니다. 첫 번째 자식의 시각적 너비를 늘려도 문서화된 두 열 할당이 성립하지는 않습니다. Apple의 LazyVGrid 문서를 참고하세요.

필요한 동작에 따라 컨테이너를 선택하세요.

요구 사항 적합한 방식
셀이 두 열에 걸치는 명시적 행 Grid, GridRow, gridCellColumns(2)
일반 셀을 자동으로 채우는 행 LazyVGrid
자동으로 채우는 행과 두 칸을 차지하는 첫 번째 셀 해당 패킹 규칙을 구현하는 사용자 지정 Layout

마지막 경우에 Apple이 지원하는 확장 지점은 Layout 프로토콜입니다. 이 프로토콜의 핵심 메서드 두 개는 컨테이너의 크기를 보고하고 자식 뷰를 배치합니다. 아래 구현은 문서화된 메서드를 기반으로 만든 독자적인 패킹 알고리즘입니다. Layout — Apple

레이아웃을 작성하기 전에 크기 규칙 정의하기

이 예제에서는 일반 열의 최소 너비를 96포인트, 간격을 8포인트로 설정합니다. 사용 가능한 너비를 활용하도록 열 너비를 균등하게 늘립니다. 첫 번째 자식은 두 칸을 차지하며 그 사이 간격도 포함합니다. 나머지 자식은 각각 한 칸을 차지합니다. 자식은 기존 순서대로 행을 채우고, 각 행의 높이는 가장 높은 자식에 맞춥니다.

사용 가능한 너비 W, 최소 열 너비 m, 간격 g일 때 열 개수는 max(1, floor((W + g) / (m + g)))입니다. 실제 일반 열 너비는 (W - (columns - 1) × g) / columns입니다. 따라서 두 칸을 차지하는 타일의 너비는 2 × columnWidth + g포인트입니다.

예를 들어 콘텐츠 영역이 408포인트라면, 96포인트 열 네 개와 8포인트 간격 세 개가 들어갑니다. 소개 타일의 너비는 200포인트입니다. 일반 버튼 두 개가 옆에 들어가고, 그 뒤의 버튼은 행마다 네 칸을 채웁니다. 이는 선택한 상수를 바탕으로 한 예시 계산이며, 실행 중인 앱에서 측정한 값은 아닙니다.

SwiftUI는 레이아웃의 유연성을 파악하는 동안 지정되지 않은 크기, 0 또는 무한대 크기를 제안할 수 있습니다. 이러한 제안에는 신중하게 대응해야 합니다. 이 구현은 이상적인 너비에 최대 네 개의 열을, 최소 너비에 한 개의 열을 사용합니다. 최대 너비가 요청되면 모든 자식이 한 행을 공유할 수 있도록 충분한 열을 사용합니다. 항상 유한한 너비를 반환합니다. Apple은 ProposedViewSize에서 이러한 제안의 의미를 설명합니다.

macOS 앱에서 레이아웃 구현하기

Xcode에서 SwiftUI macOS 앱을 만들고 배포 대상을 macOS 13 이상으로 설정하세요. 이 버전부터 Layout을 사용할 수 있습니다. 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를 더합니다. 부모가 할당한 경계가 반드시 0에서 시작하는 것은 아닙니다. 각 자식은 한 번씩 배치되며, LayoutSubview 배치 규약에 따라 이러한 호출은 placeSubviews 안에서만 이뤄집니다.

임의의 타일과 버튼 동작 추가하기

생성된 앱 진입 파일을 아래 코드로 바꾸고 프로젝트에 @main 선언이 정확히 하나만 남도록 하세요. WideFirstGrid.swift와 함께 이 코드가 전체 예제를 이룹니다. 소개 텍스트는 첫 번째 레이아웃 자식이며, 각 반복에서 버튼 하나를 제공합니다.

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 대상을 빌드하고 실행한 다음 창의 가로 크기를 조정하세요. 다음 예상 결과는 주변 패딩을 제외한 그리드의 사용 가능한 콘텐츠 너비를 기준으로 합니다. 소개 타일 하나와 버튼 열한 개, 총 자식 열두 개를 가정합니다.

그리드 콘텐츠 너비 열 개수 첫 번째 셀 너비 전체 행 수
96포인트 1 96포인트 12
200포인트 2 200포인트 7
304포인트 3 200포인트 5
408포인트 4 200포인트 4

창의 최소 너비 때문에 96포인트 사례를 테스트하려면 별도의 좁은 테스트 컨테이너를 사용하거나 창의 최소 너비를 일시적으로 줄여야 합니다. 빈 레이아웃, 소개 타일만 있는 레이아웃, 더 긴 소개 레이블도 테스트하세요. 콘텐츠가 비어 있으면 크기는 0을 반환합니다. 첫 번째 타일만 있어도 다른 자식이 없어도 사용 가능한 두 칸을 차지할 수 있습니다.

위의 네 가지 너비에서 결정적인 Python 계산을 사용해 패킹 산술을 확인했으며, 슬롯 겹침과 열 넘침도 검사했습니다. 조사 환경에는 Swift 컴파일러가 없어 SwiftUI 애플리케이션을 살펴보기만 했고, 컴파일하거나 실행하지는 않았습니다. 예제를 수정하기 전에 Xcode에서 시각적 동작을 확인하세요.

SwiftUI는 크기를 결정하기 전에 서로 다른 제안으로 sizeThatFits를 여러 번 호출할 수 있습니다. 측정 중에는 선택 상태 변경, 자식 배치 또는 기타 앱 동작을 하지 않도록 하세요. 이 예제는 기본 빈 캐시를 사용해 계획을 다시 계산하므로 이전 너비 때문에 행 위치가 오래된 상태로 남지 않습니다. Apple은 sizeThatFits(proposal:subviews:cache:)에서 이러한 반복 측정 동작을 설명합니다.

확장하기 전에 한계 이해하기

96포인트 최소 너비는 의도적으로 정한 레이아웃 정책입니다. 부모가 이보다 좁은 너비를 제공해도 레이아웃은 96포인트를 보고하므로 할당 범위를 벗어날 수 있습니다. 제공된 창 최소 너비는 이런 상황을 방지합니다. 더 좁은 컨테이너를 지원하려면 최소 열 너비를 낮추고 해당 너비에서 레이블을 확인하세요.

크기 제안은 요청입니다. 자식은 다른 크기를 선택할 수 있습니다. 제공된 타일은 할당된 너비를 수용하도록 설계되어 있습니다. 더 큰 고정 너비를 고집하는 콘텐츠로 타일을 바꾸면 겹침이 발생할 수 있으므로, 그런 자식을 추가하기 전에 크기 정책을 수정하세요. place(at:anchor:proposal:) — Apple

이 구현은 물리적인 왼쪽에서 오른쪽으로 배치하며 모든 자식을 측정합니다. 방향에 따른 순서 지정이나 지연 생성을 구현하지 않습니다. 훨씬 큰 컬렉션에서는 다른 컨테이너를 선택하기 전에 프로파일링하세요. Apple은 프로파일링을 통해 충분한 성능 향상이 확인될 때 일반 그리드에서 지연 그리드로 옮길 것을 권장합니다. 이 변경을 하더라도 셀을 여러 열에 걸치게 하는 요구 사항은 다시 검토해야 합니다. Grid — Apple

작은 Mac 팔레트라면 공유 계획 함수 덕분에 동작을 쉽게 조정할 수 있습니다. 열 최소 너비나 간격을 바꾸고 측정과 배치의 일관성을 유지하며, 한 열 대체 동작을 그대로 두세요. 그러면 공간이 허용될 때 첫 번째 타일은 계속 넓게 표시되고, 나머지 버튼은 다음으로 사용 가능한 슬롯을 채웁니다.

출처