Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
SwiftUI/SwiftUI(Advanced)

7. PreferenceKey / Custom Navigation Bar

by Toughie 2023. 6. 23.

🦁PreferenceKey / Custom Navigation Bar🦁

PreferenceKey가 무엇인지?

그리고 PreferenceKey를 활용해서 Navigation 타이틀이 상단으로 자연스럽게 이동하는 것을 직접 구현해보자.

 

먼저 공식문서를 읽어봤다.

https://developer.apple.com/documentation/swiftui/preferencekey

 

PreferenceKey | Apple Developer Documentation

A named value produced by a view.

developer.apple.com

- 뷰에서 생성되는 이름있는 value.

- 특정 뷰에 여러 개의 하위 뷰가 있을 때, 해당 뷰는 동일한 PreferenceKey를 가진 하위 뷰들의 값들을 자동으로 결합해서

상위뷰에 하나의 값으로 표시한다.

- 이 값들이 상위 뷰에서 하나로 결합되어 최종 값이 형성된다.

 

PreferenceKey를 사용하면

뷰 계층에서 생성된 데이터를 효과적으로 공유하고 전달할 수 있다!

하위 뷰에서 생선된 값들을 상위 뷰에 자동으로 전달할 수 있다 !

이를 통해 뷰 계층 내에서 값의 공유와 조작이 가능하다!

이것이 핵심내용이다.

 

대부분은 상위뷰에서 하위뷰로 데이터 흐름이 이어지는데, 하위 뷰에서 상위 뷰의 데이터를 업데이트 하려면

@Binding을 썼었다. 하지만 PreferenceKey를 사용해서 역전된 데이터 흐름을 구현할 수  있다는 말로 이해했다.

SwiftUI에서 Preference는 사용자 정의 값/설정을 의미한다. 뷰 계층 구조에서 뷰 간 전달/공유될 수 있는 설정이나 정보!

 


PreferenceKey를 활용해서 기본 .navigationTitle()을 사용하지 않고, 이와 유사하게 동작하는 화면을 구현해 보자.

먼저 PreferenceKey 프로토콜을 채택한 구조체를 만들어 준다.

struct ScrollViewOffsetPreferenceKey: PreferenceKey {
    
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

PreferenceKey의 요구사항은 아래와 같다.

offset을 활용하기 때문에 defaultValue는 CGFloat이다.

reduce 메서드는 다른 PreferenceKey 값을 결합하기 위해 사용된다.

value 파라미터 - 현재까지 결합된 값

nextValue 파라미터 - 다음 값을 생성하는 클로저

이 메서드는 value와 nextValue를 결합해서 새로운 value를 생성하고, 이를 다음 PreferenceKey 값과 결합해준다.

 

inout

copy-in copy-out

inou t매개변수는 함수 내에서 해당 값을 수정하고, 수정된 값이 함수 호출이 끝난 후에도 호출자에게 반영되게 해준다.

즉 일반적으로 함수에 사용되는 파라미터는 값이 복사되어 처리되는데(원본의 카피본), inout은 원본에 처리된 값을 할당할 수 있게 해줌!(호출자에게 전달)


View

먼저 해당 뷰는

상단에 네비게이션 타이틀을 구현하기 위한 largeTitle의 텍스트로 이루어진 titleLayer

그 아래에 스크롤뷰, ForEach를 활용한 contentLayer

그리고 스크롤뷰에 overlay를 통해 네비게이션 바 영역을 구현한 navBarLayer로 구성되어 있다.

코드를 깔끔하게 관리하기 위해 extension을 활용해 정리한 모습.

extension ScrollViewOffsetPreferenceKeyPrac {
    
    private var titleLayer: some View {
        Text(title)
            .font(.largeTitle)
            .fontWeight(.semibold)
            .frame(maxWidth: .infinity, alignment: .leading)
    }
    
    private var contentLayer: some View {
        ForEach(0..<30) { _ in
            RoundedRectangle(cornerRadius: 10)
                .fill(Color.blue.opacity(0.5))
                .frame(width: 300, height: 200)
        }
    }
    
    private var navBarLayer: some View {
        Text(title)
            .font(.headline)
            .frame(maxWidth: .infinity)
            .frame(height: 55)
            .background(Color.white)
    }
}

struct ScrollViewOffsetPreferenceKeyPrac: View {
    
    let title: String = "NEW TITLE"
    @State private var scrollViewOffset: CGFloat = 0
    
    var body: some View {
        ScrollView {
            VStack {
                titleLayer
                    .background(
                        GeometryReader { geo in
                            Text("")
                                .preference(key: ScrollViewOffsetPreferenceKey.self, value: geo.frame(in: .global).minY)
                        }
                    )
                contentLayer
            }
            .padding()
        }
        .overlay(Text("\(scrollViewOffset)"))
        .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
            scrollViewOffset = value
        }
        .overlay (
                navBarLayer
                    .opacity(scrollViewOffset < 0 ? 1 : 0)
                    .animation(.easeOut(duration: 0.3), value: scrollViewOffset)
            ,
            alignment: .top
        )
    }
}

기본 네비게이션 타이틀의 동작 방식을 생각해 보면,

스크롤을 일정수준이상 내렸을 경우에 largeTitle이 자연스럽게 navigation Bar 영역으로 올라가며 작아진다.

-> titleLayer의 Offset을 알아야한다. 이를 위해서 GeometryReader를 활용했다.

Text("")는 빈 배경을 위한 요소이다. 

preference메서드는 preference에 값을 세팅하는 메서드이다. 

key에는 미리 만들어둔 PreferenceKey 프로토콜을 채택한 ScrollViewOffsetPreferenceKey의 메타타입을 할당해준다.

