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

Day 8 · 2부 · Layout 프로토콜·정렬·Preference

기성품으로 안 되는 배치를 Layout 프로토콜로 직접 만들고, 정렬·컨테이너 기준 크기·safe area·자식→부모 값 전달을 레이아웃 엔진 안에서 푼다.
진행률
0 / 0 완료
1부에서 Grid·LazyVGrid·ViewThatFits·AnyLayout 같은 기성품 도구를 봤다. 2부는 기성품으로 안 되는 요구를 푼다. "가장 넓은 버튼 폭으로 전부 맞추기"는 Layout 프로토콜로, 중첩 스택 사이 줄 맞춤은 커스텀 정렬로, 화면 기준 비율은 containerRelativeFrame으로, 자식의 사정을 부모에게 알리는 일은 PreferenceKey로 — 모두 GeometryReader 없이.

2부 목표 · 25분

  • Layout 프로토콜로 "가장 넓은 자식 폭으로 균등 배치" 컨테이너를 직접 구현한다
  • 커스텀 VerticalAlignment로 서로 다른 스택에 든 뷰끼리 줄을 맞춘다
  • containerRelativeFrame의 "컨테이너"가 무엇인지 알고, 부모 기준 비율이 필요할 때는 다른 도구를 고른다
  • 하단 바는 safeAreaInset, 자식→부모 값 전달은 PreferenceKey로 풀고, 크기 측정용 preference는 Grid/Layout으로 대체한다

1. Layout 프로토콜 — 협상에 직접 참여하기 🔥 핵심

Layout 프로토콜은 Day 7의 제안·결정·배치를 내가 구현하는 자리다. 필수 메서드는 두 개다. sizeThatFits에서 자식을 재고 내 크기를 답하고, placeSubviews에서 자식을 놓는다. 자식에게 직접 손대지는 못하고 Subviews(프록시 모음)를 통해서만 제안·배치한다.

부모 proposal sizeThatFits ② 내 크기(CGSize)를 답한다 subviews[i].sizeThatFits ① (.unspecified) → ideal bounds = 내가 답한 크기 placeSubviews ③ 위치 + 제안을 정한다 subviews[i].place(at:) anchor · proposal bounds.origin은 (0,0)이 아닐 수 있다 → 항상 minX / midY 기준으로 계산
세션 메모: bounds는 sizeThatFits에서 요청한 크기이며, RTL 환경에서는 프레임워크가 x 위치를 자동으로 뒤집는다.

세션의 과제: 버튼 폭을 "가장 넓은 버튼의 ideal 폭"으로 전부 맞추되, 컨테이너 폭까지 늘어나지는 않게. HStack은 각자 크기로 붙이고, frame(maxWidth: .infinity)는 부모 폭만큼 늘어나서 둘 다 실패한다.

세션의 EqualWidthHStack — ideal 폭의 최대값으로 균등 배치, 간격은 시스템 선호값
struct EqualWidthHStack: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard !subviews.isEmpty else { return .zero }
        let maxSize = maxSize(subviews: subviews)
        let spacing = spacing(subviews: subviews)
        let totalSpacing = spacing.reduce(0, +)
        return CGSize(width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
                      height: maxSize.height)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard !subviews.isEmpty else { return }
        let maxSize = maxSize(subviews: subviews)
        let spacing = spacing(subviews: subviews)
        let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)
        var x = bounds.minX + maxSize.width / 2          // origin이 0이라고 가정하지 않는다
        for index in subviews.indices {
            subviews[index].place(at: CGPoint(x: x, y: bounds.midY),
                                  anchor: .center,
                                  proposal: placementProposal)
            x += maxSize.width + spacing[index]
        }
    }

    // 미지정 제안 = "네 ideal 크기는?"
    private func maxSize(subviews: Subviews) -> CGSize {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return sizes.reduce(.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) }
    }

    // 이웃한 두 뷰의 spacing 선호값 중 큰 쪽 (내장 스택과 같은 규칙)
    private func spacing(subviews: Subviews) -> [CGFloat] {
        subviews.indices.map { index in
            guard index < subviews.count - 1 else { return 0 }
            return subviews[index].spacing.distance(to: subviews[index + 1].spacing, along: .horizontal)
        }
    }
}
내장 컨테이너처럼 쓴다 — label의 maxWidth가 "받은 폭까지 늘어나기"를 허락
struct EqualButtons: View {
    var body: some View {
        EqualWidthHStack {
            Button { } label: { Text("고양이").frame(maxWidth: .infinity) }
            Button { } label: { Text("금붕어").frame(maxWidth: .infinity) }
            Button { } label: { Text("강아지").frame(maxWidth: .infinity) }
        }
        .buttonStyle(.bordered)
    }
}
📝 Apple 원문

