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

Day 7 · 2부 · 스택 분배와 GeometryReader

스택의 공간 분배(Spacer·layoutPriority·정렬), 협상에 끼지 않는 overlay/background, GeometryReader의 올바른 사용과 흔한 실수를 다룬다.
진행률
0 / 0 완료
1부에서 부모가 제안하고 자식이 크기를 고르는 협상 규칙과, 그 제안을 바꾸는 frame·fixedSize를 봤다. 2부는 자식이 여럿일 때다. HStack 안에서 가격이 "12,0…"로 잘리는 이유, ZStack으로 깐 배경이 화면 전체로 퍼지는 이유, GeometryReader가 남은 공간을 다 먹는 이유가 모두 같은 협상에서 나온다. 덜 유연한 자식부터, 정보는 아래로.

2부 목표 · 25분

  • HStack 안에서 잘리는 텍스트를 layoutPriority·Spacer·정렬로 고친다
  • ZStack으로 짠 배경을 background/overlay로 바꾸고, GeometryReader를 쓸지 말지 판단한다
  • 이미지·버튼에서 반복되는 레이아웃 실수를 모디파이어 순서로 고친다

1. 스택이 공간을 나누는 법 — Spacer · layoutPriority · 정렬

스택의 자식들은 같은 공간을 두고 경쟁한다. WWDC19 세션이 설명하는 HStack 알고리즘은 이렇다: spacing을 먼저 빼고, 남은 폭을 아직 크기가 없는 자식 수로 나눠 가장 덜 유연한 자식부터 제안한다. 자식이 가져간 만큼 빼고 반복한다.

Image delicious Avocado Toast ① 제안 300 − spacing 16 = 284 남음 미할당 284 ② 284÷3≈95를 Image에 제안 → 24 사용 남음 260 ③ 260÷2=130을 delicious에 제안 → 62 사용 남음 198 ④ 198 전부를 Avocado Toast에 제안 → 120 사용 안 씀 78 ⑤ 정렬(기본 center)로 줄 세우고, 스택 크기 = 24+62+120+16 = 222 — 300이 아니다
수치는 설명용이다. 요점 두 가지: 덜 유연한 자식이 먼저 가져가고, 스택은 자식을 딱 감싸는 크기를 고른다 — 남은 78은 버린다.
📝 Apple 원문

"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"을 제안받는다. 즉 낮은 쪽은 최소 크기로 밀리고, 높은 쪽이 나머지를 먼저 가져간다.

❌ 제목과 가격이 똑같이 경쟁 — 좁으면 가격이 "12,0…"로 잘린다
HStack {
    Text(title).lineLimit(1)
    Spacer()
    Text(price)          // 우선순위 0 → 제목과 공평하게 줄어든다
}
✅ 가격에 우선순위 1 — 제목이 먼저 줄어든다
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 = .firstBaselineHStack(alignment: .firstTextBaseline)
간격.spacing (기본 0)spacing: (기본 = 적응형)
남는 공간 밀어내기빈 뷰 + 낮은 hugging prioritySpacer()
이건 줄이지 마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이 제안 전체로 커진다.

Text.background(Color) ZStack { Color; Text } 부모가 제안한 영역 태그 크기 = Text + padding Color는 정해진 크기를 받기만 한다 부모가 제안한 영역 태그 크기 = 제안 전체 Color가 크기 결정에 참여해 끝까지 늘어난다
"배경"을 원하면 background, "겹쳐 쌓인 동등한 자식들"을 원하면 ZStack. 둘은 크기 규칙이 다르다.
❌ 태그 배경을 ZStack으로 — 파란색이 화면 전체로 퍼진다
struct ZStackBad: View {
    var body: some View {
        ZStack {
            Color.blue                       // flexible → ZStack이 제안 전체로 커진다
            Text("태그").foregroundStyle(.white)
        }
    }
}
✅ background — 크기는 Text가 정하고 배경은 따라온다
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 안에 넣으면 남은 공간을 다 먹는 이유다.

✅ 의도된 사용 — 아래로 ⚠️ 측정값을 위로 되돌리기 컨테이너 GeometryReader Path 등 자식 크기 geo.size 컨테이너 레이아웃에 영향 없음 .frame(width: 측정값) Text + overlay(Geo) 레이아웃 측정값을 위로 전송 바뀐 frame → 재측정 → 루프 위험
WWDC22 세션의 설명을 그림으로 옮겼다. 측정값을 레이아웃 바깥으로 되돌려 보내는 방식은 레이아웃 엔진을 우회한다.
📝 Apple 원문

"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 프로토콜로 푼다.

❌ 폭의 40% 막대를 만들려고 VStack 한가운데 GeometryReader
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의 높이를 바깥에서 고정
    }
}
🔑 GeometryReader 판단 기준

① 컨테이너 크기에 맞춰 그림을 그린다 → 써도 된다(크기는 바깥에서 고정). ② 비율로 크기를 나눈다 → Day 8의 containerRelativeFrame(iOS 17)이나 Grid. ③ 형제를 측정해 배치를 바꾼다 → Day 8의 Layout 프로토콜.

