← SwiftUI 14일 마스터 8 / 14 · 1부

Day 8 · 1부 · Grid와 적응형 레이아웃

GeometryReader 없이 표·격자를 짜고, 공간에 따라 가로/세로를 바꾸되 AnyLayout으로 자식 identity를 지킨다.
진행률
0 / 0 완료
Day 7의 스택·frame·Spacer만으로 대부분의 화면이 나온다. 하지만 "열 폭이 가장 긴 셀에 맞춰지는 표", "공간이 모자라면 세로로 바뀌는 버튼 줄", "가장 넓은 버튼 폭으로 전부 맞추기"에서 UIKit 개발자는 습관처럼 GeometryReader나 수동 계산을 꺼낸다. SwiftUI는 이런 요구마다 레이아웃 엔진 안에서 푸는 전용 도구를 준다. 1부는 기성품 도구 — 2차원 배치(Grid · LazyVGrid)와 공간에 따라 모양을 바꾸는 ViewThatFits · AnyLayout을 다룬다.

1부 목표 · 19분

  • 정적 표는 Grid, 스크롤되는 많은 셀은 LazyVGrid로 고르고 GridItem의 fixed/flexible/adaptive를 요구에 맞게 쓴다
  • gridCellColumns·gridCellUnsizedAxes로 병합 셀과 구분선이 있는 표를 만든다
  • 공간에 따라 모양이 바뀌는 UI를 ViewThatFits·AnyLayout으로 만들고, if/else 분기와의 identity 차이를 설명한다

1. Grid와 LazyVGrid — 2차원 배치 🔥 핵심

Grid — 모든 셀을 먼저 재고 행·열을 맞춘다

Grid는 자식을 한 번에 전부 만든다. 그래서 열 폭은 그 열에서 가장 넓은 셀에, 행 높이는 그 행에서 가장 높은 셀에 맞출 수 있다. WWDC22 · Compose custom layouts with SwiftUI의 투표 순위표가 대표 예다. WWDC22 · What's new in SwiftUI의 요약: "Grid will measure its subviews up front to enable cells that span multiple columns and enable automatic alignments across rows and columns."

세션의 순위표 — 텍스트 열은 필요한 만큼, ProgressView는 남는 전부
struct Pet: Identifiable, Equatable {
    let id = UUID()
    var type: String
    var votes: Int
}

struct Leaderboard: View {
    var pets: [Pet]
    var totalVotes: Int
    var body: some View {
        Grid(alignment: .leading) {                 // 모든 셀 기본 정렬
            ForEach(pets) { pet in
                GridRow {
                    Text(pet.type)                   // 1열: 가장 긴 이름 폭
                    ProgressView(value: Double(pet.votes), total: Double(totalVotes)) // 2열: 나머지
                    Text("\(pet.votes)")
                        .gridColumnAlignment(.trailing) // 3열만 오른쪽 정렬
                }
                Divider()                            // GridRow 밖의 뷰 = 모든 열을 가로지르는 행
            }
        }
        .padding()
    }
}

세션은 flexible 뷰의 함정도 보여 준다. Divider처럼 제안을 다 받는 뷰가 들어가면 그리드가 부모 폭 전체로 커진다. 문서는 gridCellUnsizedAxes(_:)로 "이 셀에는 추가 크기를 주지 마"라고 말하라고 안내한다.

셀 병합(gridCellColumns)과 flexible 셀 억제(gridCellUnsizedAxes)
struct GridSpanDemo: View {
    var body: some View {
        Grid(horizontalSpacing: 8, verticalSpacing: 8) {
            GridRow {
                Text("이름")
                Text("수량")
                Text("금액")
            }
            .bold()
            Divider()
                .gridCellUnsizedAxes(.horizontal)  // 그리드를 화면 폭으로 늘리지 않는다
            GridRow {
                Text("아보카도")
                Text("2")
                Text("6,000")
            }
            GridRow {
                Text("합계")
                    .gridCellColumns(2)            // 두 열 병합
                Text("6,000")
            }
        }
    }
}