"The layout protocol gives you a better way to solve this problem, by letting you work within the layout engine." — WWDC22 · Compose custom layouts with SwiftUI
→ "형제를 재서 크기를 맞추는" 문제에 GeometryReader로 측정값을 위로 보내는 대신, 레이아웃 엔진 안에서 일하는 Layout을 쓰라는 말이다. Day 7의 루프 위험이 원천적으로 사라진다.

LayoutValueKey — 자식이 레이아웃에게 힌트 주기

Layout은 프록시만 볼 수 있어 데이터 모델에 접근하지 못한다. 자식별 값을 넘기려면 LayoutValueKey를 정의하고 layoutValue(key:value:)로 붙인 뒤, 프록시에 키를 인덱스로 읽는다. 내장 layoutPriority가 같은 방식이다.

순위를 자식에 붙이고 Layout에서 읽기
private struct Rank: LayoutValueKey {
    static let defaultValue: Int = 1         // 기본값이 값의 타입도 정한다
}

extension View {
    func rank(_ value: Int) -> some View {
        layoutValue(key: Rank.self, value: value)
    }
}

struct PodiumLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        proposal.replacingUnspecifiedDimensions()  // nil 제안이면 기본 크기로 대체
    }
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let step = bounds.width / CGFloat(max(subviews.count, 1))
        for (i, subview) in subviews.enumerated() {
            let rank = subview[Rank.self]           // 키로 값 읽기
            let y = bounds.minY + CGFloat(rank - 1) * 30
            subview.place(at: CGPoint(x: bounds.minX + step * (CGFloat(i) + 0.5), y: y),
                          anchor: .top, proposal: .unspecified)
        }
    }
}

struct PodiumDemo: View {
    var body: some View {
        PodiumLayout {
            Text("🐱").rank(2)
            Text("🐟").rank(1)
            Text("🐶").rank(3)
        }
        .frame(height: 120)
    }
}
🔬 더 깊이 — cache는 프로파일링 후에

두 메서드의 cache: inout ()는 계산 결과를 공유하는 자리다. makeCache(subviews:) 문서: "Many layout types don't need a cache, because SwiftUI automatically reuses both the results of calls into the layout and the values that the layout reads from its subviews." 그리고 "Only implement a cache if profiling shows that it improves performance." 처음엔 ()로 두고, Instruments에서 레이아웃 비용이 보일 때 makeCache/updateCache를 구현한다.

2. 정렬 · 컨테이너 기준 크기 · safe area

커스텀 정렬 — 다른 스택에 든 뷰끼리 줄 맞추기

스택의 alignment는 직계 자식끼리만 맞춘다. 중첩된 스택 속 두 뷰를 맞추려면 AlignmentID로 새 정렬을 만들고, 바깥 스택에 지정한 뒤, 맞출 뷰에 alignmentGuide를 붙인다(Apple 문서 · Aligning views across stacks). WWDC19 세션 표현으로 명시한 정렬값은 "project all the way out through two layers of nested stack".

HStack(alignment: .starsAndTitle) ★★★★☆ 리뷰 128개 카테고리 아보카도 토스트 신선한 재료 .starsAndTitle 두 VStack 속 서로 다른 뷰의 중앙이 한 선에 맞는다 — 가장자리 정렬로는 불가능
WWDC19 세션의 리스트 셀 예제를 옮겼다. 정렬값이 중첩 스택을 뚫고 바깥 HStack까지 전달된다.
커스텀 VerticalAlignment 정의와 사용
extension VerticalAlignment {
    private enum StarsAndTitle: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            context[.bottom]                  // 가이드를 안 붙인 뷰의 기본값
        }
    }
    static let starsAndTitle = VerticalAlignment(StarsAndTitle.self)
}

struct ReviewCell: View {
    var body: some View {
        HStack(alignment: .starsAndTitle) {
            VStack {
                Text("★★★★☆")
                    .alignmentGuide(.starsAndTitle) { d in d[VerticalAlignment.center] }
                Text("리뷰 128개").font(.caption)
            }
            VStack(alignment: .leading) {
                Text("카테고리").font(.caption)
                Text("아보카도 토스트")
                    .font(.title2)
                    .alignmentGuide(.starsAndTitle) { d in d[VerticalAlignment.center] }
                Text("신선한 재료").font(.caption)
            }
        }
    }
}

