SwiftUI 레이아웃 시스템
SwiftUI에서 크기가 정해지는 원리 — "부모 제안 → 자식 결정 → 부모 배치" 3단 협상. frame·Stack·GeometryReader·fixedSize·Layout까지.
0. ⭐ 핵심 — 3단계 협상
① 부모가 자식에게 "이만큼 써도 돼" 크기 제안(proposed size)
② 자식이 제안 + 자기 콘텐츠를 보고 "난 이만큼 쓸게" 자기 크기 결정
③ 부모가 자식을 자기 좌표에 배치(place)
프레임을 "설정"하는 게 아니라 이 협상으로 크기가 정해진다. 부모는 강제 못 하고 제안만, 최종 크기는 자식이 결정. 레이아웃이 이상하면 "누가 제안하고 누가 결정했나"를 따라가면 풀린다.
1. .frame도 하나의 뷰다
.frame(width:height:)는 자식을 감싸는 또 다른 뷰. frame이 자식에게 그 크기를 제안하고, frame 자신은 그 크기를 부모에게 보고.
Text("Hi").frame(width: 100, height: 100)
// frame은 100×100 차지, Text는 그 안에서 자기 크기(가운데 배치)
2. 뷰별 사이징 행동 (제안에 어떻게 반응하나)
| 뷰 | 제안에 대한 반응 |
|---|---|
| Text | 필요한 만큼만(제안 내에서 줄바꿈). 제안보다 작을 수 있음 |
| Image(기본) | 고유 크기(intrinsic) 고정, 제안 무시. .resizable() 하면 제안 따름 |
| Color·Rectangle·Shape | flexible — 제안 전부 차지 |
| Spacer | flexible — 남는 공간 차지 |
| Stack | 자식들에게 제안 나눠주고 합쳐서 자기 크기 |
이 차이가 "왜 Color는 화면 꽉 채우고 Text는 딱 글자만큼인가"의 답.
3. Stack의 공간 분배
HStack/VStack이 자식에게 공간 나눠 제안하는 순서: ① 덜 유연한(고정) 자식 먼저 크기 확정 → ② 남은 공간을 유연한 자식(Spacer·flexible frame)에 분배.
.layoutPriority(_:): 높은 우선순위 자식이 먼저 제안받아 원하는 만큼 가져감(긴 텍스트 truncation 조절 등).
4. frame — 고정 vs 유연 / GeometryReader / fixedSize
.frame(width: 100) // 고정: 자식에 100 제안, 자기도 100
.frame(maxWidth: .infinity) // 유연: 부모가 준 만큼 전부 차지
.frame(minWidth:idealWidth:maxWidth:) // 범위
- GeometryReader: 부모가 준 공간 전부를 자식에 제안(greedy — 다 차지) + 크기·좌표 읽기(geo.size, geo.frame(in:)). ⚠️ greedy라 레이아웃 흐트러뜨리기 쉬움 → 필요한 곳에만.
- .fixedSize(): 자식에게 "제안 무시하고 네 이상적(ideal) 크기 써" → 자식이 intrinsic 크기. 대표: Text 잘림(…) 방지.
5. alignment · Layout 프로토콜(iOS 16+)
- Stack
alignment: 자식을 축에 맞춰 정렬(VStack(alignment: .leading)=수평 왼쪽). alignment guide=커스텀 정렬 기준선. - Layout 프로토콜(iOS 16+): 커스텀 레이아웃 직접 구현 —
sizeThatFits(proposal:subviews:cache:)+placeSubviews(...). Stack/Flow를 직접 만드는 수준.
✅ 체크 질문
- Text("Hi").frame(width: 200)에서 Text 폭? frame 폭? → Text=글자 폭, frame=200(그 안에서 Text 가운데)
- Color.red는 꽉 채우는데 Text는 딱 글자만큼인 이유? → Color=flexible(제안 다 차지), Text=필요한 만큼만
- GeometryReader가 레이아웃을 자주 깨는 이유? → greedy라 제안된 공간을 다 차지(부모 자리 밀어냄)
📌 후속 Q&A — UIKit 비교·ViewBuilder·intrinsic·Layout 프로토콜
Q. UIKit Auto Layout은 어떤 시스템? SwiftUI와 차이
| UIKit Auto Layout | SwiftUI | |
|---|---|---|
| 방식 | 제약(constraint) + 솔버 | 제안→결정 협상 |
| 계산 | 제약 솔버(Cassowary)가 선형 방정식·부등식 전역으로 한 번에 풀어 모든 frame 계산 | 부모 제안→자식 결정→부모 배치 재귀(솔버 없음) |
| 크기 원천 | 제약 + intrinsicContentSize + hugging/compression 우선순위 | 제안 + 뷰별 사이징 행동 |
UIKit=모두가 제약으로 묶여 솔버가 한 방에 해결(부모가 자식에 "제안" 없음). SwiftUI=부모-자식 재귀 협상.
Q. .frame은 modifier? / ViewBuilder란?
- .frame = view modifier: 원래 뷰를 감싼 새 뷰(ModifiedContent) 반환. 자식에 크기 제안하는 래퍼 뷰.
- @ViewBuilder = 클로저에 나열한 여러 뷰를 하나의 합성 뷰(TupleView)로 조립하는 result builder.
VStack { Text; Image }가 TupleView<(Text,Image)>가 되는 이유. if/else는 _ConditionalContent로.
Q. intrinsic size — 어떤 뷰가 자동?
intrinsicContentSize=외부 제약 없이 콘텐츠만으로 정해지는 자연 크기(UIKit).
- 있음(콘텐츠로 자동): UILabel·UIButton·UISwitch·UIImageView·UITextField·UISegmentedControl·UIProgressView → width/height 제약 없이 크기 정해짐.
- 없음: 순수 UIView·컨테이너·UIScrollView → 명시적 크기 제약 필요.
- SwiftUI 대응: Text·Image=ideal 크기(intrinsic 유사), Color·Shape=없음(flexible). 우선순위: content hugging(커지지 않으려는 저항)·compression resistance(작아지지 않으려는 저항).
Q. 뷰별 사이징 — 자세히(제안 주면 뭘 반환)
| 뷰 | 제안 받으면 |
|---|---|
| Text | 제안 폭 안에서 줄바꿈해 필요 크기(폭≤제안). 제안 nil/무한이면 ideal(한 줄 전체) |
| Image(비-resizable) | 제안 무시, intrinsic(픽셀) 크기 |
| Image.resizable() | 제안 그대로 채움 |
| Color·Rectangle·Shape | 제안 그대로(주는 만큼 다 차지) |
| Spacer | 스택 축 방향 남는 공간 다 차지 |
| Stack | 자식에 (스페이싱 뺀) 공간 제안 → 자식 합+스페이싱 |
3부류: 제안 무시하고 자기 크기(Image·Text 일부) / 제안 다 먹음(Color·Spacer) / 제안 안에서 맞춤(Text·Stack).
Q. flexible frame / "전부 차지·alignment"
Text("Hi").frame(maxWidth: .infinity) // frame 폭 꽉 참, Text는 그 안 "가운데"
Text("Hi").frame(maxWidth: .infinity, alignment: .leading) // Text가 "왼쪽"에 붙음
flexible frame=크기를 숫자 고정 대신 범위(maxWidth:.infinity 등)로 줘 제안에 맞춰 늘어남. maxWidth:.infinity면 frame이 폭을 다 차지 → 그 안 자식은 기본 가운데. alignment 파라미터가 확장된 frame 안에서 자식 위치를 정함(.leading=왼쪽).
Q. alignment guide
정렬 기준선을 뷰별로 커스터마이즈. 각 정렬(.leading 등)마다 뷰의 기준값(guide)이 있고, .alignmentGuide(.leading) { d in ... }로 그 선을 특정 뷰에서 옮김(텍스트 baseline·배지 위치 맞추기). 커스텀 정렬은 AlignmentID. alignment="어느 선에 맞출까", alignment guide="그 선을 이 뷰에선 여기로".
Q. Layout 프로토콜(iOS 16+) 자세히
HStack/VStack 같은 컨테이너를 직접 만드는 프로토콜. 두 메서드:
struct FlowLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
// subviews[i].sizeThatFits(proposal)로 각 자식 크기 질의 → 배치 계산 → 총 크기 반환
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for sv in subviews { sv.place(at: point, anchor: .topLeading, proposal: ...) }
}
} // 사용: FlowLayout { Tag("a"); Tag("b") }
- Subviews=자식 프록시 모음. sizeThatFits(proposal)로 크기 질의(=내가 자식에 제안).
- 같은 협상에 꽂힘: 내 Layout이 부모에게 제안받고 → 자식에 제안하며 크기 물어보고 → 총 크기 반환 → 배치.
- cache로 반복 계산 최적화(선택). 대표: Flow(태그 줄바꿈) 레이아웃.
📌 심화 후속 Q&A (2차)
Q. hugging/compression resistance는 스택뷰 전용?
아니다. 모든 UIView에 있다. intrinsicContentSize 있는 뷰(Label·Button 등)에서 특히 의미. hugging=intrinsic보다 커지지 않으려는 저항(낮으면 늘어남), compression resistance=intrinsic보다 작아지지 않으려는 저항(낮으면 줄어듦/잘림). 스택뷰에서 자주 드러날 뿐 각 뷰의 속성.
Q. "frame 직접 설정 안 함"인데 .frame은?
UIKit view.frame = CGRect(절대 x,y,w,h 직접 지정)과 SwiftUI .frame(width:)은 다르다. SwiftUI .frame=자식에 크기 제안하는 래퍼 뷰(자식이 더 작을 수 있고, frame 자신도 부모에게 배치받음). "frame 직접 설정 안 함"=절대 좌표(x,y) 명령이 없다는 뜻.
Q. TupleView / _ConditionalContent
- TupleView=여러 뷰를 하나로 묶은 컨테이너. body는 View 하나만 반환해야 해서 @ViewBuilder가
VStack{Text;Image}를TupleView<(Text,Image)>로 포장(컴파일러 내부 타입). - _ConditionalContent: @ViewBuilder 안 if/else를
_ConditionalContent<True,False>로 변환. 두 분기 타입이 달라도 한 타입으로 표현, 런타임엔 한쪽만 그림. §11 "if/else=다른 identity"가 이것(타입이 True/False로 갈려 상태 리셋).
Q. intrinsic size = 콘텐츠만으로 정해지는 크기? / ideal?
- 맞다. 외부 제약 관계없이 콘텐츠만으로 정해지는 크기 = "콘텐츠 넣으면 사이즈가 생김". UILabel "Hello"=그 폰트 렌더 크기. 순수 UIView는 콘텐츠 없어 intrinsic 없음((-1,-1)).
- ideal=제안이 없을 때(nil/무제한) 뷰가 원하는 자연 크기(SwiftUI판 intrinsic). Text의 ideal=줄바꿈 없이 한 줄로 다 폈을 때. fixedSize=제안 무시하고 ideal 써라.
Q. Text 폭/높이 · resizable Image 양방향
- Text: 제안 폭 안에서 줄바꿈, 높이는 콘텐츠(줄 수)에 따라 제안보다 커질 수 있음(폭 고정·높이 유동). ✅
- Image.resizable(): 제안을 그대로 채움 — 제안 작으면 축소, 크면 확대(양방향, aspectRatio로 비율 유지). 비-resizable은 제안 무시·원본 고정.
Q. "스페이싱 뺀 공간 제안 → 자식 합+스페이싱=자기 크기"
VStack(spacing: 10) { Text("A"); Text("B"); Text("C") } // 높이 300 제안
① 300 - 스페이싱(10×2=20) = 280을 자식에 (하나씩) 제안
② 각 Text는 콘텐츠 높이만 씀(예: 각 20) — 280 다 안 씀. 합 60
③ VStack 자기 크기 = 자식 합(60) + 스페이싱(20) = 80
스택은 균등 분할이 아니다. 스페이싱을 먼저 떼고, 각 자식이 필요한 만큼 가져가며(남는 건 flexible 자식이 나눔), 자기 최종 크기는 자식들 크기+스페이싱. (3차 후속의 정정 참고.)
Q. idealWidth?
.frame(minWidth:idealWidth:maxWidth:)에서 min~max=허용 범위, idealWidth=제안 없을 때 쓸 기본 폭(=ideal). ScrollView 안(제안 무한)처럼 제안 애매할 때 이 값으로 자기 폭 결정.
Q. ⭐ GeometryReader가 레이아웃 흐트러뜨리는 예시
VStack {
Text("제목")
GeometryReader { geo in Text("\(geo.size.width)") } // ← greedy
Text("설명")
}
기대: 세 Text가 각자 높이만큼 쌓임. 실제: GeometryReader가 VStack이 준 세로 공간을 전부 먹어 "제목"과 "설명"이 화면 끝까지 벌어지고 안 Text는 좌상단에 붙음. 원인=자기 콘텐츠 크기가 아니라 "부모가 준 공간 전부"를 자기 크기로 삼음. → 크기만 읽으려면 .background/.overlay에 GeometryReader 넣거나 iOS 16+ onGeometryChange.
Q. ⭐ alignment guide (실무 정렬)
Stack의 alignment=자식들을 어느 정렬선에 맞출지 선택. alignmentGuide=각 뷰가 그 선에 자기 어느 지점을 댈지 재정의.
HStack(alignment: .lastTextBaseline) { // 정렬선: 마지막 줄 baseline
Text("가격").font(.caption)
Text("29,000").font(.largeTitle) // 폰트 크기 달라도 baseline 맞음
}
// 커스텀: 이 뷰의 leading 기준을 20 오른쪽으로
Text("Label").alignmentGuide(.leading) { d in d[.leading] + 20 }
d=ViewDimensions(d[.leading]·d.width·d[.firstTextBaseline]). 용도: 폰트 크기 다른 텍스트 baseline 맞추기, 다른 행의 배지·아이콘을 한 세로선 정렬, 커스텀 정렬(AlignmentID)로 스택 밖 뷰 정렬. 한 줄: alignment=맞출 선 선택, alignmentGuide=그 선에 댈 자기 지점을 뷰별 이동.
Q. sizeThatFits란?
"이 제안(크기)을 주면 넌 얼마가 필요해?"를 묻는 질의 함수. Layout 프로토콜의 sizeThatFits(proposal:subviews:)=자식 크기 물어본 뒤 총 필요 크기 반환. UIKit UIView.sizeThatFits(_:)도 같은 개념(UILabel.sizeThatFits(CGSize(width:100, height:.max))="폭 100일 때 필요한 높이", 배치 안 하고 계산만). SwiftUI 협상의 "자식이 크기 결정" 단계가 이것.
📌 심화 후속 Q&A (3차 — 정정 포함)
Q. hugging/compression을 스택 밖 어디서 쓰나
두 개 이상이 같은 축에서 공간을 두고 경쟁할 때 "누가 양보하냐"를 정한다. 스택 밖 예:
- 두 라벨 가로 배치(제약): 공간 부족 시 값 라벨의 compression resistance를 낮춰 값이 …로 잘리고 제목은 다 보이게.
- label(고정) — textField(늘어남): textField의 hugging을 낮춰 남는 폭을 textField가 먹음.
- imageView 고정 크기 유지: 이미지 compression/hugging 높임.
혼자 있는 뷰엔 의미 없음(경쟁이 없어서). 스택이 그 경쟁 상황을 자주 만들 뿐, leading/trailing 제약 직접 배치에도 동일.
Q. VStack 안 뷰 개수별 타입 / "부모"의 정의
- 1개=
VStack<Text>(TupleView 아님), 2개=VStack<TupleView<(Text,Image)>>, 3개↑=여전히 TupleView(원소만 늘어남, 최대 10개·초과는 Group). - "부모"=트리 상 바로 위 뷰.
Text.frame(width:100)에서 Text의 부모=frame 래퍼, frame 래퍼의 부모=그 위 컨테이너. 모디파이어 래퍼도 트리 노드라 자식에겐 부모.
Q. UILabel intrinsic size 타이밍 (래스터화와 별개)
측정(intrinsic) ≠ 래스터화(비트맵). intrinsic은 텍스트 설정 즉시 글자 메트릭으로 계산되고, 래스터화는 나중(디스플레이 패스).
label.text = "Hi"
→ invalidateIntrinsicContentSize (자연 크기 바뀜 표시)
→ 레이아웃 패스: Auto Layout이 intrinsic으로 프레임 계산 → layoutSubviews로 배치
→ 디스플레이 패스: 글리프 래스터화 → layer.contents 채움
intrinsic size는 layoutSubviews보다 앞(측정). layoutSubviews는 그 크기로 배치하는 단계. 래스터화는 그보다 뒤.
Q. aspectRatio도 모디파이어(래퍼)?
그렇다. .aspectRatio(16/9, contentMode: .fit)=래퍼 뷰가 "부모 공간 안에서 16:9 유지한 최대 크기"를 자식(이미지)에 제안. .fill이면 넘치게 채움(+.clipped()). resizable 이미지에 붙임.
Q. ⭐ 스택은 균등 분할? (280÷3 정정)
VStack(spacing: 10) { Text("A"); Text("B"); Text("C") } // 높이 300 제안
① 300 - 스페이싱 20 = 280을 자식에 (하나씩) 제안
② 각 Text는 "난 20이면 돼"(콘텐츠 높이) → 각 20, 합 60 (280 다 안 씀)
③ VStack 크기 = 60 + 20 = 80 ← 300이 아니라 콘텐츠만큼
// 자식이 flexible이면 그때 남는 공간을 나눠 가짐(그때야 균등 비슷)
Q. GeometryReader가 받는 "부모 공간" = 전체? 뺀 부분?
제목·설명을 뺀 나머지. VStack이 고정 자식(제목·설명) 크기를 먼저 확정하고, 남은 세로 공간 전부를 GeometryReader(flexible·greedy)에 제안.
VStack 높이 800
├ Text("제목") → 20 확정
├ GeometryReader → 남은 760 다 차지 ← geo.size.height = 760
└ Text("설명") → 20 확정
// 제목·설명 사이가 760만큼 벌어져 보임
Q. ⭐ .alignmentGuide(.leading) { d in d[.leading] + 20 } 그림으로
"이 뷰가 정렬선에 자기 어느 지점을 댈지"를 바꾼다. 기본 .leading=자기 왼쪽 끝(d[.leading]=0)을 선에 댐.
정렬선 |
기본: |[뷰] ← d[.leading]=0(왼끝)을 선에 댐
+20: [뷰]| ← 기준점을 뷰 안 20 지점으로 → 그 점을 선에 대려고 뷰가 왼쪽으로 20 밀림
즉 기준점을 오른쪽(+)으로 옮기면 뷰는 왼쪽으로 밀린다. d=ViewDimensions(d.width·d[.leading]·d[.firstTextBaseline]). 쓸모: 특정 뷰만 들여쓰기, 다른 뷰의 특정 내부 지점을 한 세로선에 정렬.
Q. sizeThatFits 더 자세히
let label = UILabel(); label.text = "긴 문장…"; label.numberOfLines = 0
let fit = label.sizeThatFits(CGSize(width: 200, height: .greatestFiniteMagnitude))
// 입력=제안(최대), 출력=그 안에서 실제 필요 크기(폭≤200, 높이=줄 수만큼). 배치 안 함(계산만)
- .max(무제한) 주면 = 제한 없는 자연 크기 ≈ intrinsic.
- SwiftUI Layout
sizeThatFits(proposal:subviews:): 부모 제안 받아subview.sizeThatFits(제안)으로 자식들 크기 질의 → 배치 계산 → 총 크기 반환. 협상의 "자식 크기 결정 + 컨테이너 크기"가 이것. - 공통: "제안 → 필요 크기" 측정 질의(배치 아님). UIKit=개별 뷰, SwiftUI Layout=컨테이너.
관련 문서
SwiftUI some View 심화 (identity·상태·렌더링·Observation)
UIKit 레이아웃 과정 심화 (비교)