frame·fixedSize를 봤다. 2부는 자식이 여럿일 때다. HStack 안에서 가격이 "12,0…"로 잘리는 이유, ZStack으로 깐 배경이 화면 전체로 퍼지는 이유, GeometryReader가 남은 공간을 다 먹는 이유가 모두 같은 협상에서 나온다. 덜 유연한 자식부터, 정보는 아래로.2부 목표 · 25분
- HStack 안에서 잘리는 텍스트를
layoutPriority·Spacer·정렬로 고친다 ZStack으로 짠 배경을background/overlay로 바꾸고,GeometryReader를 쓸지 말지 판단한다- 이미지·버튼에서 반복되는 레이아웃 실수를 모디파이어 순서로 고친다
1. 스택이 공간을 나누는 법 — Spacer · layoutPriority · 정렬
스택의 자식들은 같은 공간을 두고 경쟁한다. WWDC19 세션이 설명하는 HStack 알고리즘은 이렇다: spacing을 먼저 빼고, 남은 폭을 아직 크기가 없는 자식 수로 나눠 가장 덜 유연한 자식부터 제안한다. 자식이 가져간 만큼 빼고 반복한다.
"The stack chooses its own size so that it exactly encloses the children." — WWDC19 · Building Custom Views with SwiftUI
→ 스택은 자식을 정확히 감싸는 크기가 된다. HStack이 화면 폭을 꽉 채우지 않는 이유다. 꽉 채우려면 Spacer나 flexible 자식이 있어야 한다.
Spacer — 스택 안의 대표적인 "욕심쟁이"
Spacer는 스택의 주축으로 최대한 늘어난다. 문서 표현으로 스페이서가 없으면 스택은 자식 ideal 폭의 합까지만 커지고, 스페이서가 있으면 부모가 허락하는 만큼 커진다. UIKit에서 빈 UIView에 낮은 hugging priority를 주던 일을 한 줄로 한다.
layoutPriority — "누가 먼저 줄어드나"를 정한다
기본 우선순위는 0이다. layoutPriority(_:) 문서: 가장 높은 우선순위 자식은 "all the space offered to the parent minus the minimum space required for all its lower-priority children"을 제안받는다. 즉 낮은 쪽은 최소 크기로 밀리고, 높은 쪽이 나머지를 먼저 가져간다.
HStack {
Text(title).lineLimit(1)
Spacer()
Text(price) // 우선순위 0 → 제목과 공평하게 줄어든다
}struct PriceRow: View {
let title: String
let price: String
var body: some View {
HStack {
Text(title)
.lineLimit(1)
Spacer(minLength: 8)
Text(price)
.layoutPriority(1) // 제목의 최소폭을 뺀 나머지를 먼저 받는다
}
}
}정렬과 spacing
스택의 alignment는 교차축 정렬이다(HStack이면 세로). 글자 크기가 다른 텍스트를 나란히 둘 때는 .center보다 .firstTextBaseline이 맞다. spacing을 생략하면 플랫폼·뷰 종류에 맞는 적응형 간격이 들어간다 — 숫자를 박기 전에 기본값을 먼저 보라.
struct StackAlignDemo: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text("Avocado Toast").font(.title)
Text("delicious").font(.caption)
Spacer() // 나머지 폭을 전부 먹어 별을 끝으로 민다
Image(systemName: "star.fill") // SF Symbol은 글자처럼 자체 기준선이 있다(텍스트 없는 Color 같은 뷰는 아래 끝이 기준선)
}
.padding(.horizontal)
}
}| 하고 싶은 일 | UIKit (UIStackView / Auto Layout) | SwiftUI |
|---|---|---|
| 가로·세로로 쌓기 | UIStackView(axis:) | HStack / VStack |
| 교차축 정렬 | .alignment = .firstBaseline | HStack(alignment: .firstTextBaseline) |
| 간격 | .spacing (기본 0) | spacing: (기본 = 적응형) |
| 남는 공간 밀어내기 | 빈 뷰 + 낮은 hugging priority | Spacer() |
| 이건 줄이지 마 | compression resistance ↑ | layoutPriority(1) 또는 fixedSize() |
레이아웃은 가장 큰 Dynamic Type에서 깨진다. 숫자 폭을 박는 대신 layoutPriority·fixedSize(horizontal: false, vertical: true)로 "줄바꿈을 허락"하면, 글자가 커져도 잘리지 않고 아래로 늘어난다.
2. overlay · background와 GeometryReader
secondary child는 크기 협상에 참여하지 않는다
background와 overlay는 원래 뷰(primary)의 크기를 먼저 정하고(1부의 Avocado Toast 협상 그림), 그 크기를 두 번째 뷰(secondary)에 제안한다. 그래서 배경이 아무리 flexible해도 전체 크기는 원래 뷰 크기다. 반면 ZStack은 모든 자식이 크기 결정에 참여한다 — flexible한 Color를 넣는 순간 ZStack이 제안 전체로 커진다.
background, "겹쳐 쌓인 동등한 자식들"을 원하면 ZStack. 둘은 크기 규칙이 다르다.struct ZStackBad: View {
var body: some View {
ZStack {
Color.blue // flexible → ZStack이 제안 전체로 커진다
Text("태그").foregroundStyle(.white)
}
}
}struct BackgroundGood: View {
var body: some View {
Text("태그")
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.blue, in: Capsule())
}
}배지처럼 "원래 뷰 위 모서리에 붙이는" 장식은 overlay(alignment:content:)로 한다. 문서에 따르면 클로저 안의 여러 뷰는 암묵적 ZStack으로 묶이고, alignment는 그 묶음 전체에 적용된다.
struct BadgeOverlay: View {
var body: some View {
Image(systemName: "bell")
.font(.title)
.overlay(alignment: .topTrailing) {
Text("3")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(4)
.background(.red, in: Circle())
.offset(x: 8, y: -8) // offset은 그리기 위치만 옮긴다(레이아웃 불변)
}
}
}GeometryReader — 정보는 아래로만 흐른다
GeometryReader 문서는 한 문장으로 성격을 밝힌다: "This view returns a flexible preferred size to its parent layout." 제안받은 공간을 전부 차지하는 flexible 뷰라는 뜻이다. VStack 안에 넣으면 남은 공간을 다 먹는 이유다.
"A geometry reader is designed to measure its container view, and report that size to its subview … the information flows downward." 그리고 측정값을 위로 되돌리면 "I'm bypassing the layout engine, which might result in a loop … this strategy isn't recommended." — WWDC22 · Compose custom layouts with SwiftUI
→ GeometryReader는 "내 컨테이너 크기를 자식에게 알려 주는" 도구다. 측정한 값을 다시 위로 보내 레이아웃을 바꾸면 루프가 생길 수 있어 권장하지 않는다. 그런 문제는 Day 8의 Layout 프로토콜로 푼다.
struct GeoBad: View {
var body: some View {
VStack {
Text("제목")
GeometryReader { geo in // 남은 세로 공간을 전부 먹는다
Color.orange.frame(width: geo.size.width * 0.4, height: 8)
} // 자식은 좌상단에 붙는다
Text("설명") // 맨 아래로 밀려난다
}
}
}struct ProgressBar: View {
var value: Double // 0...1
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(.quaternary)
Capsule().fill(.tint)
.frame(width: geo.size.width * min(max(value, 0), 1))
}
}
.frame(height: 8) // GeometryReader의 높이를 바깥에서 고정
}
}① 컨테이너 크기에 맞춰 그림을 그린다 → 써도 된다(크기는 바깥에서 고정). ② 비율로 크기를 나눈다 → Day 8의 containerRelativeFrame(iOS 17)이나 Grid. ③ 형제를 측정해 배치를 바꾼다 → Day 8의 Layout 프로토콜.
어디까지가 어느 뷰인지 헷갈리면 .border(Color.red)를 임시로 두른다. WWDC19 세션도 "throwing a background or border color on a view is a really useful trick"이라고 한다. "내가 제안한 크기"와 "뷰가 고른 크기"가 다른 지점이 바로 보인다.
struct BorderDebug: View {
var body: some View {
VStack {
Text("제목").border(Color.red)
Text("설명이 조금 더 깁니다").border(Color.blue)
}
.padding()
.border(Color.green) // 스택 + 패딩이 실제로 차지한 크기
}
}3. 흔한 실수 모음
Image는 기본이 고정 크기라 제안을 무시한다. frame은 액자일 뿐이다.
Image(systemName: "photo")
.frame(width: 120, height: 120)Image(systemName: "photo")
.resizable()
.scaledToFit()
.frame(width: 120, height: 120)배경을 칠한 뒤 넓히면 넓어진 부분은 투명한 껍질이다. 넓히기·패딩·배경을 전부 label 안에서, 이 순서로 한다.
Button("저장") { }
.padding(.vertical, 14)
.background(Color.accentColor)
.frame(maxWidth: .infinity) // 투명한 영역만 넓어진다Button {
// 저장
} label: {
Text("저장")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}Spacer()나 .frame(maxWidth: .infinity)가 필요하다.layoutPriority → 세로만 고정 → minimumScaleFactor 순으로 시도한다.✅ 복습 · 5분
Text("A").padding().background(Color.red)에서 빨간색이 화면 전체가 아니라 글자 주변만 칠해지는 이유는?
background는 원래 뷰(패딩 뷰)의 크기를 먼저 정하고, 그 크기를 Color에 제안한다. Color는 flexible이라 받은 크기를 그대로 쓴다. 반면 ZStack { Color.red; Text("A") }는 Color가 크기 결정에 참여해 제안 전체로 커진다.HStack 안의 가격 Text에 .layoutPriority(1)을 주면, 가격은 어떤 크기를 제안받나?
VStack {
HStack { Text("Hi") }.background(Color.red) // (A)
HStack { Text("Hi"); Spacer() }.background(Color.red) // (B)
}🔮 코드 예측: 위 코드를 화면에 띄우면 (A)와 (B)의 빨간 영역 폭은 각각 얼마인가?
Spacer가 주축으로 부모가 허락하는 만큼 늘어나 스택도 그만큼 커진다. 배경은 그 스택 크기에 맞춰 칠해진다.🔁 1부 복습: Text에 .fixedSize()를 붙이면 자식에게 어떤 제안이 가고, 어떤 위험이 있나? 실무 기본값은?
nil(미지정)을 제안해 ideal 크기로 고정한다 — 긴 텍스트는 한 줄 전체 폭이 된다. 문서 표현대로 부모 경계를 넘을 수 있어 좁은 화면·큰 Dynamic Type에서 화면 밖으로 나간다. 실무 기본값은 fixedSize(horizontal: false, vertical: true) — 가로는 제안을 받아 줄바꿈하고, 세로만 잘리지 않게 한다.🛠 미니 과제 (선택 · 10분)
쇼핑 목록의 상품 행을 만든다. Preview 하나로 끝난다.
struct ProductRow: View {
let title: String
let price: String
let isNew: Bool
var body: some View {
HStack {
Text(title).lineLimit(1)
Text(price).bold()
}
}
}
#Preview {
VStack(alignment: .leading, spacing: 12) {
ProductRow(title: "한정판 아보카도 토스트 세트 (2인분, 소스 추가)", price: "12,000원", isNew: true)
ProductRow(title: "커피", price: "3,500원", isNew: false)
}
.padding()
}- 요구 1 — 가격은 절대 잘리지 않고, 제목이 최대 2줄까지 줄바꿈된다.
- 요구 2 — 짧은 제목이어도 가격은 행의 오른쪽 끝에 붙고, 행 전체에 둥근 회색 배경이 깔린다(ZStack 금지).
- 요구 3 —
isNew면 행의 왼쪽 위 모서리에 빨간 "NEW" 배지를 붙이되, 행 크기는 변하지 않는다.
확인 방법: Preview 캔버스의 Variants → Dynamic Type Variants로 가장 큰 글자 크기에서도 가격이 온전히 보이는지 본다. 각 뷰에 .border(.red)를 잠깐 둘러 경계를 확인한다.
정답 코드 보기
struct ProductRow: View {
let title: String
let price: String
let isNew: Bool
var body: some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading) // 남는 폭 전부
Text(price)
.bold()
.fixedSize() // 절대 안 잘림
}
.padding(12)
.background(Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
.overlay(alignment: .topLeading) { // 크기에 영향 없음
if isNew {
Text("NEW")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.red, in: Capsule())
.offset(x: -6, y: -8)
}
}
}
}가격에는 layoutPriority(1)을 써도 된다. 다만 한 줄 가격은 fixedSize()가 의도를 더 직접 말한다 — "ideal 크기로 고정".