LazyVGrid — 보이는 셀만 만들고, 열 정의는 미리 준다

Lazy 그리드는 보이는 셀만 만들기 때문에 한 행 전체를 미리 잴 수 없다. 세션 표현으로 lazy 그리드는 "need you to provide information about one of their dimensions at initialization time" — 그래서 columns:에 GridItem 배열을 넘긴다.

fixed(60) + flexible ×2 폭 380 60 flexible 152 flexible 152 adaptive(minimum: 100) 폭 380 → 3칸 121 121 121 adaptive(minimum: 100) 폭 260 → 2칸 126 126 (폭 밖) spacing 8 기준 · fixed = 고정 · flexible = 남는 폭을 나눔 · adaptive = 한 칸 자리에 들어가는 만큼 여러 칸
adaptive는 폭에 따라 칸 수가 바뀐다 — 아래 PhotoGrid(최소 100, 간격 8, 좌우 여백 16)라면 iPhone 세로(가용 폭 약 358)는 3열, 가로는 6열 안팎, iPad는 그보다 많아진다. 코드는 한 줄 그대로다.
사진 격자 — adaptive 하나로 기기 폭에 맞춰 열 수가 바뀐다
struct PhotoGrid: View {
    let columns = [GridItem(.adaptive(minimum: 100), spacing: 8)]
    var body: some View {
        ScrollView {                              // 그리드 자체에는 스크롤이 없다
            LazyVGrid(columns: columns, spacing: 8) {
                ForEach(0..<60, id: \.self) { i in
                    RoundedRectangle(cornerRadius: 8)
                        .fill(Color.blue.opacity(0.2))
                        .aspectRatio(1, contentMode: .fit) // 폭에 맞춘 정사각형
                        .overlay(Text("\(i)"))
                }
            }
            .padding()
        }
    }
}
UIKit — 같은 "1/3 폭 셀"을 Compositional Layout으로
@MainActor func makeLayout() -> UICollectionViewLayout {
    let item = NSCollectionLayoutItem(
        layoutSize: .init(widthDimension: .fractionalWidth(1.0 / 3.0),
                          heightDimension: .fractionalHeight(1.0)))
    let group = NSCollectionLayoutGroup.horizontal(
        layoutSize: .init(widthDimension: .fractionalWidth(1.0),
                          heightDimension: .absolute(120)),
        subitems: [item])
    return UICollectionViewCompositionalLayout(section: NSCollectionLayoutSection(group: group))
}
// + dataSource, cell 등록, 스냅샷 적용이 별도로 필요하다
GridLazyVGridUIKit
자식 생성한 번에 전부보일 때만UICollectionView 셀 재사용
열 폭 결정셀 내용으로 자동미리 GridItem으로 지정Compositional Layout 크기 지정
열별 정렬gridColumnAlignmentGridItem(alignment:)셀 안 제약으로 직접
셀 병합gridCellColumns없음커스텀 레이아웃 필요
잘 맞는 곳순위표, 영수증, 설정 요약사진첩, 상품 목록—
📝 Apple 원문

"Only use a lazy grid if profiling your app shows that a Grid view performs poorly because it tries to load too many views at once." — Apple 문서 · LazyVGrid
→ 기본은 Grid다. lazy 그리드는 "뷰가 너무 많아 느리다"는 게 프로파일링으로 확인됐을 때 고른다.

2. 공간에 적응하기 — ViewThatFits와 AnyLayout

ViewThatFits — 후보를 순서대로 재 보고 첫 번째로 맞는 것

ViewThatFits 문서: "It selects the first child whose ideal size on the constrained axes fits within the proposed size." 선호하는 순서(보통 큰 것 → 작은 것)로 후보를 나열한다. 기본은 가로·세로 둘 다 검사하고, in: .horizontal로 한 축만 볼 수 있다.

