iOS/내 방식대로 풀어보기

뒤로가기 버튼과 함께 제스쳐도 사라진 것에 대하여 in SwiftUI

thinkySide 2025. 2. 16. 10:16

본 포스팅은 애플 디벨로퍼 아카데미 @POSTECH 테크 포럼 이벤트,
'기술 글자랑 대회'의 게시글을 가져와 작성되었습니다.

안녕하세요, 3기 주니어 러너 한톨입니다! 😇 오늘은 제가 SwiftUI에서 Navigation 기능을 구현할 때 자주 마주쳤었던 문제 해결 과정을 간단하게 공유해 보려 합니다. 작은 내용이지만 누군가에게 도움이 되길 바람과 동시에, 더 좋은 해결 방법을 함께 고민해 봤으면 합니다!

 

SwiftUI의 NavigationBar 커스텀하기

SwiftUI는 기본적으로 Navigation 기능을 제공합니다. NavigationStack 계층 내부에 NavigationLink 를 넣거나, navigationDestination(for:destination:) modifier를 추가함으로써 다음 화면에 대한 정의 및 기본 NavigationBar를 생성할 수 있습니다.

import SwiftUI

struct ContentView: View {

    @State private var isButtonViewPresented = false

    var body: some View {
        NavigationStack {

            // 1. NavigationLink 방식
            NavigationLink("NavigationLink") {
                AView()
            }

            // 2. navigationDestination(for:destination:) modifier 방식
            Button("Button") {
                isButtonViewPresented.toggle()
            }
            .navigationDestination(isPresented: $isButtonViewPresented) {
                BView()
            }
        }
    }
}

사용하기 쉽고 모두에게 익숙한 NavigationBar 가 자동으로 생성되었습니다. 그와 함께 Back 버튼과 Swipe 제스처도 함께 구현되었네요. SwiftUI가 제공하는 기본 NavigationBar 는 기능상으로 큰 문제 없이 사용이 가능합니다.

 

하지만 우리는, 다르게 사용해야 할 상황에 꽤나 자주 놓이곤 합니다. UX/UI 디자인과 새로운 기능을 추가하는 등의 요구사항을 반영하려면, Custom NavigationBar의 필요성을 자연스레 느끼게 되죠.

저 또한 Custom NavigationBar 의 필요성을 느끼게 되었고
이를 프로젝트에 녹여내며 마주했던 문제를 예제 코드 + 실제 프로젝트와 함께 정리해 보겠습니다.

 

🙋🏻‍♂️ 문제 1. NavigationBar가 두개 생겼어요!

Custom NavigationBar 를 구현하며 마주한 첫 번째 문제를 아래 코드와 함께 살펴보겠습니다.

struct CustomNavigationBar: View {

    let title: String
    let backButtonAction: () -> Void

    var body: some View {
        HStack {
            Button {
                backButtonAction()
            } label: {
                Image(systemName: "chevron.left")
                    .font(.system(size: 24, weight: .bold))
            }

            Spacer()

            Text(title)
                .font(.system(size: 17, weight: .semibold))

            Spacer()

            Text("🥰")
        }
        .frame(height: 56)
    .padding(.horizontal, 16)
    .foregroundStyle(.white)
    .background(.black)
    }
}

먼저 뒤로가기 버튼, 네비게이션 제목, 귀여운 이모지로 구성한 Custom NavigationBar 예제 코드입니다. 사용은 아래와 같이 가능합니다.

struct CustomView: View {

    @Environment(\.dismiss) private var dismiss

    var body: some View {

        VStack {
            CustomNavigationBar(title: "Custom Navigation") {
                dismiss()
            }

            Spacer()

            Text("CustomView")

            Spacer()
        }
    }
}

VStack 을 사용해 CustomNavigationBar 를 최상단에 올려주었고, 뒤로가기 액션을 실행하기 위해 closure 내부에서 dismiss 환경 변수를 사용했습니다.

