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

9. Custom NavigationView/Link

by Toughie 2023. 6. 25.

🦁Custom NavigationView/Link🦁

화면 간 전환에 아주 유용하고 강력한 기능인 네비게이션뷰/링크를 커스텀 해보자.

기본 네비게이션뷰 같은 경우 특히 네비게이션 타이틀,네비게이션바 부분의 커스텀에는 많은 제약이 있다.

네비게이션의 동작 원리에 맞게 아예 처음부터 재구성하기보다는,

기존 네비게이션을 활용하면서 필요한 부분만 커스텀 해서 사용한다고 보는 편이 맞겠다.

 

 

먼저 기본 네비게이션 뷰 사용법은 아래와 같다.

        NavigationView {
            ZStack {
                Color.yellow.ignoresSafeArea()
                
                NavigationLink {
                    Text("Destination")
                        .navigationTitle("Title2")
                        .navigationBarBackButtonHidden(false)
                } label: {
                    Text("Navigate")
                }

            }
            .navigationTitle("Nav Title")
        }

네비게이션 뷰 안에 컨텐츠를 넣는 방식. 네비게이션 링크는 네비게이션 뷰 안에서만 동작한다.

네비게이션 타이틀은 네비게이션 뷰 스코프 내부에서 동작하는 메서드이다.


먼저 네비게이션 뷰를 활용하는 화면을 떠올려보면..

상단에 네비게이션바 영역에 네비게이션 타이틀, 그리고 뒤로 돌아가는 백버튼이 있다. 그 아래에는 컨텐츠가 있다. 

다만 기본 기능에는 부제목을 추가하는 기능이 없으며, 자유자재로 네비게이션 영역을 커스텀하기 위해서 커스텀 코드를 짜보는 것.

먼저 상단 네비게이션바 영역부터 구현해보자.


CustomNavBarView

import SwiftUI

struct CustomNavBarView: View {
    //백버튼을 통해 dismiss를 위한 환경변수
    @Environment(\.presentationMode) var presentationMode
    //백버튼 표시/숨기기
    let showBackButton: Bool
    //네비게이션 타이틀
    let title: String
    //부제목은 필요한 경우에만 쓸 수 있도록 옵셔널 선언
    let subtitle: String?
    
    var body: some View {
        HStack {
        //필요에 따라 백버튼 표기/숨기기 
            if showBackButton {
                backButton
            }
            Spacer()
            //네비게이션 타이틀 영역
            titleSection
            Spacer()
            if showBackButton {
                backButton
                    .opacity(0)
            }

        }
        .padding()
        //accentColor
        .tint(Color.white)
        .foregroundColor(.white)
        .font(.headline)
        .background(Color.blue.ignoresSafeArea(edges: .top))
    }
}
extension CustomNavBarView {
    //extension 활용 코드 분리
    private var backButton: some View {
        Button {
            presentationMode.wrappedValue.dismiss()
        } label: {
            Image(systemName: "chevron.left")
        }
    }
    
    private var titleSection: some View {
        VStack {
            Text(title)
                .font(.title)
                .fontWeight(.semibold)
            if let subTitle = subtitle {
                Text(subTitle)
            }
        }
    }
}

CustonNavBarContainerView

위에서 만들어둔 Navigation Bar와 콘텐츠를 묶어보자.

//제네릭 활용
struct CustomNavBarContainerView<Content: View>: View {
    
    let content: Content
    //기본 값 할당해준 뒤, 이후 PreferenceKey로 값 관리함
    @State private var showBackButton: Bool = true
    @State private var title: String =  ""
    @State private var subtitle: String?
    
    //ViewBuilder 활용, 클로저로 컨텐츠영역 초기화
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        VStack(spacing: 0) {
            CustomNavBarView(showBackButton: showBackButton, title: title, subtitle: subtitle)
            content
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}

CustomNavView

네비게이션바, 컨텐츠 영역을 묶은 NavBarContainerView를 만들었으니 이것을 기본 NavigationView로 감싸준다.

이렇게 해야 기본 네비게이션 방식을 활용할 수 있기 때문이다.

다만 상단에 네비게이션 바 영역이 중첩되기 때문에 기본 네비게이션 바를 숨겨준다.

기본 네비게이션 바를 숨기면 기본 백 제스쳐를 사용하지 못하기 때문에 해당 부분도 수정해준다.

 

struct CustomNavView<Content: View>: View {
    
    let content: Content
    
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        NavigationView {
            CustomNavBarContainerView {
                content
            }
            //상단 영역 중첩으로 인해 기본 네비게이션바 영역을 숨겨줌
            .navigationBarHidden(true)
            //위 코드가 Deprecated되어서 아래 주석 코드를 활용할 수도 있음.
//            .toolbar(.hidden)
        }
        .navigationViewStyle(StackNavigationViewStyle())
        //위 코드가 Deprecated되어서 아래 주석 코드를 활용할 수도 있음.
        //NavigationStack
    }
}

백 제스쳐 사용을 위한 코드

extension UINavigationController {
    open override func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = nil
    }
}

iOS의 네비게이션 스택에서는 이전 화면으로 되돌아가기 위해 유저가 왼쪽에서 오른쪽으로 스와이프하는,

백 스와이프 제스쳐가 제공된다. UINavigationController는 이 백 스와이프 제스처를 인식해서 동작한다.

 

위 코드에서는 UINavigationController의 viewDidload()를 재정의하고 있다.

(open 접근제어자를 통해 재정의 가능)

- UINavigationController가 로드될 때 viewDidLoad()메서드가 호출됨

- super.viewDidLoad()를 호출해서 상위 클래스인 UINavigationController의 vieDidLoad()를 실행함.
- interactivePopGestureRecognizer?.delegate를 nil로 설정해서 백 스와이프 제스처의 delegate를 해제함.

