NEON의 이것저것

[SwiftUI] 화면별 제스처를 통한 뒤로가기 활성화 여부 제어하기 본문

개발지식 정리/Swift

[SwiftUI] 화면별 제스처를 통한 뒤로가기 활성화 여부 제어하기

Neoself 2025. 6. 17. 21:43

1. 개요

iOS 앱 개발에서 화면 전환 시 탭바의 표시/숨김과 스와이프 백 제스처의 활성화/비활성화는 중요한 UX 요소입니다.

SwiftUI를 활용해 앱을 고도화하게 될 경우, 대부분은 SwiftUI 프레임워크에서 기본으로 제공하는 navigationBar 대신 커스텀 제작한 헤더를 통해 화면전환을 제어하게 될 것입니다. 하지만 이의 경우 아래 화면과 같이 SwiftUI의 기본 네비게이션 헤더와 중복되는 상황이 발생하게 됩니다.

 

(좌) .navigationBarBackButtonHidden(false) / (우) .navigationBarBackButtonHidden(true)

 

이때, .navigationBarBackButtonHidden(true)를 통해 기본 네비게이션 헤더 가리기를 수행하면 1차적으로 위 문제를 해결할 수 있습니다. 하지만, 해당 뷰 수정자를 적용시키면, 화면 좌측 끝에서 우측으로 스와이프할때 이전 화면으로 전환되는 PopGesture가 동작하지 않게 됩니다.

왜죠?

SwiftUI의 NavigationView와 NavigationStack은 내부적으로 UIKit의 UINavigationController를 사용하기 때문에, 내부구현코드를 통해 원인을 분석해보고자 했으나, 이는 공개되지 않았기에 파악이 불가했습니다. 다만 백 버튼이 숨겨진 상황은 뒤로 가면 안되는 상황임을 전달하기 위한 애플의 설계철학이 반영된 결정임을 추론해볼 수 있었습니다.

 

PopGesture의 무조건적 차단은 사용자 경험 측면에서는 바람직하지 않을 수 있습니다. 회원가입이나 결제와 같은 중요한 프로세스에서는 치명적인 오류로 이어질 수 있는 뒤로가기를 차단하기 위해 스와이프 백을 비활성화해야 하지만, 일반적인 사용 플로우에서는 스와이프 백 제스처를 유지하면서 뒤로가기 버튼만 숨기고 싶은 경우가 있습니다.

 

이러한 세밀한 제어를 위해 커스텀 구현이 필요하게 되었습니다.

 

2. View Modifier의 개념

SwiftUI의 View Modifier는 뷰의 동작과 외관을 수정하는 재사용 가능한 컴포넌트입니다. Modifier는 다음과 같은 특징을 가집니다:

1. 체이닝 가능: 여러 Modifier 연속적 적용

2. 재사용성: 동일한 수정 여러 뷰에 적용

3. 캡슐화: 관련된 로직 하나의 컴포넌트로 추상화

*추상화: 복잡한 세부사항을 숨기고, 핵심적이고 본질적인 특성만을 드러내는 과정

 

3. 스와이프 백 제어 구현

3.1 PopGestureManager 구현

스와이프 백 제스처의 상태를 관리합니다. 싱글톤 패턴을 사용해 앱 전체에서 일관된 상태를 유지하고자 했습니다.

@MainActor
final class PopGestureManager {
    static let shared = PopGestureManager()
    
    private init() {}
    
    private(set) var isSwipeBackDisabled = false
    
    func updateSwipeBackDisabled(to bool: Bool) {
        isSwipeBackDisabled = bool
    }
}

 

3.2 SwipeBackDisabledViewModifier 구현

이후 SwiftUI의 뷰 생명주기를 접근하기 위해 ViewModifier를 새로 생성한 후, onAppear 뷰 수정자에 앞서 생성한 PopGestureManager을 호출해, 뷰가 나타날 때 ViewModifier를 통해 주입한 제스처의 활성화 여부값으로 동적으로 변경되게끔 설계했습니다.

struct SwipeBackDisabledViewModifier: ViewModifier {
    let isDisabled: Bool
    
