← SwiftUI 로드맵 4 / 5

04 · 레이아웃 — 3단계 협상

부모가 제안하고 자식이 결정하고 부모가 배치한다. Auto Layout과 방향이 반대인 이유, .frame이 강제가 아닌 이유, 컨테이너 선택 기준, GeometryReader를 아끼는 이유.
진행률
0 / 0 완료
Auto Layout에서 우리는 "제약을 걸어 크기를 강제"했다. SwiftUI는 반대다 — 부모는 제안할 뿐이고, 크기는 자식이 정한다. .frame(width: 100)을 걸었는데 안 먹는 것처럼 보이는 순간들이 전부 이 한 문장에서 나온다.

1. 3단계 협상 — 제안 · 결정 · 배치

Apple ProposedViewSize 문서의 첫 문장이 규칙 전체다.

📝 Apple 원문

"During layout in SwiftUI, views choose their own size, but they do that in response to a size proposal from their parent view."

풀어 쓰면 매 레이아웃은 세 단계다.

부모 (VStack 등) 자식 (Text 등) ① "이만큼 쓸래?" (제안) ProposedViewSize — 강제가 아니다 ② "난 이 크기로 할게" (결정) 자식이 최종 결정권을 갖는다 ③ "그럼 여기 놓을게" (배치) 위치만 정한다 — 크기는 못 바꾼다 부모는 자식의 선택을 존중한다 — 늘이거나 찌그러뜨릴 수 없다
이 세 단계가 계층 전체에서 재귀적으로 일어난다. 화살표 ②의 방향이 Auto Layout과의 결정적 차이다.
🔑 한 문장으로

부모는 제안하고, 자식이 정하고, 부모가 놓는다. 부모에게는 "강제" 수단이 없다.

2. Auto Layout과 방향이 다르다

Auto Layout에서 제약은 양방향이었다. 자식의 intrinsic content size가 부모 크기를 밀어내고, 부모의 제약이 자식을 압축했다. 우선순위(priority)로 그 싸움을 중재했다.

Auto Layout — 양방향 제약 SwiftUI — 단방향 협상 부모 뷰 자식 뷰 압축 밀기 서로 크기에 영향 → 우선순위로 중재 충돌하면 제약이 깨지고 콘솔에 경고 부모 뷰 자식 뷰 제안 결정 역할이 나뉜다 → 충돌 자체가 없다 깨질 제약이 없으니 레이아웃 경고도 없다
SwiftUI에 "제약 충돌 경고"가 없는 건 로그를 숨긴 게 아니라, 충돌할 구조가 아니기 때문이다.
🟢 쉽게 이해하기

Auto Layout은 줄다리기다. 부모와 자식이 양쪽에서 당기고, 우선순위(힘)가 센 쪽이 이긴다. 둘 다 세면 줄이 끊어진다(제약 충돌).

SwiftUI는 옷 가게다. 점원(부모)이 "M 사이즈 어때요?"라고 권하고, 손님(자식)이 "저는 L 입을게요"라고 정한다. 점원은 손님이 고른 옷을 어디에 걸지만 정한다. 실랑이가 없다.

3. .frame이 안 먹는 것처럼 보이는 이유

UIKit에서 view.frame.size.width = 100은 명령이었다. SwiftUI의 .frame(width: 100)명령이 아니다. 1챕터에서 배운 대로 이것도 감싸는 뷰이고, 하는 일은 "자식에게 폭 100을 제안하고, 자기 자신은 폭 100이 되는 것"이다.

텍스트가 잘리는 이유 — frame은 Text를 줄이지 못한다
Text("아주 아주 아주 긴 제목입니다")
    .frame(width: 100)
// frame 뷰: 폭 100
// Text: 100을 제안받아 줄바꿈 후 필요한 높이를 스스로 결정
//       한 단어가 100보다 넓으면 → 잘리거나 넘친다 (frame이 강제로 못 줄인다)
Color — 제안을 그대로 수용 Text — 자기 크기를 스스로 결정 Color.blue 150 × 90 그대로 .frame(width:150, height:90) flexible 뷰 → 제안 = 결과 Rectangle · Spacer 도 같은 부류 긴 제목입니다 frame 영역 .frame(width:150, height:90) 내용 기반 뷰 → 자기 크기 유지 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)
}
🔑 레이아웃 디버깅 1번 도구

