SwiftUI Study – PreferenceKey로 상·하위 뷰 간 정보를 안전하게 전달하는 고급 레이아웃·상태 공유 패턴
Dev Study/SwiftUI 2025. 12. 11. 12:57반응형
SwiftUI Study – PreferenceKey로 상·하위 뷰 간 정보를 안전하게 전달하는 고급 레이아웃·상태 공유 패턴
1. 왜 중요한가 (문제 배경)
SwiftUI는 기본적으로 단방향 데이터 흐름을 지향한다.
- 부모 → 자식: @Binding, Environment, ObservableObject 등으로 쉽게 전달 가능
- 하지만 자식 → 부모로 레이아웃 정보, 스크롤 위치, 내부 상태를 올려 보내야 하는 상황이 실무에선 매우 많다.
예를 들어:
- 스크롤 위치에 따라 상단 헤더를 축소/확장
- 현재 어떤 탭이 화면 중앙에 위치했는지에 따라 인디케이터 이동
- 자식 뷰가 계산한 높이를 부모가 알아야 레이아웃을 조정
- 여러 자식의 정보를 합쳐 부모가 하나의 UI 상태를 만들고 싶을 때
이때 쓰는 무기가 바로 PreferenceKey 이다.
하지만 잘못 사용하면:
- 값이 예상과 다르게 합쳐지거나
- 여러 곳에서 중복 설정되어 디버깅이 어려워지고
- “왜 이 값이 계속 바뀌지?” 같은 현상이 생긴다.
이번 팁은 PreferenceKey 자체를 메인으로 다루는 첫 고급 주제로,
기존 GeometryReader, ScrollView, Navigation 관련 팁과는 완전히 다른 축의 내용이다.
2. 잘못된 패턴 예시
❌ 예시 1: GeometryReader로만 부모 레이아웃 제어를 시도
struct HeaderView: View {
var body: some View {
GeometryReader { proxy in
Text("타이틀")
.frame(width: proxy.size.width) // ❌ 자식이 부모 레이아웃을 직접 지배
}
}
}
문제점
- 자식이 GeometryReader로 부모의 사이즈를 직접 사용하면 레이아웃 구조가 꼬이기 쉽다.
- 여러 중첩 레이아웃에서 “어느 기준의 size인지” 헷갈리기 쉽고, 반응형 레이아웃을 만들기 어렵다.
- “정보 전달”이 아니라 “레이아웃 개입”에 치중하는 잘못된 사용 패턴이다.
❌ 예시 2: 전역 상태로 스크롤 위치를 올리는 패턴
final class ScrollState: ObservableObject {
@Published var offset: CGFloat = 0
}
struct WrongScrollView: View {
@EnvironmentObject var scrollState: ScrollState
var body: some View {
ScrollView {
VStack {
GeometryReader { proxy in
Color.clear
.onAppear {
scrollState.offset = proxy.frame(in: .global).minY // ❌ 순간 값만 사용
}
}
// ...
}
}
}
}
문제점
- “한 시점의 값”을 전역 환경에 강하게 바인딩
- 여러 스크롤 뷰가 있으면 서로 값을 덮어씀
- 명시적 “부모-자식 관계”가 보이지 않아 디버깅이 힘들다.
❌ 예시 3: PreferenceKey를 쓰지만, reduce 구현이 잘못된 경우
struct OffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue() // ❌ 여러 자식이 값을 설정하면 마지막 것만 남음
}
}
문제점
- 여러 자식이 값을 올릴 수 있는데, 마지막 것만 남는 구조
- 합치거나(min/max/sum) 우선순위를 정해야 한다면 reduce를 제대로 구현해야 한다.
3. 올바른 패턴 예시
✅ 예시 1: 자식 → 부모로 높이(height)를 올려 보내는 기본 패턴
PreferenceKey 정의:
struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue()) // 가장 큰 값 사용
}
}
자식 뷰:
struct MeasuredView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.background(
GeometryReader { proxy in
Color.clear
.preference(key: HeightPreferenceKey.self,
value: proxy.size.height)
}
)
}
}
부모 뷰:
struct ParentView: View {
@State private var headerHeight: CGFloat = 0
var body: some View {
VStack(spacing: 0) {
MeasuredView {
HeaderContent()
}
.onPreferenceChange(HeightPreferenceKey.self) { value in
headerHeight = value
}
ContentView()
.padding(.top, headerHeight / 2)
}
}
}
포인트
- 자식은 “자신의 높이”만 계산해서 Preference로 올려준다.
- 부모는 onPreferenceChange를 통해 필요한 만큼만 사용한다.
- 자식이 부모 레이아웃을 직접 건드리지 않고, “정보만 전달”하는 구조다.
✅ 예시 2: 스크롤 위치를 기반으로 상단 헤더를 축소/확장하는 패턴
Offset PreferenceKey:
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue() // 하나의 스크롤 기준이면 마지막 값만 사용해도 OK
}
}
스크롤 내부에서 offset 올리기:
struct TrackingScrollView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ScrollView {
GeometryReader { proxy in
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: proxy.frame(in: .named("scroll")).minY
)
}
.frame(height: 0)
content
}
.coordinateSpace(name: "scroll")
}
}
부모에서 사용:
struct CollapsingHeaderScreen: View {
@State private var offset: CGFloat = 0
var body: some View {
VStack(spacing: 0) {
HeaderView(shrink: min(max(-offset / 100, 0), 1))
TrackingScrollView {
ForEach(0..<100) { i in
Text("Row \(i)")
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
.onPreferenceChange(ScrollOffsetKey.self) { value in
offset = value
}
}
}
포인트
- ScrollView 내부에서 offset을 측정하고 Preference로 올림
- 부모는 offset 값만 보고 헤더 크기를 조정
- GeometryReader를 남용하지 않고도 자연스러운 “축소 헤더” 구현 가능
✅ 예시 3: 현재 표시 중인 탭에 맞춰 인디케이터를 이동하는 패턴
PreferenceKey:
struct TabFrameKey: PreferenceKey {
static var defaultValue: [Int: CGRect] = [:]
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
각 탭 버튼에서 frame 보고하기:
struct TabItemView: View {
let index: Int
let title: String
let isSelected: Bool
var body: some View {
Text(title)
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: TabFrameKey.self,
value: [index: proxy.frame(in: .named("tabBar"))]
)
}
)
}
}
부모에서 인디케이터 위치 계산:
struct TabBarView: View {
@State private var selection: Int = 0
@State private var frames: [Int: CGRect] = [:]
var body: some View {
ZStack(alignment: .bottomLeading) {
HStack {
TabItemView(index: 0, title: "홈", isSelected: selection == 0)
.onTapGesture { selection = 0 }
TabItemView(index: 1, title: "검색", isSelected: selection == 1)
.onTapGesture { selection = 1 }
TabItemView(index: 2, title: "설정", isSelected: selection == 2)
.onTapGesture { selection = 2 }
}
if let frame = frames[selection] {
Rectangle()
.frame(width: frame.width, height: 3)
.offset(x: frame.minX, y: 0)
.animation(.easeInOut, value: frames)
.animation(.easeInOut, value: selection)
}
}
.coordinateSpace(name: "tabBar")
.onPreferenceChange(TabFrameKey.self) { value in
frames = value
}
}
}
포인트
- 각 탭 버튼은 자신 위치만 보고
- 부모는 모든 프레임을 합쳐 “선택된 탭의 frame”에 인디케이터를 그린다.
- UIKit의 custom tab bar 구현 패턴을 SwiftUI스럽게 표현한 형태다.
4. 실전 적용 팁
✔ 팁 1 – PreferenceKey는 “정보 전달용”, 레이아웃 조작용이 아니다
- 자식 뷰는 측정/계산 결과만 위로 올려준다.
- 실제 레이아웃 변화는 부모가 결정해야 한다.
✔ 팁 2 – reduce 구현이 핵심
- 여러 자식이 값을 올릴 수 있다는 점을 항상 염두에 두자.
- 사용할 수 있는 패턴:
- value = nextValue() → 마지막 값만 사용
- value = max(value, nextValue()) → 최대값
- value.merge(...) → 딕셔너리 합치기
- value.append(contentsOf: nextValue()) → 배열 누적
✔ 팁 3 – coordinateSpace를 명시적으로 사용해 위치를 예측 가능하게
- .frame(in:) 에 사용하는 좌표계 이름을 통일해두면,
디버깅 및 레이아웃 추론이 훨씬 쉬워진다.
✔ 팁 4 – “전역 ObservableObject로 스크롤 상태를 올리는 패턴”은 가급적 피하기
- 실제로 관계가 있는 부모-자식 사이에는 PreferenceKey가 더 적합하다.
- 전역 상태는 마지막 수단 정도로만 쓰는 것이 좋다.
✔ 팁 5 – 복잡한 커스텀 UI(Sticky Header, Custom Tab, Scroll Sync)는 대부분 PreferenceKey로 풀린다
- UIKit 시절에는 delegate/closure/Notification으로 풀던 문제를
SwiftUI에서는 PreferenceKey + coordinateSpace 조합으로 깔끔히 해결할 수 있다.
5. 정리
- PreferenceKey는 SwiftUI에서 자식 → 부모 방향 정보 전달을 위한 공식적인 메커니즘이다.
- GeometryReader·전역 상태에만 의존하면 레이아웃·상태가 점점 예측 불가능해진다.
- “자식은 측정만, 부모는 결정만”이라는 역할 분리가 중요하다.
- 레이아웃, 스크롤, 탭 인디케이터 등 고급 UI를 구현할 때
PreferenceKey를 적극적으로 활용하면 SwiftUI만의 강점을 살릴 수 있다.
반응형

