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."
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(_:)로 "이 셀에는 추가 크기를 주지 마"라고 말하라고 안내한다.
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 배열을 넘긴다.
adaptive는 폭에 따라 칸 수가 바뀐다 — 아래 PhotoGrid(최소 100, 간격 8, 좌우 여백 16)라면 iPhone 세로(가용 폭 약 358)는 3열, 가로는 6열 안팎, iPad는 그보다 많아진다. 코드는 한 줄 그대로다.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()
}
}
}@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 등록, 스냅샷 적용이 별도로 필요하다Grid | LazyVGrid | UIKit | |
|---|---|---|---|
| 자식 생성 | 한 번에 전부 | 보일 때만 | UICollectionView 셀 재사용 |
| 열 폭 결정 | 셀 내용으로 자동 | 미리 GridItem으로 지정 | Compositional Layout 크기 지정 |
| 열별 정렬 | gridColumnAlignment | GridItem(alignment:) | 셀 안 제약으로 직접 |
| 셀 병합 | gridCellColumns | 없음 | 커스텀 레이아웃 필요 |
| 잘 맞는 곳 | 순위표, 영수증, 설정 요약 | 사진첩, 상품 목록 | — |
"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로 한 축만 볼 수 있다.
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는 _ConditionalContent의 서로 다른 두 가지, AnyLayout은 한 자리의 한 뷰다.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)
}
}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() } }
}
}
}AnyLayout에는 Layout 프로토콜을 따르는 타입만 들어간다. 내장 스택의 Layout 버전이 HStackLayout·VStackLayout·ZStackLayout·GridLayout이다. 조건부 레이아웃이 필요 없으면 문서도 그냥 HStack/Grid를 쓰라고 한다.
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 세션).가로/세로 전환에 if/else 대신 AnyLayout을 쓰면 무엇이 달라지나?
if/else는 두 가지가 서로 다른 structural identity라 전환 시 자식이 삭제·삽입된다(상태 초기화, 페이드 전환). AnyLayout은 뷰 계층 구조가 항상 같아 자식의 identity와 상태가 유지되고, 위치만 바뀌어 이동 애니메이션이 된다.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)
}
}🔮 코드 예측: 위 그리드는 한 줄에 몇 칸이고, 한 칸의 폭은 대략 얼마인가?
🔁 지난 Day 복습: GeometryReader를 VStack 가운데 넣으면 왜 아래 뷰가 밀려나나?
GeometryReader는 "flexible preferred size"를 반환하는 뷰라 VStack이 제안한 남은 공간을 전부 차지한다. 게다가 자식을 좌상단에 놓는다. 그래서 아래 형제는 맨 끝으로 밀린다. 크기는 바깥에서 frame으로 정해 주고, 비율·형제 측정은 오늘 배운 도구로 대체한다.기성품으로 안 되는 배치는 Layout 프로토콜로 직접 만든다. 2부는 커스텀 Layout·커스텀 정렬·containerRelativeFrame(흔한 오해 포함)·safeAreaInset·PreferenceKey를 다룬다.