[SwiftUI] PreferenceKey

Junyoung Park·2022년 8월 21일
0

SwiftUI

목록 보기
38/136
post-thumbnail
post-custom-banner

Use PreferenceKey to extract values from child views in SwiftUI | Advanced Learning #10

PreferenceKey

구현 목표



  • 자식 뷰의 네비게이션 뷰 타이틀을 변경할 때 사용되는 편리한 방법
  • 부모 뷰 → 자식 뷰의 데이터 플로우: 데이터 바인딩 - PreferenceKey
  • 네비게이션 타이틀의 스크롤 뷰 감지 커스텀 구현

구현 태스크

핵심 코드

        .onPreferenceChange(CustomTitlePreferenceKey.self) { value in
            self.navTitle = value
        }
  • 특정 뷰의 PreferenceKey를 변경하는 메소드. @State 값을 변경할 수 있는 클로저로, 특정 값의 변화를 자식 뷰에서 감지해서 부모 뷰로 이동시키는 방법으로 사용됨
struct CustomTitlePreferenceKey: PreferenceKey {
    static var defaultValue: String = ""
    static func reduce(value: inout String, nextValue: () -> String) {
        value = nextValue()
    }
}
  • defaultValue, reduce 두 개의 변수, 함수를 선언한 뒤 사용 가능.
  • reduce 함수를 통해 새롭게 들어온 inout 변수를 새로운 값으로 변경 가능. nextValue는 해당 값을 리턴하는 일종의 클로저, 함수임에 주의
extension View {
    func customTitle(text: String) -> some View {
        self
            .preference(key: CustomTitlePreferenceKey.self, value: text)
    }
}
  • 뷰 익스텐션으로 확장한 preference 키 사용 방법
  • 해당 뷰에 preference를 걸고, 이후 onPreferenceChange와 같은 방법으로 내부 값 변경 가능

소스 코드

import SwiftUI

struct PreferenceKeyBootCamp: View {
    @State private var navTitle: String = "HELLO WOLRD"
    var body: some View {
        NavigationView {
            VStack {
                SecondaryScreen(text: navTitle)
                    .navigationTitle("FIRST NAV TITLE")
            }
        }
        .onPreferenceChange(CustomTitlePreferenceKey.self) { value in
            self.navTitle = value
        }
    }
}

extension View {
    func customTitle(text: String) -> some View {
        self
            .preference(key: CustomTitlePreferenceKey.self, value: text)
    }
}

struct SecondaryScreen: View {
    let text: String
    @State private var newValue = ""
    var body: some View {
        Text(text)
            .onAppear(perform: getDataFromDatabase)
            .customTitle(text: newValue)
    }
    
    private func getDataFromDatabase() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
            self.newValue = "NEW VALUE FROM DATABASE"
        }
    }
}

struct CustomTitlePreferenceKey: PreferenceKey {
    static var defaultValue: String = ""
    static func reduce(value: inout String, nextValue: () -> String) {
        value = nextValue()
    }
}
  • 자식 뷰 SecondaryScreen이 나타나는 시점에서 DB에서 값을 받아오는 것처럼 행동하는 getDataFromDatabase()
import SwiftUI

struct GeometryPreferenceBootCamp: View {
    @State private var rectSize: CGSize = .zero
    var body: some View {
        VStack {
            Spacer()
            Text("Hello, World!")
                .frame(width: rectSize.width, height: rectSize.height)
                .background(Color.blue)
            Spacer()
            HStack {
                Rectangle()
                GeometryReader { geometry in
                    Rectangle()
                        .updateRectangleGeoSize(geometry.size)
                }
                Rectangle()
            }
            .frame(height: 55)
        }
        .onPreferenceChange(RectangleGeometryPreferenceKey.self) { value in
            self.rectSize = value
        }
    }
}

extension View {
    func updateRectangleGeoSize(_ size: CGSize) -> some View {
        preference(key: RectangleGeometryPreferenceKey.self, value: size)
    }
}

