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

Day 7 · 1부 · 3단계 협상과 제안값

제안·결정·배치 3단계와 ProposedViewSize의 특수값, frame·fixedSize가 제안을 바꾸는 방식을 Auto Layout과 비교해 설명한다.
진행률
0 / 0 완료
Auto Layout에서는 제약을 걸어 크기를 강제했다. SwiftUI는 방향이 반대다 — 부모는 크기를 제안만 하고, 최종 크기는 자식이 고른다. .frame(width: 100)이 "안 먹는" 것처럼 보이는 순간, 배경색이 글자만큼만 칠해지는 순간, GeometryReader가 화면을 다 먹어 버리는 순간이 전부 이 규칙 하나에서 나온다. 제안 → 결정 → 배치. 1부는 이 협상 규칙 자체와, 제안값을 바꾸는 두 도구 frame·fixedSize를 다룬다.

1부 목표 · 18분

  • .padding().background() 한 줄을 보고 제안·응답이 어떤 순서로 오가는지 그림으로 그린다
  • Auto Layout 개념을 SwiftUI 쪽 짝과 맞춘다 — intrinsic content size ↔ ideal size, hugging/compression ↔ fixedSize/layoutPriority
  • 뷰를 "제안을 수용하는 뷰 / 자기 크기를 고집하는 뷰"로 분류하고, frame(width:)·frame(maxWidth:)·fixedSize() 중 맞는 도구를 고른다

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

SwiftUI 레이아웃은 부모와 자식 사이의 짧은 대화가 계층 전체에서 재귀적으로 반복되는 것이다. WWDC19 · Building Custom Views with SwiftUI는 이 대화를 세 단계로 설명한다.

부모 (VStack 등) 자식 (Text 등) ① "이만큼 쓸래?" (제안) ProposedViewSize — 강제가 아니다 ② "난 이 크기로 할게" (결정) 자식이 최종 결정권을 갖는다 ③ "그럼 여기 놓을게" (배치) 위치만 정한다 — 크기는 못 바꾼다 부모는 자식의 선택을 존중한다 — 늘이거나 찌그러뜨릴 수 없다
화살표 ②의 방향이 Auto Layout과의 결정적 차이다. 크기를 정하는 쪽은 언제나 자식이다.
📝 Apple 원문

"In SwiftUI there's no way to force a size on your child, the parent has to respect that choice." — WWDC19 · Building Custom Views with SwiftUI
→ SwiftUI에는 자식에게 크기를 강제할 방법이 없다. 부모는 자식이 고른 크기를 그대로 받아들여야 한다.

한 줄짜리 코드로 협상 따라가기

세션이 쓰는 예제를 그대로 가져온다. 모디파이어 하나하나가 감싸는 뷰(Day 1)라는 점을 떠올리면, 이 코드는 뷰 3겹이다: background → padding → Text.

SwiftUI — 모디파이어 3겹이 크기를 주고받는다
struct ToastView: View {
    var body: some View {
        Text("Avocado Toast")
            .padding(10)            // 자식에게 10씩 덜 제안, 자기는 10씩 더 크게
            .background(Color.green) // 레이아웃 중립: 제안을 그대로 전달
    }
}
루트 (safe area) .background .padding(10) Text 제안 390×800 제안 390×800 (그대로) 제안 370×780 (−10씩) 응답 120×20 응답 140×40 (+10씩) 응답 140×40 Color.green 140×40 제안 → 그대로 수용 ③ 받은 140×40을 가운데에 배치
제안(파랑)은 내려가며 줄어들고, 응답(주황)은 올라오며 커진다. background는 자기 크기가 정해진 뒤에 그 크기를 Color에 제안한다 — 그래서 배경이 글자 크기에 딱 맞는다.

Auto Layout과 방향이 다르다

Auto Layout의 제약은 양방향이었다. intrinsic content size가 부모를 밀어내고, 부모의 제약이 자식을 압축하고, priority가 그 싸움을 중재했다. SwiftUI는 역할이 나뉘어 있어 싸움 자체가 없다.