    func body(content: Content) -> some View {
        content
            .onAppear {
                PopGestureManager.shared.updateSwipeBackDisabled(to: isDisabled)
            }
    }
}

 

3.3 UINavigationController 확장

마지막으로, 실제 스와이핑 제스처에 대한 차단 여부를 조작하기 위해 UINavigationController의 확장을 새로 구현했습니다. 해당 확장에서는 UINavigationController의 interactivePopGestureRecognizer 속성의 delegate를 자신으로 설정한 후, UIGestureRecognizerDelegate 프로토콜의 gestureRecognizerShouldBegin 메서드를 구현하여 제스처 시작 여부를 제어했습니다. 이 메서드 내부에서는 PopGestureManager에서 관리하는 isSwipeBackDisabled 불린값과 현재 네비게이션 스택의 뷰컨트롤러 개수를 확인하여, 스와이프 백 제스처 활성화 여부를 화면 단위로 동적 변경할 수 있게 구현했습니다.

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

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return !PopGestureManager.shared.isSwipeBackDisabled && viewControllers.count > 1
    }
}

 

 

3.4. View 확장자에 ViewModifier 연결

import SwiftUI
import UIKit

extension View {
    func swipeBackDisabled(_ isDisabled: Bool) -> some View {
        modifier(SwipeBackDisabledViewModifier(isDisabled: isDisabled))
    }
}

마지막으로, SwiftUI의 View 확장을 통해 커스텀 ViewModifier를 편리하게 사용할 수 있는 헬퍼 메서드를 구현해, SwiftUI 뷰에서 메서드 체이닝 방식으로 간편하게 스와이프 백 제스처 비활성화 기능을 적용할 수 있게 되었습니다.

 

이제, 각 화면에 대해 아래와 같이 뷰수정자 추가를 통해 손쉽게 백스와이핑을 제어할 수 있게 됩니다.

struct ExampleView: View {
    
    var body: some View {
        VStack {
            headerSection
            ...
        }
        .swipeBackDisabled(true) // 해당화면에서는 스와이핑 동작을 통한 뒤로가기 불가
    }
}

 

하지만, 프로젝트 규모가 커짐에 따라 화면이 늘어날때마다 각 화면에 대한 구현 파일에서 해당 뷰 수정자 상태를 일일히 변경하는 것은 불필요한 리소스 낭비로 이어질 것이라 판단했고, 휴먼에러 발생 가능성과도 이어질 수 있을 것이라 생각했습니다. 때문에, 이전에 글로 공유드렸던 Navigation Router 패턴에 이를 조합하여, 화면 별 백스와이핑 제어를 중앙화하는 구조를 고안하게 되었습니다.

특히 전환대상 화면에 대한 정보를 담는 Route Enum에서 disableSwipeBack이라는 연관 속성을 필수로 포함시키게끔 구조를 설계해, 새로운 화면이 추가될 때마다 해당 화면의 Route를 정의하면서 자연스럽게 백 제스처에 대한 설정값도 포함시킬 수 있도록 했습니다.

 

 

 

4. 라우팅 시스템과의 통합

import SwiftUI

struct MainNavigationStack: View {
    @StateObject private var router = AppRouter()
    
    var body: some View {
        NavigationStack(path: $router.path) {
            MainTabView()
                .navigationDestination(for: AppRoute.self) { route in
                    destinationView(for: route)
                        .swipeBackDisabled(route.disableSwipeBack) // 스와이프 백 제스처 제어
                }
        }
        .environmentObject(router)
    }
    
    @ViewBuilder
    private func destinationView(for route: AppRoute) -> some View {
        switch route {
        case .homeDetail:
            HomeDetailView()
        case .profileEdit:
            ProfileEditView()
        case .settings:
            SettingsView()
        case .listView:
            ExampleListView()
        case .detailView(let id):
            ExampleDetailView(id: id)
        case .formView:
            ExampleFormView()
        }
    }
}

기존에 정의했던 NavigationStack 구조체에 정의된 navigationDestination 내부에 뷰 수정자를 추가함으로써, 파일에 대한 이동 없이 단일 파일에서 백 제스처에 대한 제어를 쉽게 할 수 있게 됩니다.

 

감사합니다.