struct RectangleGeometryPreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}
  • Spacer, padding 등으로 그려진 뷰의 정확한 크기를 안 뒤 다른 뷰에 적용할 때 사용하는 방법
  • 자식 뷰의 특정 컴포넌트 뷰 크기를 알아내 부모 뷰의 특정 컴포넌트 뷰로 이동시키기
  • rectSize 프레임 사이즈의 첫 번째 타이틀이 HStack에 넣어놓은 사각형 뷰 크기 프레임을 입력받아 값 변경
  • RectangleGeometryPreferenceKeyupdateRectangleGeoSize 뷰 익스텐션 함수를 통해 사각형 뷰에 등록, onPreferenceChange를 통해 해당 값(CGSize)을 rectSize에 할당
import SwiftUI

struct ScrollViewOffsetPreferenceBootCamp: View {
    let title = "SCROLL VIEW"
    @State private var scrollViewOffset: CGFloat = 0
    var body: some View {
        ScrollView {
            VStack {
                titleLayer
                    .opacity(Double(scrollViewOffset) / 63.0)
                    .onScrollViewOffsetChange { offset in
                        scrollViewOffset = offset
                    }
                contentLayer
            }
            .padding()
        }
        .overlay(Text("\(scrollViewOffset)"))
        .overlay(navBarLayer.opacity(scrollViewOffset < 40 ? 1.0 : 0.0), alignment: .top)
    }
}

extension View {
    func onScrollViewOffsetChange(action:@escaping (_ offset: CGFloat) -> ()) -> some View {
        self
            .background(
                GeometryReader { geometry in
                    Text("")
                        .preference(key: ScrollViewOffsetPreferenceKey.self, value: geometry.frame(in: .global).minY)
                }
            )
            .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
                action(value)
            }
    }
}

extension ScrollViewOffsetPreferenceBootCamp {
    private var titleLayer: some View {
        Text(title)
            .font(.largeTitle)
            .fontWeight(.semibold)
            .frame(maxWidth: .infinity, alignment: .leading)
    }
    
    private var contentLayer: some View {
        ForEach(0..<100) { _ in
            RoundedRectangle(cornerRadius: 10)
                .fill(Color.red.opacity(0.3))
                .frame(width: 300, height: 200)
            
        }
    }
    
    private var navBarLayer: some View {
        Text("OVERLAY TITLE")
            .font(.headline)
            .fontWeight(.bold)
            .frame(maxWidth: .infinity)
            .frame(height: 55)
            .background(.white)
    }
}

struct ScrollViewOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
  • 디폴트 네비게이션 바 타이틀이 스크롤 뷰 스크롤 시 자동으로 라지 타이틀에서 인라인으로 변경되는 동작을 커스텀 구현한 코드
  • 네비게이션 라지 타이틀: titleLayer
  • 네비게이션 인라인 타이틀: navBarLayer
  • 스크롤 뷰 컨텐츠: contentLayer
  • 스크롤 뷰의 이동 감지: titleLayeronScrollViewOffsetChange를 적용, 보이지 않는 Text("") 뷰의 현재 위치값을 GeometryReader로 읽어 scrollViewOffset에 동기화하는 역할
  • 보이지 않는 Text("")는 스크롤이 이동 방향에 따라 값 변경 → 현재 스크롤 바의 상대적 위치 파악 가능 → 네비게이션 라지/인라인 타이틀 여부 결정 가능
  • 네비게이셔 라지 타이틀의 디폴트 스크롤 오프셋: CGFloat 63인 까닭에 내려갈 수록 opacity 작게, 특정 값 40 이하라면 곧바로 인라인 타이틀 나타나도록 구현

구현 화면

부모 뷰에서 자식 뷰의 특정 값을 PreferenceKey로 등록해 캐치할 수 있는 것은 State, Binding뿐만이 아님을 알 수 있었다.

  • 오히려 Binding보다 위의 프레임, 위치 정보 등을 얻는 데에는 가장 적합한 방법인 것 같다.
  • 스크롤 뷰, 페이징 기법 등 자식 뷰와 부모 뷰의 관계에 따라 적용해야 하는(즉 값이 바뀌는) 태스크가 필요할 때 참조할 것!
  • 이스케이핑 클로저를 통해 파라미터로 들어온 값을 그대로 밖으로 옮겨줄 수 있다(이스케이핑)는 데 다시 한 번 주의할 것!
profile
JUST DO IT
post-custom-banner

0개의 댓글