부모 제안 폭 280 ① HStack 후보 ideal 폭 340 ② VStack 후보 ideal 폭 120 340 > 280 ✗ 건너뜀 120 ≤ 280 ✓ 이걸 그린다 후보를 ideal 크기로 순서대로 재 보고, 제안 안에 들어가는 첫 번째 하나만 화면에 남긴다
Dynamic Type이 커지면 HStack의 ideal 폭이 커져 자동으로 VStack 후보로 넘어간다. 세션의 투표 버튼이 정확히 이 시나리오다.
버튼 줄 — 한 줄에 안 들어가면 세로로
struct VoteButtons: View {
    var body: some View {
        ViewThatFits(in: .horizontal) {   // 가로 축만 검사
            HStack { buttons }            // 1순위
            VStack { buttons }            // 2순위
        }
    }
    @ViewBuilder private var buttons: some View {
        Button("고양이") { }
        Button("금붕어") { }
        Button("강아지") { }
    }
}

AnyLayout — 레이아웃만 바꾸고 자식 identity는 유지

ViewThatFits는 후보마다 다른 뷰다. 상태(스크롤 위치, 텍스트 입력, 애니메이션 연속성)를 유지한 채 배치만 바꾸려면 AnyLayout을 쓴다. 문서: "enable dynamically changing the type of a layout container without destroying the state of the subviews."

if / else — 다른 뷰로 교체 AnyLayout — 같은 뷰가 이동 true 분기의 A·B·C false 분기의 A'·B'·C' identity가 달라 삭제 + 삽입 (페이드) 위치만 이동 HStackLayout VStackLayout 같은 A·B·C가 위치만 바뀜 (이동 애니메이션)
Day 4의 structural identity가 그대로 적용된다. if/else는 _ConditionalContent의 서로 다른 두 가지, AnyLayout은 한 자리의 한 뷰다.
❌ if/else로 스택 교체 — 자식이 새로 만들어져 이동 대신 페이드
struct ToggleBad: View {
    @State private var isVertical = false
    var body: some View {
        VStack {
            if isVertical {
                VStack { Avatars() }   // 이 가지의 Avatars와
            } else {
                HStack { Avatars() }   // 이 가지의 Avatars는 서로 다른 뷰
            }
            Button("전환") { withAnimation { isVertical.toggle() } }
        }
    }
}

struct Avatars: View {
    var body: some View {
        Circle().fill(.red).frame(width: 44, height: 44)
        Circle().fill(.green).frame(width: 44, height: 44)
        Circle().fill(.blue).frame(width: 44, height: 44)
    }
}
✅ AnyLayout — 같은 자식, 배치 규칙만 교체
struct ToggleGood: View {
    @State private var isVertical = false
    var body: some View {
        let layout = isVertical ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
        VStack {
            layout { Avatars() }       // 구조가 항상 같다 → identity 유지
            Button("전환") { withAnimation { isVertical.toggle() } }
        }
    }
}
🔑 HStack이 아니라 HStackLayout

AnyLayout에는 Layout 프로토콜을 따르는 타입만 들어간다. 내장 스택의 Layout 버전이 HStackLayout·VStackLayout·ZStackLayout·GridLayout이다. 조건부 레이아웃이 필요 없으면 문서도 그냥 HStack/Grid를 쓰라고 한다.

문서 예제 — Dynamic Type에 따라 가로/세로 전환
struct DynamicTypeLayout: View {
    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
    var body: some View {
        let layout = dynamicTypeSize <= .large
            ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())
        layout {
            Text("First label")
            Text("Second label")
        }
    }
}
요구UIKit에서 하던 일SwiftUI
넓으면 가로, 좁으면 세로traitCollectionDidChange에서 stackView.axis 변경ViewThatFits (자동 측정) 또는 AnyLayout (조건 직접)
전환 시 뷰 상태 유지같은 서브뷰를 재배치AnyLayout
전환 시 다른 UI를 보여 줌서브뷰 hidden 토글ViewThatFits의 후보별 다른 뷰
♿ 접근성 한 줄