👀 Preview 팁

어디까지가 어느 뷰인지 헷갈리면 .border(Color.red)를 임시로 두른다. WWDC19 세션도 "throwing a background or border color on a view is a really useful trick"이라고 한다. "내가 제안한 크기"와 "뷰가 고른 크기"가 다른 지점이 바로 보인다.

레이아웃 디버깅 1번 도구 — 임시 테두리
struct BorderDebug: View {
    var body: some View {
        VStack {
            Text("제목").border(Color.red)
            Text("설명이 조금 더 깁니다").border(Color.blue)
        }
        .padding()
        .border(Color.green)   // 스택 + 패딩이 실제로 차지한 크기
    }
}

3. 흔한 실수 모음

🚨 이미지를 frame으로 줄이려 한다

Image는 기본이 고정 크기라 제안을 무시한다. frame은 액자일 뿐이다.

❌ 이미지는 원래 크기 그대로, 120×120 액자만 생긴다
Image(systemName: "photo")
    .frame(width: 120, height: 120)
✅ resizable로 flexible하게 만든 뒤 비율 유지
Image(systemName: "photo")
    .resizable()
    .scaledToFit()
    .frame(width: 120, height: 120)
🚨 버튼을 꽉 채웠는데 탭 영역·배경은 그대로

배경을 칠한 뒤 넓히면 넓어진 부분은 투명한 껍질이다. 넓히기·패딩·배경을 전부 label 안에서, 이 순서로 한다.

❌ 배경 → 넓히기 순서, 그리고 label 바깥
Button("저장") { }
    .padding(.vertical, 14)
    .background(Color.accentColor)
    .frame(maxWidth: .infinity)   // 투명한 영역만 넓어진다
✅ label 안에서 넓히기 → 패딩 → 배경
Button {
    // 저장
} label: {
    Text("저장")
        .frame(maxWidth: .infinity)
        .padding(.vertical, 14)
        .background(Color.accentColor)
        .foregroundStyle(.white)
        .clipShape(RoundedRectangle(cornerRadius: 10))
}
❌ HStack이 화면을 꽉 채울 거라 기대
스택은 자식을 감싸는 크기다. 꽉 채우려면 Spacer()나 .frame(maxWidth: .infinity)가 필요하다.
❌ 잘리는 텍스트에 fixedSize() 남발
가로 고정은 화면 밖으로 넘친다. layoutPriority → 세로만 고정 → minimumScaleFactor 순으로 시도한다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
Text("A").padding().background(Color.red)에서 빨간색이 화면 전체가 아니라 글자 주변만 칠해지는 이유는?
background는 원래 뷰(패딩 뷰)의 크기를 먼저 정하고, 그 크기를 Color에 제안한다. Color는 flexible이라 받은 크기를 그대로 쓴다. 반면 ZStack { Color.red; Text("A") }는 Color가 크기 결정에 참여해 제안 전체로 커진다.
쉽게 말하면 배경은 "주인공 옷 크기에 맞춰 재단한 안감"이고, ZStack은 "주인공과 똑같이 자리 싸움하는 친구"다.
HStack 안의 가격 Text에 .layoutPriority(1)을 주면, 가격은 어떤 크기를 제안받나?
부모가 받은 공간에서 우선순위가 낮은 자식들(제목 등)의 최소 크기를 뺀 나머지 전부를 먼저 제안받는다(Apple 문서 · layoutPriority). 가격이 필요한 만큼 가져가고, 제목은 남은 폭 안에서 줄바꿈·말줄임된다. 기본 우선순위는 모두 0이라 아무것도 안 주면 공평하게 줄어든다.
쉽게 말하면 피자를 나눌 때 "가격이"에게 먼저 필요한 만큼 떼어 주고, 나머지 친구들은 최소 한 조각씩만 보장받는 규칙이다.
코드 예측용
VStack {
    HStack { Text("Hi") }.background(Color.red)             // (A)
    HStack { Text("Hi"); Spacer() }.background(Color.red)   // (B)
}
🔮 코드 예측: 위 코드를 화면에 띄우면 (A)와 (B)의 빨간 영역 폭은 각각 얼마인가?
(A)는 "Hi" 글자 폭만 빨갛다 — 스택은 자식을 딱 감싸는 크기를 고른다. (B)는 제안받은 폭 전체가 빨갛다 — Spacer가 주축으로 부모가 허락하는 만큼 늘어나 스택도 그만큼 커진다. 배경은 그 스택 크기에 맞춰 칠해진다.
쉽게 말하면 스택은 안에 든 물건만큼만 큰 가방이다. 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()
}

확인 방법: Preview 캔버스의 Variants → Dynamic Type Variants로 가장 큰 글자 크기에서도 가격이 온전히 보이는지 본다. 각 뷰에 .border(.red)를 잠깐 둘러 경계를 확인한다.

정답 코드 보기
정답 예시 — maxWidth로 제목이 남는 폭을, fixedSize로 가격이 제 크기를
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 크기로 고정".

출처 · 참고자료