value에는 geo.frame(in: .global).minY가 전달되는데 이는 GeometryReader에서 생성되는 뷰의 프레임에서 Y좌표의 최소값이다.

 

.preference(key:value:)는 뷰의 계층 구조상 상위 뷰로 값을 전파한다.

즉 .preference(key:value:)가 사용된 하위 뷰에서 생성된 값을(여기서는 y좌표값,offset)을 

해당 키(ScrollViewOffsetPreferenceKey)를 가진 상위 뷰에서 수신할 수 있다.

 

ScrollView 안에 VStack 안에 titleLayer가 있기 때문에 현재 ScrollView가 상위뷰, titleLayer는 하위뷰이다.

이제 titleLayer의 배경에 .preference(key:value:)를 적용해서 값을 생성했으니 이것을 상위뷰에서 받아 사용하려는 것이다.

그래서 아래 코드를 ScrollView에 적용한 것이다.

        .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
            scrollViewOffset = value
        }

여기서 value는 titleLayer의 배경에서 GeometryReader를 통한 offset 값인 것이다.

이것을 최상위 뷰의 @State 프로퍼티에 할당해 주는 것이다.

 

-> 복잡하게 느껴지지만, 결국 하위 뷰의 값을 상위뷰에서 받아 활용할 수 있게 한다는 의미이다!

 

이제 상위뷰에서 스크롤에 따른 네비게이션타이틀(titleLayer)의 offset 값을 알 수 있게 되었다.

처음 뷰가 렌더링 되었을 때 Yoffset은 63이었다. 아래로 스크롤하면 계속 작아지는 것이고.

스크롤을 했을 때 네비게이션 타이틀은 서서히 페이드아웃 되는 애니메이션이기 때문에 

.opacity(Double(scrollViewOffset) / 63)을 활용했다. 즉 처음에는 투명도가 1이다가 

아래로 스크롤을 하면 scrollViewOffset가 작아지고 투명도는 0에 가까워지기 때문이다.

 

그렇게 라지 네비게이션타이틀의 투명도가 0이되면 이제 네비게이션바 영역에 타이틀이 나타나야한다.

        .overlay (
                navBarLayer
                    .opacity(scrollViewOffset < 0 ? 1 : 0)
                    .animation(.easeOut(duration: 0.3), value: scrollViewOffset)
            ,
            alignment: .top
        )

스크롤뷰에 overlay를 통해 구현했다. 이 값은 적절히 시뮬레이터를 돌려보며 조정했고

부드럽게 나타나도록 애니메이션도 함께 적용했다.

 

하위 뷰에서 GeometryReader를 활용해서 값을 생성하고, 이를 PreferenceKey에 합쳐서 쌓아두고

최신으로 합쳐진 값을 상위 뷰에서 받는 코드들을 아래와 같이 깔끔하게 합칠 수 있다.

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

뷰의 확장에서 구현된 해당 메서드는

action이라는 클로저 매개변수를 사용한다.

@escaping이기에 이 클로저는 외부에서 전달되고, 해당 뷰의 수명주기 외부에서 실행될 수 있다.

동작 순서

GeometryReader를 사용해서 ScrollViewOffsetPreferenceKey에 값을 전달하고

전달된 값을 onPreferenceChange를 통해 감지해서 클로저 action을 실행한다.

 

@escaping을 통해 action클로저를 탈출시키지 않는다면?

Escaping closure captures non-escaping parameter 'action' 에러가 발생한다.

 

Swift 클로저 매게변수는 기본적으로 non-escaping이라 클로저가 함수의 스코프를 벗어날 수없다.

즉 함수가 종료되면 클로저도 바로 종료되어 버린다는 말.

하지만 .onPreferenceChange는 escaping closure를 요구하기에 @escaping을 붙여준 것.

*escaping closure는 함수가 리턴된 후에도 힙 영역에 살아있다.( 함수의 스코프에서 벗어남, 탈출!)

 

아래와 같이 코드를 정돈할 수 있다.

struct ScrollViewOffsetPreferenceKeyPrac: View {
    
    let title: String = "NEW TITLE"
    @State private var scrollViewOffset: CGFloat = 0
    
    var body: some View {
        ScrollView {
            VStack {
                titleLayer
                    .opacity(Double(scrollViewOffset) / 63)
                    //⭐️
                    .onScrollViewOffsetChanged { offset in
                        self.scrollViewOffset = offset
                    }
                contentLayer
            }
            .padding()
        }
        .overlay (
                navBarLayer
                    .opacity(scrollViewOffset < 0 ? 1 : 0)
                    .animation(.easeOut(duration: 0.3), value: scrollViewOffset)
            ,
            alignment: .top
        )
    }
}

꽤 생소한 개념이라서 이해하는데 시간이 좀 걸렸다..

 

같은 PreferenceKey를 공유하는 하위 뷰들에서 생성된 값들을 모으고 합쳐서 상위 뷰로 슉 보낸다.

-> 하위 뷰에서 생성된 데이터를 상위 뷰에서 받아 활용할 수 있다!

이게 제일 중요하다고 생각한다.

보통은 데이터 흐름이 상위 뷰 -> 하위 뷰이지만,

@Binding, PreferenceKey를 활용하면

하위뷰 -> 상위뷰도 가능하다는것 :)

'SwiftUI > SwiftUI(Advanced)' 카테고리의 다른 글

9. Custom NavigationView/Link  (0) 2023.06.25
8. Custom TabView / TabBar 커스텀 탭뷰/탭바  (0) 2023.06.24
6. @ViewBuilder / 뷰 빌더  (0) 2023.06.22
5. Generic 제네릭  (0) 2023.06.21
4. custom Shape / path  (0) 2023.06.20