🦁PreferenceKey / Custom Navigation Bar🦁
PreferenceKey가 무엇인지?
그리고 PreferenceKey를 활용해서 Navigation 타이틀이 상단으로 자연스럽게 이동하는 것을 직접 구현해보자.
먼저 공식문서를 읽어봤다.
https://developer.apple.com/documentation/swiftui/preferencekey
- 뷰에서 생성되는 이름있는 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 |