기존 정렬의 값을 덮어쓰는 것도 같은 모디파이어다. 세션은 텍스트가 없는 이미지의 "시각적 기준선"을 높이의 일정 비율로 정의하는 예를 든다.

아이콘의 기준선을 직접 정한다
struct IconLabel: View {
    var body: some View {
        HStack(alignment: .firstTextBaseline) {
            Image(systemName: "leaf")
                .alignmentGuide(.firstTextBaseline) { d in d.height * 0.8 } // 위에서 80% 지점
            Text("비건 메뉴")
        }
    }
}

containerRelativeFrame — GeometryReader 없이 비율 크기

containerRelativeFrame은 가장 가까운 컨테이너 크기를 기준으로 폭·높이를 정한다. WWDC23 · Beyond scroll views: "Previously you would've had to use a GeometryReader to accomplish this". WWDC23 · What's new in SwiftUI의 설명대로 "The count specifies how many chunks to divide the screen into. The span says how many of those chunks each view should take." — count는 몇 등분할지, span은 그중 몇 칸을 차지할지다.

가로 캐러셀 — 카드 한 장이 화면의 3/4, 다음 카드가 살짝 보인다
struct Park: Identifiable { let id = UUID(); let name: String }

struct ParkCarousel: View {
    let parks: [Park]
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 10) {
                ForEach(parks) { park in
                    RoundedRectangle(cornerRadius: 12)
                        .fill(Color.green.opacity(0.3))
                        .overlay(Text(park.name))
                        .aspectRatio(3.0 / 2.0, contentMode: .fit)      // 높이는 비율로
                        .containerRelativeFrame(.horizontal, count: 4, span: 3, spacing: 10)
                }
            }
        }
        .safeAreaPadding(.horizontal, 20)   // 컨테이너 크기에서 빠진다
    }
}
🚨 "컨테이너"는 부모 HStack이 아니다

문서가 꼽는 컨테이너는 iOS 화면(윈도), NavigationSplitView의 열, NavigationStack, TabView의 탭, ScrollView·List 같은 스크롤 뷰다. 그리고 크기는 "subtracting any safe area insets"한 값이다. 카드 안의 HStack에서 containerRelativeFrame(.horizontal) { w, _ in w * 0.4 }를 쓰면 카드 폭이 아니라 화면 폭의 40%가 된다. 부모 뷰 기준 비율이 필요하면 Grid나 커스텀 Layout을 쓴다.

safeAreaInset — 하단 바가 콘텐츠를 가리지 않게

safeAreaInset(edge:)은 콘텐츠를 가장자리에 붙이고, 원래 뷰의 safe area를 그만큼 넓힌다. 그래서 List의 마지막 행이 바 뒤로 숨지 않고 스크롤로 끝까지 올라온다. UIKit에서 additionalSafeAreaInsets나 contentInset을 손으로 맞추던 일이다.

❌ ZStack으로 바를 얹음 — 마지막 메시지가 바 뒤에 가려진다
struct ChatBad: View {
    var body: some View {
        ZStack(alignment: .bottom) {
            List(0..<50, id: \.self) { Text("메시지 \($0)") }
            Button("보내기") { }
                .frame(maxWidth: .infinity)
                .padding()
                .background(.bar)
        }
    }
}
✅ safeAreaInset — List가 바 높이만큼 스크롤 여유를 갖는다
struct ChatGood: View {
    var body: some View {
        List(0..<50, id: \.self) { Text("메시지 \($0)") }
            .safeAreaInset(edge: .bottom) {
                Button("보내기") { }
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(.bar)
            }
    }
}

3. PreferenceKey — 자식에서 부모로 값 올리기

데이터는 보통 위에서 아래로 흐른다(@Environment, Day 5). PreferenceKey는 반대 방향이다. 문서: "A view with multiple children automatically combines its values for a given preference into a single value visible to its ancestors." 여러 자식의 값은 reduce(value:nextValue:)로 합쳐지고, 조상은 onPreferenceChange로 받는다.

조상 · onPreferenceChange → 88 VStack · reduce = max "이름" → 40 "이메일 주소" → 88 "전화" → 36 Environment (아래로) Preference (위로)
preference는 view-tree 순서대로 reduce에 들어온다(문서). 합치는 규칙은 키가 정한다 — 여기서는 최댓값.

기하가 아닌 값부터 — 가장 안전한 사용

내장 navigationTitle도 자식이 부모 내비게이션 바에 값을 올리는 구조다. 직접 만들 때도 먼저 "크기 아닌 값"에 쓰면 레이아웃 루프 걱정이 없다.