눈치채셨겠지만 이렇게 작성하게 되면 NavigationBar가 두 개가 생겨버립니다.

  1. NavigationStack에서 생성하는 기본 NavigationBar
  2. 우리가 직접 구현한 CustomNavigationBar

다행히도 SwiftUI는 이를 해결할 수 있게 modifier를 제공하고 있었습니다.

private struct CustomView: View {

    @Environment(\.dismiss) private var dismiss

    var body: some View {

        VStack {
            CustomNavigationBar(title: "Custom Navigation") {
                dismiss()
            }

            Spacer()

            Text("CustomView")

            Spacer()
        }
        // ⛳️ NavigationBar의 뒤로가기 버튼을 숨겨줍니다!
        .navigationBarBackButtonHidden() 
    }
}

첫 번째 문제가 쉽게 해결되었습니다. 이제 CustomNavigationBar만 화면에 잘 출력되고 있네요!

 

🙋🏻‍♀️ 문제 2. 뒤로가기 제스처가 안 먹혀요,,,

하지만 늘 그렇듯 문제는 여기서 끝나지 않았습니다. UI 테스트 중 곧바로 부자연스러움을 느끼게 되었는데, Navigation의 뒤로가기 제스처가 동작하지 않는 문제였습니다. 즉 navigationBarBackButtonHidden modifier는 NavigationBar와 함께, 뒤로가기 제스처도 함께 비활성화하고 있었습니다.

 

iOS 사용자 입장에서 친숙한 뒤로가기 제스처가 동작하지 않는 것은 사용자 경험을 떨어트리기에 충분했고, 이는 꼭 해결해야 하는 문제라 판단했습니다. 그렇게 공식 문서와 인터넷을 떠돌다 찾게 된 해결 방법은 아래와 같았습니다. (from StackOverflow)

import UIKit

extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
    open override func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return viewControllers.count > 1
    }
}

UIKit의 UINavigationController 를 확장(extension)해 제스처를 활성화하는 코드입니다. 위 코드 적용 시, 뒤로가기 제스처가 다시 돌아온 것을 확인할 수 있었습니다!

넘어가기 전, 위 코드의 동작 방식에 대해 명확히 이해하고 넘어가 보려 합니다.

extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
    //
}
  1. UINavigationController 확장(extension)
    • SwiftUI에서 Navigation 사용 시, 외부에 노출되어 있진 않지만(감싸져 있지만)
      내부에선UIKit의 UINavigationController 를 관리하고 있습니다.
    • 즉, 이를 확장함으로써 전체 Navigation의 기능을 제어 및 추가할 수 있게 됩니다.
  2. UIGestureRecognizerDelegate 프로토콜 채택
    • 앱의 제스처 인식 동작을 미세 조정(fine-tune)할 수 있게 돕는 Delegate 프로토콜입니다.
  3. @retroactive 키워드의 의미 (번외!)
    • Swift6에서 도입된 키워드로, 다른 모듈 등에서 같은 프로토콜을 중복으로 채택할 때
      경고를 띄워주는 역할을 수행합니다.
open override func viewDidLoad() {
    super.viewDidLoad()
    interactivePopGestureRecognizer?.delegate = self
}
  1. viewDidLoad LifeCycle override(재정의)
    • UINavigationController가 메모리에 로드 될 때,
      즉 로드 후 즉시 초기 설정을 진행하기 위한 LifeCycle 메서드를 재정의합니다.
  2. 뒤로가기 제스처(PopGesture) 인식기 대리자 설정
    • 뒤로가기 제스처의 조정을 위해 interactivePopGestureRecognizer 의 대리자(Delegate)를 설정합니다.
    • 이로써 뒤로가기 제스처에 대한 다양한 설정이 가능해집니다.
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    return viewControllers.count > 1
}
  1. gestureRecognizerShouldBegin(_:) Delegate 함수 구현
    • 제스처의 동작 가능 여부를 결정하는 UIGestureRecognizerDelegate 프로토콜의 옵셔널 메서드입니다.
    • Delegate 메서드의 이름에서 Should 키워드가 들어가게 되면 일반적으로 Bool 타입을 반환합니다.
    • true 를 반환하게 되면 뒤로가기 제스처가 활성화되고, false 를 반환하게 되면 제스처가 비활성화됩니다.
  2. viewController 개수에 따른 제스처 활성화 여부 결정
    • 위 코드에서 곧바로 true 를 반환하더라도, 동일하게 뒤로가기 제스처가 활성화됩니다.
      하지만 뒤로가기 제스처가 유효한 상황은, 항상 RootView에서 특정 View로 Push 된 상태여야 하기 때문에,
      viewController의 개수가 2개 이상이어야만 합니다.
    • 해당 코드 없이 RootView에서 뒤로가기 제스처 시도 시, 화면이 정지되는 버그가 발생합니다.
      정확한 디버깅은 어렵지만, 뒤로 갈 수 없는 상태에서 제스처를 호출했을 때 문제가 발생하는 것으로 추측하고 있습니다.

 