Auto Layout — 양방향 제약 SwiftUI — 단방향 협상 부모 뷰 자식 뷰 압축 밀기 서로 크기에 영향 → 우선순위로 중재 충돌하면 제약이 깨지고 콘솔에 경고 부모 뷰 자식 뷰 제안 결정 역할이 나뉜다 → 충돌 자체가 없다 깨질 제약이 없으니 레이아웃 경고도 없다
SwiftUI에 "Unable to simultaneously satisfy constraints"가 없는 건 로그를 숨겨서가 아니라, 충돌할 구조가 아니기 때문이다.
UIKit — 제약으로 폭을 "강제"한다 (텍스트가 길면 잘리거나 충돌 경고)
final class LabelVC: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let label = UILabel()
        label.text = "Avocado Toast"
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        NSLayoutConstraint.activate([
            label.widthAnchor.constraint(equalToConstant: 100), // 명령
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
}
상황UIKit (Auto Layout)SwiftUI
크기 결정권제약 시스템 전체가 푼다각 뷰가 스스로 고른다
콘텐츠 크기intrinsicContentSizeideal size (nil 제안에 대한 응답)
늘어나지 않게 / 줄지 않게hugging / compression resistance priorityfixedSize(), layoutPriority(_:)
제약 부족·과잉ambiguous / unsatisfiable 경고개념이 없다 — 항상 결과가 정의된다
좌우 반전(RTL)leading/trailing 앵커자동 — 좌표를 프레임워크가 뒤집는다
📝 Apple 원문

"There are no underconstrained or overconstrained systems in SwiftUI which means everything you can express has a well-defined effect." — WWDC19 · Building Custom Views with SwiftUI
→ SwiftUI에는 제약이 모자라거나 넘치는 상태가 없다. 쓴 코드는 무엇이든 정해진 결과를 낸다. "틀린 레이아웃"은 없고 "원하지 않은 결과"만 있다.

2. 제안값과 뷰의 성격 — frame · fixedSize 🔥 핵심

ProposedViewSize — 제안에는 특별한 값 세 개가 있다

제안은 ProposedViewSize다. 폭·높이가 각각 CGFloat?이고, 부모는 특수값을 던져 자식의 유연성을 측정한다.

📝 Apple 원문

"Layout containers typically measure their subviews by proposing several sizes and looking at the responses." — Apple 문서 · ProposedViewSize
→ 컨테이너는 여러 크기를 제안해 보고 그 답을 모아 공간을 나눈다. 레이아웃은 "한 번 묻고 끝"이 아니다.

부모 HStack 등 .zero 최소 크기는? → minimum .infinity 최대 크기는? → maximum .unspecified (nil) 이상적 크기는? → ideal 세 답을 모아 "누가 얼마나 양보할 수 있는지" 판단하고 최종 공간을 배분한다
Apple 문서 표현 그대로: zero → minimum, infinity → maximum, unspecified → ideal. nil은 "제약 없이 네가 원하는 만큼"이라는 뜻이다.

같은 제안, 다른 반응 — 뷰의 세 부류

레이아웃 디버깅의 절반은 "이 뷰가 제안을 받아들이는 부류인가"를 아는 것이다. 아래 코드를 Preview에 붙이면 차이가 바로 보인다.

같은 .frame(150×60)에 대한 네 가지 반응
struct ProposalDemo: View {
    var body: some View {
        VStack(spacing: 16) {
            // ① flexible: 제안을 그대로 수용 → 정확히 150×60
            Color.blue
                .frame(width: 150, height: 60)
            // ② 내용 기반: 자기 크기를 고른다 → frame 안에서 가운데 정렬될 뿐
            Text("짧음")
                .frame(width: 150, height: 60)
                .border(Color.red)
            // ③ 이미지 기본값: 제안을 무시하고 원래 크기
            Image(systemName: "star.fill")
                .frame(width: 150, height: 60)
                .border(Color.green)
            // ④ resizable()을 붙이면 flexible 부류로 바뀐다
            Image(systemName: "star.fill")
                .resizable()
                .scaledToFit()
                .frame(width: 150, height: 60)
                .border(Color.orange)
        }
    }
}
뷰제안에 대한 반응실무 메모
Color · Rectangle 등 Shape · Spacer제안을 전부 수용 (flexible)제안한 만큼 정확히 채운다
Text제안 폭 안에서 줄바꿈 → 필요한 크기 선택"텍스트 범위는 표시된 줄의 폭·높이를 넘지 않는다" (WWDC19)
Image (기본)제안 무시 — 고정 크기.resizable()을 붙여야 flexible
.frame(width:height:)그 값을 자식에게 제안, 자신은 그 크기강제가 아니다 — 자식은 여전히 자기 크기
.frame(maxWidth: .infinity)받은 제안 안에서 최대로 넓어짐"가로로 꽉 채우기" 표준 관용구
.fixedSize()자식에게 nil 제안 → ideal 크기 고정"자르지 말고 제 크기대로"
📝 Apple 원문

"The frame is not a constraint in SwiftUI, it's just a view which you can think of like a picture frame." — WWDC19 · Building Custom Views with SwiftUI
→ frame은 제약이 아니라 액자 같은 뷰다. 액자는 그림(자식)에게 크기를 제안할 뿐, 그림을 잘라 맞추지 않는다.

frame(width:) vs frame(maxWidth:) — 순서까지 같이 본다

frame(maxWidth: .infinity)는 "넓게 제안받으면 넓어지는 껍질"을 하나 씌운다. 그 껍질 바깥에 붙인 배경만 넓어진다(Day 1의 모디파이어 순서).

❌ 배경을 먼저 칠하고 넓혔다 — 노란색은 글자 크기만
Text("알림 설정")
    .background(Color.yellow)   // Text 크기의 배경
    .frame(maxWidth: .infinity, alignment: .leading) // 투명한 넓은 껍질
✅ 먼저 넓히고 칠한다 — 한 줄 전체가 노랗다
Text("알림 설정")
    .frame(maxWidth: .infinity, alignment: .leading) // 넓은 껍질
    .background(Color.yellow)   // 넓은 껍질 크기의 배경

fixedSize — 부모 제안에 대한 "역제안"

fixedSize() 문서는 이것을 "a counter proposal to the view size proposed to a view by its parent"라고 부른다. 부모 제안을 버리고 자식에게 ideal 크기를 묻는다. 한 축만 고정하는 fixedSize(horizontal:vertical:)이 실무에서 더 자주 쓰인다.

Apple 문서 예제를 세 가지로 비교
struct FixedSizeDemo: View {
    var body: some View {
        VStack(spacing: 40) {
            // ① 200 폭 안에서 줄바꿈·말줄임
            Text("A single line of text, too long to fit in a box.")
                .frame(width: 200, height: 80)
                .border(Color.gray)
            // ② ideal 크기(한 줄 전체)로 고정 → 테두리 밖으로 넘친다
            Text("A single line of text, too long to fit in a box.")
                .fixedSize()
                .frame(width: 200, height: 80)
                .border(Color.gray)
            // ③ 가로는 제안 수용(줄바꿈), 세로만 ideal → 잘리지 않고 여러 줄
            Text("A single line of text, too long to fit in a box.")
                .fixedSize(horizontal: false, vertical: true)
                .frame(width: 200)
                .border(Color.gray)
        }
    }
}
⚠️ 주의

fixedSize()는 부모 경계를 넘을 수 있다. 문서도 "This can result in the view exceeding the parent's bounds"라고 적는다. 좁은 화면·큰 Dynamic Type에서 가로 fixedSize()는 글자가 화면 밖으로 나가는 원인이 된다. 기본 선택은 ③ 세로만 고정이다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
SwiftUI 레이아웃의 3단계를 말하고, Auto Layout과 결정적으로 다른 단계를 짚어라.
① 부모가 크기를 제안 → ② 자식이 스스로 크기를 결정해 응답 → ③ 부모가 위치만 정한다. 다른 단계는 ②다. Auto Layout은 제약이 양방향으로 크기를 밀고 당기지만, SwiftUI에서는 자식이 최종 결정권을 갖고 부모는 그 크기를 바꿀 수 없다.
쉽게 말하면 옷 가게 점원(부모)이 "M 어때요?" 하고 권하면, 손님(자식)이 "전 L 입을게요" 하고 정한다. 점원은 옷을 어디 걸지만 정한다.
부모가 자식에게 폭 0, .infinity, nil을 제안해 보는 이유는? 각각 무엇을 알아내나?
자식이 얼마나 유연한지 재려는 것이다. 0 제안에 대한 답이 최소 크기, .infinity에 대한 답이 최대 크기, nil(미지정)에 대한 답이 ideal 크기다(Apple 문서 · ProposedViewSize). 스택 같은 컨테이너는 이 답들을 모아 공간을 나눈다. fixedSize()가 자식에게 보내는 것이 바로 nil 제안이다.
쉽게 말하면 친구에게 "의자 0칸이면? 무한 칸이면? 네 맘대로면?" 하고 세 번 물어보면, 그 친구가 최소 몇 칸, 최대 몇 칸, 원래 좋아하는 칸 수를 알 수 있다.
코드 예측용
VStack {
    Text("Hi").frame(width: 200).background(Color.red)   // (A)
    Text("Hi").background(Color.red).frame(width: 200)   // (B)
}
🔮 코드 예측: 위 코드에서 (A)와 (B)의 빨간 영역 폭은 각각 얼마인가?
(A)는 폭 200 전체가 빨갛다 — frame 껍질(200) 바깥에 배경을 칠했다. (B)는 "Hi" 글자 폭만 빨갛다 — Text 크기에 배경을 칠한 뒤 투명한 200 껍질로 감쌌다. 두 줄의 레이아웃 크기(200)는 같고, 칠해진 영역만 다르다.
쉽게 말하면 액자에 페인트를 칠하면 액자 전체가 빨갛고, 그림에 칠한 뒤 액자에 넣으면 그림만 빨갛다.
🔁 지난 Day 복습: UIHostingController를 자식 VC로 붙였을 때, SwiftUI 콘텐츠 크기 변화를 Auto Layout의 intrinsic size로 반영하게 하는 설정은?
hostingController.sizingOptions = .intrinsicContentSize (iOS 16+). 이 옵션을 켜면 SwiftUI 콘텐츠의 ideal size가 바뀔 때 호스팅 컨트롤러 뷰의 intrinsic content size가 갱신돼, 주변 Auto Layout 제약이 그 크기를 따른다. 오늘 배운 "ideal size = nil 제안에 대한 응답"이 바로 이 값이다.
쉽게 말하면 SwiftUI 쪽이 "나 커졌어!"라고 외치면 UIKit 쪽이 듣고 자리를 넓혀 주게 하는 스위치다.
➡️ 2부 예고

2부는 자식이 여럿일 때다 — 스택이 덜 유연한 자식부터 공간을 나누는 알고리즘과 Spacer·layoutPriority·정렬, 크기 협상에 끼지 않는 overlay/background, 정보가 아래로만 흐르는 GeometryReader, 그리고 흔한 실수를 다룬다.

출처 · 참고자료