자식 필드 중 하나라도 입력이 있으면 부모가 안다
struct UnsavedKey: PreferenceKey {
    static let defaultValue = false
    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        value = value || nextValue()       // 하나라도 true면 true
    }
}

struct EditorField: View {
    @State private var text = ""
    var body: some View {
        TextField("메모", text: $text)
            .preference(key: UnsavedKey.self, value: !text.isEmpty)
    }
}

struct EditorScreen: View {
    @State private var hasUnsaved = false
    var body: some View {
        VStack {
            EditorField()
            EditorField()
            Text(hasUnsaved ? "저장 안 된 변경 있음" : "변경 없음")
        }
        .onPreferenceChange(UnsavedKey.self) { hasUnsaved = $0 }
    }
}

크기를 올릴 때 — 먼저 대안을 본다

라벨 폭을 가장 긴 라벨에 맞추는 폼은 전통적으로 GeometryReader + preference로 풀었다. 동작은 하지만, Day 7에서 본 "측정값을 위로 보내 frame을 바꾸는" 구조 그대로다.

❌ (동작하지만 비추천) 측정 → preference → frame 변경 = 레이아웃 바깥 순환
struct LabelWidthKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

struct AlignedForm: View {
    @State private var labelWidth: CGFloat?
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            row("이름", "홍길동")
            row("이메일 주소", "gildong@example.com")
            row("전화", "010-0000-0000")
        }
        .onPreferenceChange(LabelWidthKey.self) { labelWidth = $0 } // 상태 변경 → 다시 레이아웃
        .padding()
    }
    private func row(_ label: String, _ value: String) -> some View {
        HStack {
            Text(label)
                .fixedSize()
                .background(
                    GeometryReader { proxy in
                        Color.clear.preference(key: LabelWidthKey.self, value: proxy.size.width)
                    }
                )
                .frame(width: labelWidth, alignment: .leading)
            Text(value).foregroundStyle(.secondary)
        }
    }
}
✅ Grid — 열 폭이 가장 넓은 셀에 자동으로 맞는다 (측정 코드 0줄)
struct FormAlignedByGrid: View {
    var body: some View {
        Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) {
            GridRow { Text("이름"); Text("홍길동").foregroundStyle(.secondary) }
            GridRow { Text("이메일 주소"); Text("gildong@example.com").foregroundStyle(.secondary) }
            GridRow { Text("전화"); Text("010-0000-0000").foregroundStyle(.secondary) }
        }
        .padding()
    }
}
⚠️ 주의 — defaultValue는 static let

프로토콜 요구사항은 static var defaultValue: Self.Value { get }(읽기 전용)이다. static var defaultValue = 0으로 쓰면 Swift 6 언어 모드에서 "전역 가변 상태" 동시성 오류가 난다. static let이면 요구사항도 만족하고 오류도 없다. LayoutValueKey도 마찬가지다.

