.frame(width: 100)을 걸었는데 안 먹는 것처럼 보이는 순간들이 전부 이 한 문장에서 나온다.
1. 3단계 협상 — 제안 · 결정 · 배치
Apple ProposedViewSize 문서의 첫 문장이 규칙 전체다.
"During layout in SwiftUI, views choose their own size, but they do that in response to a size proposal from their parent view."
풀어 쓰면 매 레이아웃은 세 단계다.
부모는 제안하고, 자식이 정하고, 부모가 놓는다. 부모에게는 "강제" 수단이 없다.
2. Auto Layout과 방향이 다르다
Auto Layout에서 제약은 양방향이었다. 자식의 intrinsic content size가 부모 크기를 밀어내고, 부모의 제약이 자식을 압축했다. 우선순위(priority)로 그 싸움을 중재했다.
Auto Layout은 줄다리기다. 부모와 자식이 양쪽에서 당기고, 우선순위(힘)가 센 쪽이 이긴다. 둘 다 세면 줄이 끊어진다(제약 충돌).
SwiftUI는 옷 가게다. 점원(부모)이 "M 사이즈 어때요?"라고 권하고, 손님(자식)이 "저는 L 입을게요"라고 정한다. 점원은 손님이 고른 옷을 어디에 걸지만 정한다. 실랑이가 없다.
3. .frame이 안 먹는 것처럼 보이는 이유
UIKit에서 view.frame.size.width = 100은 명령이었다. SwiftUI의 .frame(width: 100)은 명령이 아니다. 1챕터에서 배운 대로 이것도 감싸는 뷰이고, 하는 일은 "자식에게 폭 100을 제안하고, 자기 자신은 폭 100이 되는 것"이다.
Text("아주 아주 아주 긴 제목입니다")
.frame(width: 100)
// frame 뷰: 폭 100
// Text: 100을 제안받아 줄바꿈 후 필요한 높이를 스스로 결정
// 한 단어가 100보다 넓으면 → 잘리거나 넘친다 (frame이 강제로 못 줄인다).frame인데 결과가 다르다. "frame이 안 먹는다"가 아니라, frame은 원래 제안만 한다.같은 .frame을 네 종류 뷰에 걸어 보면 차이가 바로 보인다. 그대로 붙여 실행할 수 있는 코드다.
VStack(spacing: 16) {
// ① flexible 뷰: 제안을 그대로 수용 → 정확히 150×60
Color.blue
.frame(width: 150, height: 60)
// ② 내용 기반 뷰: 자기 크기를 고른다 → frame 영역 안에서 가운데 정렬될 뿐
Text("짧음")
.frame(width: 150, height: 60)
.border(Color.red) // 빨간 테두리(frame 영역)와 글자 크기가 다르다
// ③ Image 기본값: 제안을 무시하고 원본 픽셀 크기
Image("logo")
.frame(width: 150, height: 60)
.border(Color.green) // 이미지가 테두리를 넘거나 못 채운다
// ④ .resizable() 을 붙이면 ③이 flexible 부류로 바뀐다
Image("logo")
.resizable()
.frame(width: 150, height: 60)
}어디까지가 어느 뷰인지 모르겠으면 .border()를 임시로 둘러라. "내가 준 크기"와 "뷰가 고른 크기"가 다른 지점이 눈에 보인다. Auto Layout 디버거 대신 쓰는 도구다.
VStack {
Text("제목").border(Color.red)
Text("설명").border(Color.blue)
}
.border(Color.green) // 스택 자신이 차지한 크기어떤 뷰가 제안을 받아들이고 어떤 뷰가 자기 고집을 부리는지 알면 레이아웃 디버깅이 끝난다.
| 뷰 | 제안에 대한 반응 | 실무 메모 |
|---|---|---|
Color · Rectangle · Spacer | 제안을 전부 수용 (flexible) | 제안한 만큼 정확히 채운다 |
Text | 제안 폭 안에서 줄바꿈 → 필요한 크기 선택 | 단어가 안 들어가면 잘린다 |
Image (기본) | 제안 무시 — 원본 픽셀 크기 | .resizable() 붙이면 flexible로 바뀜 |
VStack · HStack | 자식들에게 나눠 제안하고 합을 응답 | 자식이 정한 크기의 합이 스택 크기 |
.frame(width:) | 그 값을 자식에게 제안, 자신은 그 크기 | 강제가 아니다 |
.frame(maxWidth: .infinity) | 가능한 만큼 넓게 제안받아 확장 | "꽉 채우기"의 표준 관용구 |
.fixedSize() | 자식에게 unspecified 제안 → ideal 크기 | "자르지 말고 제 크기대로" 할 때 |
.frame으로 억지로 늘리는 건 답이 아니다. 아래 순서로 가면 대개 ①에서 끝난다.
HStack {
Text(longTitle) // 여기가 잘린다
Spacer()
Text("12,000원")
}HStack {
Text(longTitle)
.frame(maxWidth: .infinity, alignment: .leading) // 남는 폭을 받아 간다
Text("12,000원")
.layoutPriority(1) // 가격은 줄지 않게
}Text(longTitle)
.fixedSize(horizontal: false, vertical: true)
// 가로: 부모 제안을 받아들여 줄바꿈 / 세로: 필요한 만큼 스스로 결정Text(longTitle)
.lineLimit(1)
.minimumScaleFactor(0.8) // 최대 20%까지 축소 허용, 그 이상은 "…"4. 부모는 어떻게 자식을 "측정"하나 — 특수 제안값
HStack이 자식 3개에게 공간을 나눠 주려면, 각 자식이 얼마나 유연한지 먼저 알아야 한다. 그 방법이 여러 크기를 제안해 보고 답을 보는 것이다. Apple 문서:
"Layout containers typically measure their subviews by proposing several sizes and looking at the responses."
· "The zero proposal; the view responds with its minimum size."
· "The infinity proposal; the view responds with its maximum size."
· "The unspecified proposal; the view responds with its ideal size."
GeometryReader 주의사항의 배경이다.내장 컨테이너로 안 되는 배치는 Layout 프로토콜(iOS 16+)로 직접 만든다. 필요한 메서드는 두 개뿐이고, 그 두 개가 정확히 위 3단계다. 한 번 읽어 보면 협상 구조가 확실히 박힌다.
struct EqualSpacedVStack: Layout {
var spacing: CGFloat = 8
// 부모에게 "나는 이 크기다"라고 응답하는 자리
func sizeThatFits(proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()) -> CGSize {
// ② 자식들에게 물어본다 — 크기는 자식이 정한다
let sizes = subviews.map { $0.sizeThatFits(proposal) }
let width = sizes.map(\.width).max() ?? 0
let height = sizes.map(\.height).reduce(0, +)
+ spacing * CGFloat(max(0, subviews.count - 1))
return CGSize(width: width, height: height)
}
// ③ 위치만 정한다 — 자식이 고른 크기는 바꿀 수 없다
func placeSubviews(in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()) {
var y = bounds.minY
for subview in subviews {
subview.place(at: CGPoint(x: bounds.midX, y: y),
anchor: .top,
proposal: proposal)
y += subview.sizeThatFits(proposal).height + spacing
}
}
}
// 내장 컨테이너와 똑같이 쓴다
EqualSpacedVStack(spacing: 16) {
Text("첫째")
Text("둘째")
Text("셋째")
}자식을 직접 만질 수는 없다 — Subviews(프록시 모음)를 통해 측정(sizeThatFits)하고 배치(place)만 한다. 부모가 자식 크기를 강제할 수단이 API 수준에서 아예 없다는 걸 여기서 확인할 수 있다.
5. 컨테이너 고르기
Apple Picking container views for your content가 lazy 스택에 대해 명확한 지침을 준다.
"Stack views load their child views all at once, making layout fast and reliable, because the system knows the size and shape of every subview as it loads them. Lazy stacks trade some degree of layout correctness for performance, because the system only calculates the geometry for subviews as they become visible."
"Always start with a standard stack view and only switch to a lazy stack if profiling your code shows a worthwhile performance improvement."
UITableView에 익숙하면 "많은 항목 = 무조건 lazy"라고 생각하게 된다. Apple은 반대로 말한다 — 기본 스택으로 시작하고, 프로파일링이 이득을 보여줄 때만 lazy로 간다. lazy는 무료가 아니라 레이아웃 정확성을 성능과 바꾸는 거래다.
| 컨테이너 | 쓰는 곳 | UIKit 대응 | 주의 |
|---|---|---|---|
VStack/HStack/ZStack | 기본 배치. 항목 수가 적당할 때 | UIStackView | 여기서 시작한다 |
LazyVStack/LazyHStack | 스크롤되는 긴 목록 (커스텀 셀 레이아웃) | — | 정확성 ↔ 성능 거래. 프로파일링 후 |
List | 표준 목록 UI · 스와이프 삭제 · 구분선 | UITableView + DataSource | 행은 항상 lazy · ScrollView 불필요 |
Grid/LazyVGrid | 2차원 격자 · 이미지 갤러리 | UICollectionView | 스크롤 내장 안 됨 → ScrollView로 감싸기 |
Form | 설정 · 입력 화면 | 정적 UITableView | 플랫폼별 렌더가 크게 다름 |
ScrollView | 위 컨테이너에 스크롤을 부여 | UIScrollView | List는 감쌀 필요 없다 |
표만 보면 잘 안 붙으니 최소 예제로 한 번에 훑는다.
VStack(alignment: .leading, spacing: 8) {
Text("제목").font(.headline)
Text("설명").foregroundStyle(.secondary)
}List(todos) { todo in
Text(todo.title)
}
// 스와이프 삭제 · 이동 같은 표준 인터랙션은 ForEach에 붙인다
List {
ForEach(todos) { todo in
Text(todo.title)
}
.onDelete { offsets in todos.remove(atOffsets: offsets) }
.onMove { from, to in todos.move(fromOffsets: from, toOffset: to) }
}ScrollView {
LazyVStack(spacing: 12) {
ForEach(cards) { card in
CardView(card: card)
}
}
.padding()
}ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 12) {
ForEach(photos) { photo in
PhotoCell(photo: photo)
}
}
.padding()
}Form {
Section("알림") {
Toggle("푸시 받기", isOn: $pushEnabled)
Picker("빈도", selection: $frequency) {
ForEach(Frequency.allCases) { f in
Text(f.label).tag(f)
}
}
}
Section {
Button("로그아웃", role: .destructive) { logout() }
}
}// 버튼을 가로로 꽉 채우기 — 이 순서를 지켜야 탭 영역까지 넓어진다
Button("저장") { save() }
.frame(maxWidth: .infinity) // 먼저 넓히고
.padding(.vertical, 14) // 그다음 여백
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
// 균등 분할 — 둘 다 최대로 확장하면 자연히 반반이 된다
HStack(spacing: 0) {
Color.blue.frame(maxWidth: .infinity)
Color.green.frame(maxWidth: .infinity)
}
// 40 : 60 처럼 정확한 비율 (iOS 17+) — GeometryReader가 필요 없다
HStack(spacing: 0) {
Color.blue.containerRelativeFrame(.horizontal) { w, _ in w * 0.4 }
Color.green.frame(maxWidth: .infinity)
}Apple: "the layout of controls inside a Form may differ significantly based on the platform. For example, a Picker in a Form on iOS adds navigation, showing the picker's choices on a separate screen, while the same Picker on macOS displays a pop-up button." iOS만 보고 만든 Form이 macOS·visionOS에서 다르게 보이는 건 버그가 아니다.
6. GeometryReader — 필요할 때만
"부모 크기를 알아야 하는" 상황에서 GeometryReader를 쓴다. 문제는 그게 3단계 협상에 끼어드는 방식이라는 점이다.
Apple GeometryReader: "This view returns a flexible preferred size to its parent layout." → 앞 절 표의 flexible 부류다. 즉 제안받은 공간을 그대로 받아들인다.
fatbobman이 더 명시적으로 정리한다: "GeometryReader will occupy all the space provided by the parent view", 그리고 "It places the child view's origin (0,0) at the origin of the GeometryReader" — 즉 자식은 좌상단에 놓인다.
이 둘을 합치면 왜 VStack 안에 넣었을 때 레이아웃이 무너지는 것처럼 보이는지 설명된다 — 남은 공간을 전부 먹고, 자식은 그 큰 영역의 좌상단에 붙는다.
VStack {
Text("제목")
GeometryReader { geo in // 남은 공간을 전부 먹는다
Bar().frame(width: geo.size.width * 0.4)
}
Text("설명") // 밀려난다
}// 방법 1 — Spacer 비율로 나눈다
HStack(spacing: 0) {
Bar()
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
// 방법 2 — containerRelativeFrame (iOS 17+)
Bar().containerRelativeFrame(.horizontal) { width, _ in width * 0.4 }fatbobman이 지적하는 핵심은 레이아웃 사이클이 여러 번 돈다는 것이다 — 일단 배치해 보고(1차 평가), 그 값을 읽어서(데이터 획득), 그걸로 다시 배치한다(재평가). 이걸 뷰 수십 개에 걸어 커스텀 레이아웃을 흉내내면 성능이 크게 나빠진다.
그래서 iOS 16의 Layout 프로토콜과 LazyVGrid 같은 대안이 나온 것이다 — 이들은 렌더 후 콜백이 아니라 레이아웃 단계에서 크기 정보를 얻는다. GeometryReader는 금기가 아니지만 "다른 방법이 없을 때" 도구다.
레이아웃이 예상과 다를 때 순서대로 물어라. ① 이 뷰는 제안을 수용하는 부류인가, 자기 크기를 고집하는 부류인가? ② 부모가 충분한 크기를 제안하고 있나? ③ 내가 건 모디파이어가 어느 껍질에 붙었나(1챕터의 감싸기 순서)? 대부분 이 셋 안에서 끝난다.
이 챕터 요약
- 부모가 제안 → 자식이 결정 → 부모가 배치. 부모는 자식의 크기를 강제할 수 없다.
- Auto Layout은 양방향 줄다리기, SwiftUI는 단방향 협상. 그래서 제약 충돌이라는 개념이 없다.
.frame은 명령이 아니라 감싸는 뷰 + 제안이다.Color는 수용하고Text는 자기 크기를 고른다.- 부모는
zero/infinity/unspecified를 제안해 최소·최대·ideal을 알아낸 뒤 배분한다. - lazy 스택은 기본이 아니다 — 일반 스택으로 시작하고 프로파일링 후에 바꾼다.
List는 이미 lazy다. GeometryReader는 공간을 전부 차지한다. 비율 레이아웃은Spacer·containerRelativeFrame이 먼저다.