반응형

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만의 강점을 살릴 수 있다.
반응형
Posted by 까칠코더
,