큰 Dynamic Type에서 버튼 줄이 넘치면 ViewThatFits로 세로 후보를 준비해 둔다. 글자를 줄이는(minimumScaleFactor) 것보다 배치를 바꾸는 편이 읽기 쉽다 — 세션의 투표 버튼이 이 방식이다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
Grid는 열 폭을 셀 내용에 맞추는데 LazyVGrid는 왜 GridItem으로 미리 열을 정해야 하나?
Grid는 자식을 한 번에 전부 만들어 모든 셀을 잴 수 있다. LazyVGrid는 보이는 셀만 만들기 때문에 아직 안 만든 셀의 폭을 알 수 없다. 그래서 한 축(열)의 정보를 초기화 시점에 받아야 한다(WWDC22 세션).
쉽게 말하면 반 전체 키를 다 재고 줄을 세우는 것(Grid)과, 교실에 들어오는 순서대로 앉혀야 해서 책상 크기를 미리 정해 두는 것(Lazy)의 차이다.
가로/세로 전환에 if/else 대신 AnyLayout을 쓰면 무엇이 달라지나?
if/else는 두 가지가 서로 다른 structural identity라 전환 시 자식이 삭제·삽입된다(상태 초기화, 페이드 전환). AnyLayout은 뷰 계층 구조가 항상 같아 자식의 identity와 상태가 유지되고, 위치만 바뀌어 이동 애니메이션이 된다.
쉽게 말하면 if/else는 학생을 다른 반 학생으로 바꿔 앉히는 것이고, AnyLayout은 같은 학생들의 자리 배치만 바꾸는 것이다.
코드 예측용 — 폭 320에 adaptive 열
struct AdaptiveQuiz: View {
    let columns = [GridItem(.adaptive(minimum: 100), spacing: 8)]
    var body: some View {
        LazyVGrid(columns: columns, spacing: 8) {
            ForEach(0..<9, id: \.self) { i in
                Color.blue.frame(height: 40).overlay(Text("\(i)"))
            }
        }
        .frame(width: 320)
    }
}
🔮 코드 예측: 위 그리드는 한 줄에 몇 칸이고, 한 칸의 폭은 대략 얼마인가?
3칸, 칸당 약 101이다. adaptive는 "최소 100짜리 칸이 spacing 8을 끼고 몇 개 들어가나"를 센다. 3칸은 100×3 + 8×2 = 316 ≤ 320이라 들어가고, 4칸은 424라 안 들어간다. 남는 폭은 칸들이 나눠 가져 (320 − 16) ÷ 3 ≈ 101이 된다. 위 도해의 폭 380 → 칸당 121과 같은 계산이다.
쉽게 말하면 "한 사람당 최소 100cm" 벤치에 몇 명 앉을 수 있는지 세고, 남는 자리는 앉은 사람들이 똑같이 나눠 쓴다.
🔁 지난 Day 복습: GeometryReader를 VStack 가운데 넣으면 왜 아래 뷰가 밀려나나?
GeometryReader는 "flexible preferred size"를 반환하는 뷰라 VStack이 제안한 남은 공간을 전부 차지한다. 게다가 자식을 좌상단에 놓는다. 그래서 아래 형제는 맨 끝으로 밀린다. 크기는 바깥에서 frame으로 정해 주고, 비율·형제 측정은 오늘 배운 도구로 대체한다.
쉽게 말하면 "남는 자리 다 줘"라고 말하는 친구가 가운데 앉으면, 옆 친구들은 구석으로 밀린다.
➡️ 2부 예고

기성품으로 안 되는 배치는 Layout 프로토콜로 직접 만든다. 2부는 커스텀 Layout·커스텀 정렬·containerRelativeFrame(흔한 오해 포함)·safeAreaInset·PreferenceKey를 다룬다.

출처 · 참고자료