어디까지가 어느 뷰인지 모르겠으면 .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 문서:

📝 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."

부모 HStack 등 .zero 최소 크기는? → 20pt .infinity 최대 크기는? → 무제한 unspecified 이상적 크기는? → 86pt 세 답을 모아 "누가 얼마나 양보할 수 있는지" 판단하고 최종 공간을 배분한다
레이아웃이 한 번에 끝나지 않고 여러 번 "물어보는" 과정이라는 점이, 다음 절 GeometryReader 주의사항의 배경이다.
🟣 한 걸음 더 — 3단계를 직접 구현해 보기 롱테일

내장 컨테이너로 안 되는 배치는 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 스택에 대해 명확한 지침을 준다.

📝 Apple 원문

"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."

🚨 UIKit 감각의 함정

UITableView에 익숙하면 "많은 항목 = 무조건 lazy"라고 생각하게 된다. Apple은 반대로 말한다 — 기본 스택으로 시작하고, 프로파일링이 이득을 보여줄 때만 lazy로 간다. lazy는 무료가 아니라 레이아웃 정확성을 성능과 바꾸는 거래다.

컨테이너쓰는 곳UIKit 대응주의
VStack/HStack/ZStack기본 배치. 항목 수가 적당할 때UIStackView여기서 시작한다
LazyVStack/LazyHStack스크롤되는 긴 목록 (커스텀 셀 레이아웃)정확성 ↔ 성능 거래. 프로파일링 후
List표준 목록 UI · 스와이프 삭제 · 구분선UITableView + DataSource행은 항상 lazy · ScrollView 불필요
Grid/LazyVGrid2차원 격자 · 이미지 갤러리UICollectionView스크롤 내장 안 됨ScrollView로 감싸기
Form설정 · 입력 화면정적 UITableView플랫폼별 렌더가 크게 다름
ScrollView위 컨테이너에 스크롤을 부여UIScrollViewList는 감쌀 필요 없다

표만 보면 잘 안 붙으니 최소 예제로 한 번에 훑는다.

VStack — 항목이 적고 전부 한 번에 보여도 될 때
VStack(alignment: .leading, spacing: 8) {
    Text("제목").font(.headline)
    Text("설명").foregroundStyle(.secondary)
}
List — 표준 목록. 스크롤 내장, 행은 이미 lazy
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 — 커스텀 행 레이아웃이 필요하고, 프로파일링으로 이득을 확인했을 때만
ScrollView {
    LazyVStack(spacing: 12) {
        ForEach(cards) { card in
            CardView(card: card)
        }
    }
    .padding()
}
LazyVGrid — 스크롤 뷰포트가 없으니 ScrollView로 감싼다
ScrollView {
    LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 12) {
        ForEach(photos) { photo in
            PhotoCell(photo: photo)
        }
    }
    .padding()
}
Form — 설정·입력 화면
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)
}
⚠️ Form은 플랫폼마다 다르게 그려진다

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 안에 넣었을 때 레이아웃이 무너지는 것처럼 보이는지 설명된다 — 남은 공간을 전부 먹고, 자식은 그 큰 영역의 좌상단에 붙는다.

❌ 부모 폭의 40%를 만들려고 GeometryReader — 주변 레이아웃이 깨진다
VStack {
    Text("제목")
    GeometryReader { geo in                    // 남은 공간을 전부 먹는다
        Bar().frame(width: geo.size.width * 0.4)
    }
    Text("설명")                                // 밀려난다
}
✅ 비율은 대개 GeometryReader 없이 표현된다
// 방법 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챕터의 감싸기 순서)? 대부분 이 셋 안에서 끝난다.

이 챕터 요약

출처 · 참고자료