-> 백 스와이프 제스처가 더이상 UINavigationController의 제스처 인식 델리게이트에 의해 처리되지 않고, 시스템 제스처를 통해 이전 화면으로 돌아갈 수 있게 됨. (기본 네비게이션 영역을 숨겼기 때문에 델리게이트를 해제시켜준 것)


⭐️뷰 생명주기 ⭐️

UIViewController의 생명주기는 아래와 같은 순서로 진행된다.

1. init() or initWithCoder() - 초기화 메서드

2. loadView() - 뷰의 계층 구조를 로드하는 메서드

3. viewDidLoad() - 뷰 컨트롤러의 뷰가 메모리에 로드된 후 호출되는 메서드

4. viewWillAppear(_:) - 뷰가 화면에 나타나기 직전에 호출되는 메서드

5. viewDidAppear(_:) - 뷰가 화면에 나타난 후 호출되는 메서드

6. viewWillDisappear(_:) -뷰가 화면에서 사라지기 직전에 호출되는 메서드

7. viewDidDisappear(_:) - 뷰가 화면에서 사라진 후 호출되는 메서드


이제 커스텀네비게이션뷰에서 타이틀을 설정할 때  .navigationTitle("Hi")와 같이 동작하도록 

(하위 뷰에서 상위 뷰의 데이터를 업데이트 할 수 있도록)

 

PreferenceKey와 뷰 확장 메서드를 구현해보자.

현재 커스텀 네비게이션바 영역에는 showBackButton, title, subtitle 세 프로퍼티가 있다.

따라서 각각 PreferenceKey를 만들어 준다.

PreferenceKey

struct CustomNavBarTitlePreferenceKey: PreferenceKey {
    static var defaultValue: String = ""
    static func reduce(value: inout String, nextValue: () -> String) {
        value = nextValue()
    }
}

struct CustomNavBarSubtitlePreferenceKey: PreferenceKey {
    static var defaultValue: String?
    static func reduce(value: inout String?, nextValue: () -> String?) {
        value = nextValue()
    }
}

struct CustomNavBarBackButtonHiddenPreferenceKey: PreferenceKey {
    static var defaultValue: Bool = false
    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        value = nextValue()
    }
}

Custom Method

preference키를 만들었으니, 메서드를 통해 값을 바꿀 수 있게 하자.

extension View {

    func customNavigationTitle(_ title: String) -> some View {
        self
            .preference(key: CustomNavBarTitlePreferenceKey.self, value: title)
    }
    
    func customNavigationSubtitle(_ subtitle: String?) -> some View {
        preference(key: CustomNavBarSubtitlePreferenceKey.self, value: subtitle)
    }
    
    func customNavigationBarBackButtonHidden(_ hidden: Bool) -> some View {
        preference(key: CustomNavBarBackButtonHiddenPreferenceKey.self, value: hidden)
    }
    
    func customNavBarItems(title: String = "", subtitle: String?, backButtonHidden: Bool = false) -> some View {
        self
            .customNavigationTitle(title)
            .customNavigationSubtitle(subtitle)
            .customNavigationBarBackButtonHidden(backButtonHidden)
    }
}

이제 Preference 값이 바뀌었을 때 이를 추적할 수 있도록 코드를 추가하자.

CustomNavBarContainerView에 적용해준다. (기존에 속성들이 @State변수로 선언되어 있음)

struct CustomNavBarContainerView<Content: View>: View {
    
    let content: Content
    
    @State private var showBackButton: Bool = true
    @State private var title: String =  ""
    @State private var subtitle: String?

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        VStack(spacing: 0) {
            CustomNavBarView(showBackButton: showBackButton, title: title, subtitle: subtitle)
            content
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .onPreferenceChange(CustomNavBarTitlePreferenceKey.self) { value in
            self.title = value
        }
        .onPreferenceChange(CustomNavBarSubtitlePreferenceKey.self) { value in
            self.subtitle = value
        }
        .onPreferenceChange(CustomNavBarBackButtonHiddenPreferenceKey.self) { value in
            self.showBackButton = !value
        }
    }
}

이제 네비게이션 링크도 커스텀 해주자.

why? 커스텀 네비게이션뷰 내부에서 기본 네비게이션 링크를 쓰면

이와 같이 상단부에 우리가 커스텀해둔 뷰가 나타나지 않기 때문이다.

네비게이션링크도 네비게이션뷰와 마찬가지로 기본 네비게이션 링크를 활용해서 코드를 재구성한다.

CustomNavigationLink

import SwiftUI

struct CustomNavLink<Label: View, Destination: View>: View {
    
    let destination: Destination
    let label: Label
    
    init(@ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label) {
        self.destination = destination()
        self.label = label()
    }
    
    var body: some View {
    //기본 네비게이션 링크로 감싸주고
        NavigationLink {
            //커스텀 네비게이션 컨테이너 뷰 안에서
            CustomNavBarContainerView {
                destination
            }
            //기본 네비바영역 숨겨줌
            .navigationBarHidden(true)
        } label: {
            label
        }
    }
}

사용예시

struct AppNavBarView: View {
    var body: some View {
        CustomNavView {
            ZStack {
                Color.green.opacity(0.2).ignoresSafeArea()
                CustomNavLink {
                    Text("DESTINATION")
                        .customNavigationTitle("SECONDSCREEN")
                        .customNavigationSubtitle("SECONDSUBTITLE")
                } label: {
                    Text("NAVIGATE")
                }

            }
            .customNavigationTitle("CUSTOMTITLE")
            .customNavigationSubtitle("SUBTITLE")
            .customNavigationBarBackButtonHidden(true)
        }
    }
}