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(프록시 모음)를 통해서만 제안·배치한다.
세션의 과제: 버튼 폭을 "가장 넓은 버튼의 ideal 폭"으로 전부 맞추되, 컨테이너 폭까지 늘어나지는 않게. HStack은 각자 크기로 붙이고, frame(maxWidth: .infinity)는 부모 폭만큼 늘어나서 둘 다 실패한다.
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)
}
}
}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)
}
}"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가 같은 방식이다.
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: 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".
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은 그중 몇 칸을 차지할지다.
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) // 컨테이너 크기에서 빠진다
}
}문서가 꼽는 컨테이너는 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을 손으로 맞추던 일이다.
struct ChatBad: View {
var body: some View {
ZStack(alignment: .bottom) {
List(0..<50, id: \.self) { Text("메시지 \($0)") }
Button("보내기") { }
.frame(maxWidth: .infinity)
.padding()
.background(.bar)
}
}
}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로 받는다.
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을 바꾸는" 구조 그대로다.
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)
}
}
}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()
}
}프로토콜 요구사항은 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 | — |
| 하단 바·상단 배너 | safeAreaInset | ZStack으로 얹기 |
✅ 복습 · 5분
커스텀 Layout의 두 필수 메서드는 각각 무엇을 하고, 자식에게는 어떻게 접근하나?
sizeThatFits는 부모의 제안을 받아 자식을 재 보고(subviews[i].sizeThatFits(.unspecified) 등) 내 크기를 답한다. placeSubviews는 그 크기만큼의 bounds를 받아 각 자식의 위치와 제안을 place(at:anchor:proposal:)로 정한다. 자식 뷰 자체가 아니라 Subviews 프록시로만 제안·배치하고, bounds.origin은 (0,0)이 아닐 수 있어 minX/midY 기준으로 계산한다.라벨 폭을 맞추려고 GeometryReader + PreferenceKey로 폭을 올려 frame을 바꾸는 방식은 왜 피하고, 무엇으로 대체하나?
@State 변경 → 다시 레이아웃이라는 레이아웃 바깥 순환이 생긴다(Day 7의 루프 위험). 열 맞춤은 Grid가 셀을 전부 재서 자동으로, 형제 크기로 배치를 정하는 일은 Layout이 레이아웃 엔진 안에서 한다. preference는 "저장 안 된 변경 있음" 같은 크기 아닌 값을 올릴 때 가장 안전하다.HStack(spacing: 0) {
Color.blue.containerRelativeFrame(.horizontal) { w, _ in w * 0.5 }
Color.green
}
.frame(width: 200, height: 40)🔮 코드 예측: 위 코드에서 파란 영역의 폭은 100인가, 195인가? (safe area 좌우 인셋은 0이라고 가정)
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()
}- 요구 1 — 각 태그는 ideal 크기(
.unspecified제안)로 배치하고, 현재 줄에 안 들어가면 다음 줄로 내린다. - 요구 2 —
sizeThatFits는 실제로 쓴 폭과 높이를 반환한다(proposal.width가nil이면 한 줄로 가정). - 요구 3 —
placeSubviews에서bounds.origin이 (0,0)이라고 가정하지 않는다.
확인 방법: 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에 담는다.