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

thinkySide·2024년 12월 20일
0
post-thumbnail

본 포스팅은 애플 디벨로퍼 아카데미 @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을 이용해 명시적으로
여러 가지 설정을 해주어야 하는 상황에 자주 놓이게 되는 것 같습니다. (아직은 말이죠!)

더 어려운 문제를 해결하기 위해선, 두 가지 프레임워크에 대한
적정 수준의 이해도가 필요함을 다시 한번 느끼게 됩니다.

더 좋은 방법이 있거나, 개선할 수 있는 방향이 있다면 함께 논의하고 싶습니다! 감사합니다! 😃

profile
UX 한스푼 넣은 iOS 디발자 한톨 / Apple Developer Academy @POSTECH 3기

0개의 댓글