🙋🏻 문제 3. 모든 View에서 뒤로가기 제스처가 필요한 건 아닌데요!

처음엔 모든 NavigationView 에서 뒤로가기 제스처가 필요할 것으로 예상했습니다. 하지만 예상과 다르게 특정 화면에선 뒤로가기 제스처가 필요하지 않았고, 어떤 화면에선 꼭 막아야 하는 상황도 생겨났습니다.

 

아카데미 러너라면 누구나 사용할 수 있는 익명 기반 커뮤니티 앱, 캐플 프로젝트를 진행하며

(기습 홍보)

마주한 문제 상황은 아래와 같았습니다.

  1. 회원가입이 여러 번 될 수 있는 문제
    • 전체 약관 동의 후 다음 버튼을 탭할 시, 회원가입 요청 API가 전송됩니다.
    • 사용자가 그대로 시작하기 버튼을 탭하고 메인 화면으로 진입하는 경우, 문제가 발생하지 않습니다.
    • 하지만 뒤로가기 제스처를 이용해 이전 화면으로 돌아간 후 다음 버튼을 탭 하면 중복 회원가입이 발생할 수 있습니다.

  1. 텍스트 작성 중 의도하지 않은 제스처로 작업 내용이 소실될 수 있는 문제
    • 텍스트 작성 중 NavigationBar의 X 버튼을 탭할 시, 경고 알림 창을 통해 사용자에게 작업 내용이 소실될 수 있음을 인지시킵니다.
    • 하지만 뒤로가기 제스처를 이용해 이전 화면으로 돌아갈 시, 경고 알림 창 없이 그대로 작업 내용이 소실됩니다.
    • 이는 의도하지 않은 제스처 등으로 사용자가 실수하게 됐을 때, 부정적인 사용자 경험으로 이어질 수 있습니다.

즉, 기본적으로 NavigationView의 뒤로가기 제스처는 유지하되, 특정 View에서의 뒤로가기 제스처의 비활성 기능이 필요했습니다. 그렇게 여러 가지 방법을 시도 후 찾게 된 방법은 다음과 같습니다.

final class PopGestureManager {

    // Singleton 객체 생성
    static let shared = PopGestureManager()
    private init() {}

    // 뒤로가기 제스처를 허용하는지 확인 변수
    private(set) var isAllowPopGesture = true

    // 뒤로가기 제스처를 허용하는 변수 업데이트
    func updateAllowPopGesture(_ bool: Bool) {
        isAllowPopGesture = bool
    }
}
  1. 뒤로가기 제스처 활성화 여부를 관리하는 플래그 변수 isAllowPopGesture 를 선언합니다.
  2. 미리 구현해두었던 gestureRecognizerShouldBegin Delegate 메서드에서 위 값을 사용(접근)하기 위해, Singleton 객체를 생성합니다.
import UIKit

extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
    open override func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {

    // ⭐️ 2가지 조건 모두 만족했을 때 뒤로가기 제스처를 활성화 시킵니다!
        return PopGestureManager.shared.isAllowPopGesture && viewControllers.count > 1
    }
}
  1. gestureRecognizerShouldBegin Delegate 메서드 내에 뒤로가기 활성화 여부를 나타내는 조건을 추가합니다.
  2. 결과적으로 뒤로가기가 허용되어 있고 + viewController 의 개수가 2개 이상일 때 뒤로가기 제스처가 가능해집니다.
private struct DisableNavigationView: View {

    var body: some View {

        VStack {
            //
        }
        .navigationBarBackButtonHidden()
        .task {
            // ⭐️ 뒤로가기 제스처를 비활성화 시킵니다!
            PopGestureManager.shared.updateAllowPopGesture(false)
        }
    }
}
  1. 뒤로가기를 비활성화할 View에서 task modifier를 이용해 View가 나타나기 전에 플래그 변수를 설정합니다.

결과적으로, 특정 View에서 뒤로가기 제스처 비활성화에 성공했습니다! 하지만 걸리는 점이 있다면, 뒤로가기 제스처를 기본적으로 활성화할 View에서도 위 task modifer를 통해 매번 값을 업데이트 해주어야 한다는 것입니다.

DisableNavigationView()
    .task {
        PopGestureManager.shared.updateAllowPopGesture(false)
    }

NormalNavigationView()
    .task {
        // 🧐 모든 화면에서 이렇게 활성화 시켜줘야만 할까,,?
            PopGestureManager.shared.updateAllowPopGesture(true)
    }

이는 개발자가 실수로 modifier 구현을 잊을 수 있다는 말이기도 합니다. 위와 같은 실수를 방지하기 위해선, 뒤로가기 제스처를 비활성화해야 하는 View에서만 사용하는 것이 바람직해 보였습니다. 해당 문제를 해결하기 위해 화면이 사라진 후 호출이 가능한 onDisappear modifier를 사용해 값을 다시 돌려놓음으로써 뒤로가기 제스처를 활성화시키는 코드를 추가했습니다.

private struct DisableNavigationView: View {

    var body: some View {

        VStack {
            //
        }
        .navigationBarBackButtonHidden()
        .task {
            PopGestureManager.shared.updateAllowPopGesture(false)
        }
        .onDisappear {
            // ⭐️ 화면이 사라진 후 뒤로가기 제스처 값을 원래대로 돌려놓습니다!
            PopGestureManager.shared.updateAllowPopGesture(true)
        }
    }
}

마지막으로 일관성 및 재사용을 위해 ViewModifier 와 View 를 확장(extension) 해주었습니다.

// 1️⃣ ViewModifier 생성
struct PopGestureDisabledViewModifier: ViewModifier {

    func body(content: Content) -> some View {
        content
            .task {
                PopGestureManager.shared.updateAllowPopGesture(false)
            }
            .onDisappear {
                PopGestureManager.shared.updateAllowPopGesture(true)
            }
    }
}

// 2️⃣ View 확장
extension View {

    func popGestureDisabled() -> some View {
        modifier(PopGestureDisabledViewModifier())
    }
}

// 3️⃣ View에서 사용
private struct DisableNavigationView: View {

    var body: some View {

        VStack {
            //
        }
        .navigationBarBackButtonHidden()
        .popGestureDisabled() // ⭐️ 간단하게 modifier로 적용이 가능합니다!
    }
}

 

📚 정리하기

SwiftUI는 선언형 프레임워크로 Naivgation 등의 기본 기능을 쉽게 적용할 수 있습니다. 하지만 기존 기능을 변경하고 싶거나 새로운 기능을 추가하고 싶을 때, UIKit을 이용해 명시적으로 여러 가지 설정을 해주어야 하는 상황에 자주 놓이게 되는 것 같습니다. (아직은 말이죠!)

 

더 어려운 문제를 해결하기 위해선, 두 가지 프레임워크에 대한 적정 수준의 이해도가 필요함을 다시 한번 느끼게 됩니다. 더 좋은 방법이 있거나, 개선할 수 있는 방향이 있다면 함께 논의하고 싶습니다! 감사합니다! 😃