하고 싶은 일첫 선택피할 것
열 맞춤 표·폼Grid (1부)GeometryReader + preference로 폭 맞추기
형제 크기로 배치 결정Layout 프로토콜측정값을 @State에 넣고 frame 변경
화면·스크롤 뷰 기준 비율containerRelativeFrame최상위 GeometryReader
자식 상태를 부모에 알림PreferenceKey (값) 또는 @Binding—
하단 바·상단 배너safeAreaInsetZStack으로 얹기

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
커스텀 Layout의 두 필수 메서드는 각각 무엇을 하고, 자식에게는 어떻게 접근하나?
sizeThatFits는 부모의 제안을 받아 자식을 재 보고(subviews[i].sizeThatFits(.unspecified) 등) 내 크기를 답한다. placeSubviews는 그 크기만큼의 bounds를 받아 각 자식의 위치와 제안을 place(at:anchor:proposal:)로 정한다. 자식 뷰 자체가 아니라 Subviews 프록시로만 제안·배치하고, bounds.origin은 (0,0)이 아닐 수 있어 minX/midY 기준으로 계산한다.
쉽게 말하면 먼저 "다들 키가 얼마야?" 물어서 교실 크기를 정하고(sizeThatFits), 그다음 교실 안에 자리를 배정한다(placeSubviews). 학생에게 직접 손대지 않고 반장(프록시)을 통해서만 말한다.
라벨 폭을 맞추려고 GeometryReader + PreferenceKey로 폭을 올려 frame을 바꾸는 방식은 왜 피하고, 무엇으로 대체하나?
측정 → preference → @State 변경 → 다시 레이아웃이라는 레이아웃 바깥 순환이 생긴다(Day 7의 루프 위험). 열 맞춤은 Grid가 셀을 전부 재서 자동으로, 형제 크기로 배치를 정하는 일은 Layout이 레이아웃 엔진 안에서 한다. preference는 "저장 안 된 변경 있음" 같은 크기 아닌 값을 올릴 때 가장 안전하다.
쉽게 말하면 줄자로 재서 쪽지로 올려 보내고, 다시 자르는 걸 반복하는 대신, 처음부터 크기를 맞춰 주는 틀(Grid·Layout)을 쓰는 것이다.
코드 예측용 — 폭 390인 iPhone 화면에서
HStack(spacing: 0) {
    Color.blue.containerRelativeFrame(.horizontal) { w, _ in w * 0.5 }
    Color.green
}
.frame(width: 200, height: 40)
🔮 코드 예측: 위 코드에서 파란 영역의 폭은 100인가, 195인가? (safe area 좌우 인셋은 0이라고 가정)
약 195다. containerRelativeFrame의 기준은 부모 HStack(200)이 아니라 가장 가까운 컨테이너, 여기서는 화면(윈도) 폭 390에서 safe area를 뺀 값이다. 그 결과 파란색 frame은 195를 차지하고, HStack의 200 안에서 초록은 남은 약 5만 받는다(Day 7의 협상대로 스택은 덜 유연한 자식부터 배분한다). 부모 폭의 절반이 목적이라면 Grid나 Layout을 쓴다.
쉽게 말하면 "방 크기의 절반"을 달라고 했는데, 여기서 "방"은 내가 들어 있는 상자가 아니라 집 전체(화면)다.
🔁 1부 복습: Dynamic Type이 커지면 ViewThatFits { HStack{…}; VStack{…} }가 왜 저절로 VStack을 고르나?
ViewThatFits는 후보를 순서대로 ideal 크기로 재 보고 제안 안에 들어가는 첫 후보를 그린다. 글자가 커지면 HStack 후보의 ideal 폭이 제안 폭을 넘어 건너뛰고, 더 좁은 VStack 후보가 선택된다. 조건 코드를 직접 쓸 필요가 없다.
쉽게 말하면 옷을 큰 것부터 입어 보고 맞는 첫 번째 옷을 입는다. 몸(글자)이 커지면 자연히 다음 옷으로 넘어간다.

🛠 미니 과제 (선택 · 10분)

태그를 왼쪽부터 채우고, 줄이 넘치면 다음 줄로 내리는 TagCloud 레이아웃을 만든다. UIKit이라면 UICollectionViewFlowLayout으로 하던 일이다.

시작 코드 — 두 메서드를 채운다
struct TagCloud: Layout {
    var spacing: CGFloat = 8
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        // TODO: 제안 폭 안에서 줄바꿈하며 필요한 높이 계산
        .zero
    }
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        // TODO: bounds.minX / minY 기준으로 왼쪽 위부터 배치
    }
}

#Preview {
    let tags = ["SwiftUI", "Layout", "Grid", "ViewThatFits", "AnyLayout",
                "PreferenceKey", "iOS 17", "containerRelativeFrame"]
    return TagCloud {
        ForEach(tags, id: \.self) { tag in
            Text(tag)
                .padding(.horizontal, 10)
                .padding(.vertical, 4)
                .background(Color.blue.opacity(0.15), in: Capsule())
        }
    }
    .padding()
}

확인 방법: Preview에서 기기를 iPhone SE ↔ iPad로 바꾸거나 .frame(width: 200)을 걸어 줄 수가 바뀌는지 본다. 가장 큰 Dynamic Type에서도 태그가 겹치지 않아야 한다.

정답 코드 보기
정답 예시 — 두 메서드가 같은 줄바꿈 규칙을 공유한다
struct TagCloud: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity      // nil이면 한 줄
        var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0, widest: CGFloat = 0
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > 0 && x + size.width > maxWidth {     // 줄바꿈
                y += rowHeight + spacing
                x = 0
                rowHeight = 0
            }
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
            widest = max(widest, x - spacing)
        }
        return CGSize(width: widest, height: y + rowHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > bounds.minX && x + size.width > bounds.maxX {
                y += rowHeight + spacing
                x = bounds.minX
                rowHeight = 0
            }
            subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size))
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
        }
    }
}

같은 계산이 두 메서드에 중복된다. 태그가 수백 개라 프로파일링에서 비용이 보이면, 그때 줄 정보를 cache에 담는다.

출처 · 참고자료