-
SwiftUI에서 인터랙티브 푸시 네비게이션 사용하기SwiftUI 2024. 12. 12. 18:46
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 SwiftUI에서 쉽게 인터랙티브 푸시 네비게이션을 사용하는걸 구현해보려고 합니다 🙋🏻
먼저 인터랙티브 푸시 네비게이션도 다양한 쓰임이 있을건데, 현재 제가 하고자 하는 결과물부터 공유해볼께요!
두둥 - 😃
이런걸 해보려고 합니다!
즉, 탭뷰와 같은걸 사용하지 않고 엣지 스와이프를 통해 이전과 이후 뷰로 슬라이드 형식으로 자연스럽게 전환되도록 인터랙티브 푸시 네비게이션을 SwiftUI에 얹어보려고 합니다 🙋🏻
그럼 한번 시작해볼까요?
인터랙티브 푸시 네비게이션 구현하기
먼저 제가 하는 방식으로는 SwiftUI에서만 모든걸 해결할 수 없어요.
그렇기에 UIKit의 UINavigationController를 채택한 별도의 InteractivePushNavigationController 클래스를 만들어서 그걸 뷰 모디파이어화 시켜 SwiftUI에서 사용하려고 합니다.
그럼 먼저 클래스부터 만들어볼까요?
InteractivePushNavigationController
class InteractivePushNavigationController: UINavigationController { private var interactivePushGestureRecognizer: UIScreenEdgePanGestureRecognizer? private var percentDrivenInteractiveTransition: UIPercentDrivenInteractiveTransition? private var destinationViewController: UIViewController? private let pushAnimator = InteractivePushAnimator() private static let gestureVelocityThreshold: CGFloat = 500.0 override func viewDidLoad() { super.viewDidLoad() navigationBar.isHidden = true interactivePopGestureRecognizer?.delegate = self interactivePopGestureRecognizer?.isEnabled = true } func setupInteractivePush(to viewController: UIViewController) { destinationViewController = viewController delegate = self let gesture = UIScreenEdgePanGestureRecognizer( target: self, action: #selector(handleInteractivePushGesture(_:)) ) gesture.edges = .right gesture.delegate = self view.addGestureRecognizer(gesture) interactivePushGestureRecognizer = gesture } @objc private func handleInteractivePushGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) { guard let view = self.view else { return } let progress = abs(-gestureRecognizer.translation(in: view).x / view.frame.width) switch gestureRecognizer.state { case .began: guard let destination = destinationViewController else { return } pushViewController(destination, animated: true) case .changed: percentDrivenInteractiveTransition?.update(progress) case .ended: let velocity = gestureRecognizer.velocity(in: view).x if velocity < 0.0 && (progress > 0.5 || velocity < -Self.gestureVelocityThreshold) { percentDrivenInteractiveTransition?.finish() } else { percentDrivenInteractiveTransition?.cancel() } default: percentDrivenInteractiveTransition?.cancel() } } } extension InteractivePushNavigationController: UINavigationControllerDelegate { func navigationController( _ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController ) -> UIViewControllerAnimatedTransitioning? { return operation == .push ? pushAnimator : nil } func navigationController( _ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning ) -> UIViewControllerInteractiveTransitioning? { if interactivePushGestureRecognizer?.state == .began { percentDrivenInteractiveTransition = UIPercentDrivenInteractiveTransition() percentDrivenInteractiveTransition?.completionCurve = .easeOut return percentDrivenInteractiveTransition } return nil } } extension InteractivePushNavigationController: UIGestureRecognizerDelegate { func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer === interactivePopGestureRecognizer { return viewControllers.count > 1 } if gestureRecognizer === interactivePushGestureRecognizer { guard let destination = destinationViewController else { return false } return topViewController !== destination && !viewControllers.contains(destination) } return false } }
코드가 복잡해보이지만 하나씩 살펴보면 크게 어려운게 없습니다.
먼저 주요 프로퍼티들을 살펴볼께요.
1️⃣ interactivePushGestureRecognizer - 화면 우측 가장자리의 제스처를 인식하는 객체
2️⃣ percentDrivenInteractiveTransition - 제스처 기반 전환 진행률을 관리하는 객체
3️⃣ destinationViewController - 전환될 목적지의 뷰 컨트롤러
4️⃣ pushAnimator - 전환 애니메이션을 담당하는 객체 (이 해당 클래스 구현은 아래에서 다룹니다.)
5️⃣ gestureVelocityThreshold - 제스처 속도 임계값 (설정하기 나름이고 현재는 1초당 500)
이렇게 프로퍼티들을 가지고 로직을 구현하면 됩니다.
먼저 viewDidLoad 메서드에서 네비게이션 바를 숨겨주고 기본적으로 제공하는 뒤로가기 제스처에 대해 활성화 시킬 수 있도록 delegate 설정을 해줍니다.
그 다음, setupInteractivePush 메서드를 살펴보면 목적지 뷰 컨트롤러를 설정해줘요.
우측 가장자리에서 제스처 즉, 스와이프 엣지 제스처를 인식할 수 있도록 해당 제스처 인식기를 생성하고 설정해주는 역할을 하는 메서드입니다.
그리고 handleInteractivePushGesture 메서드에서는 제스처의 이동 거리를 화면 넓이로 나눠 진행률을 계산해줍니다.
왼쪽으로 이동하기에 음수 값을 절대 값으로 처리해줍니다.
state 상태를 가지고 began, changed, ended의 케이스에서 로직을 담아줘요.
began에선 제스처가 시작 시 목적지 화면으로 전환이 시작되도록 합니다.
changed에선 제스처 진행 중에 전환 진행률을 업데이트 해주는 역할을 해요.
그리고 ended에선 제스처가 종료되었을 시 현재 어느정도 제스처가 이뤄졌는지 진행률을 판단해 이전 뷰 혹은 다음 뷰로 전환할지를 결정합니다.
그리고 두번째로, UINavigationControllerDelegate를 채택하여 확장시킵니다.
여기서는 push 동작일때만 커스텀 애니메이터를 사용하도록하고 제스처가 시작되면 인터랙티브 전환 컨트롤러를 생성하는 메서드 두개를 구현해줘요.
마지막으로 UIGestureRecognizerDelegate를 채택하여 확장시킨 곳이 보이죠?
여기선 뒤로가기 제스처의 동작이 현재 쌓인 뷰 컨트롤러가 2개 이상일때만 동작시켜줄 수 있도록 로직을 다뤄주고 우측 엣지 스와이프의 동작에서 목적지가 현재 화면이 아니고 네비게이션 스택에 없을 때만 동작하여 전환될 수 있도록 로직을 구성해줍니다.
이렇게하면 우리가 원하는 인터랙티브 푸시 네비게이션 사용을 위한 클래스가 만들어졌습니다!
그럼 다음으로 우리 커스텀한 애니메이터 구현을 미뤘는데 구현해봐야겠죠?
InteractivePushAnimator
class InteractivePushAnimator: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return 0.3 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromView = transitionContext.view(forKey: .from), let toView = transitionContext.view(forKey: .to) else { return } let containerView = transitionContext.containerView let screenWidth = containerView.frame.width toView.frame = containerView.frame.offsetBy(dx: screenWidth, dy: 0) containerView.addSubview(toView) let duration = transitionDuration(using: transitionContext) UIView.animate( withDuration: duration, delay: 0, options: .curveEaseInOut, animations: { fromView.frame = containerView.frame.offsetBy(dx: -screenWidth * 0.3, dy: 0) toView.frame = containerView.frame }, completion: { finished in if transitionContext.transitionWasCancelled { toView.removeFromSuperview() } transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } ) } }
NSObject를 상속받고 UIViewControllerAnimatedTransitioning 프로토콜을 채택하여 구현합니다.
해당 프로토콜은 뷰 컨트롤러 간의 커스텀 전환 애니메이션을 정의할 때 사용됩니다.
먼저 transitionDuration 메서드에서 전환 시간을 설정해줘요.
전환 애니메이션의 지속 시간을 저는 0.3초로 설정했습니다.
해당 메서드는 프로토콜의 필수 구현 메서드에요.
다음으로 animateTransition 메서드를 구현해야 합니다.
실제 전환 애니메이션의 구체적인 동작을 정의하는 역할을 해줍니다.
먼저 fromView인 현재 화면의 뷰와 toView인 목적지 화면의 뷰를 안전하게 guard문으로 옵셔널 바인딩을 해줍니다.
그리고, 컨테이너 뷰를 설정합니다.
toView를 화면 오른쪽에 위치시키고 화면 넓이만큼 x축을 이동시켜주는거죠.
그 toView를 컨테이너에 추가합니다.
그 후 애니메이션 실행 로직이 있어요.
0.3초 동안 애니메이션을 실행하고 easeInOut으로 자연스러운 가속/감속 효과를 가져갑니다.
애니메이션을 보면 fromView는 왼쪽으로 화면 넓이의 30%만큼 이동하고 toView는 우측에서 중앙으로 이동하는 형태죠.
그리고 completion을 마지막으로 볼께요.
전환이 취소되면 toView를 제거해줘요.
그리고 전환 컨텍스트에 완료 상태를 알려줍니다.
취소가 되지 않았으면 true, 취소 되었으면 false로 넘겨주겠죠?
이렇게 애니메이션을 구현해야지만, 기본적으로 제공하는 push 전환 애니메잉션처럼 현재 화면이 왼쪽으로 밀리면서 우측에서 새로운 화면이 나오는 슬라이딩 효과를 구현해낼 수 있어요!
자 그럼 이제 해당 클래스를 가지고 우리는 SwiftUI에서 사용할 수 있도록 만들어 볼까요?
InteractivePushNavigationView
struct InteractivePushNavigationView<Content: View>: UIViewControllerRepresentable { let content: Content let destinationView: AnyView init<D: View>(content: Content, destination: D) { self.content = content self.destinationView = AnyView(destination) } func makeUIViewController(context: Context) -> InteractivePushNavigationController { let hostingController = UIHostingController(rootView: content) let navigationController = InteractivePushNavigationController(rootViewController: hostingController) let destinationController = UIHostingController(rootView: destinationView) navigationController.setupInteractivePush(to: destinationController) return navigationController } func updateUIViewController(_ uiViewController: InteractivePushNavigationController, context: Context) {} }
뷰 모디파이어 안에 들어가게 하기 위해 UIKit 세상의 클래스를 UIViewControllerRepresentable을 채택해 구조체로 만듭니다.
여기서 destinationView는 전환될 목적지 화면의 SwiftUI의 뷰가 될 것이고 content는 현재 화면의 SwiftUI 뷰가 됩니다.
AnyView를 사용한 이유는 어떤 타입의 뷰도 받을 수 있도록 하기 위함입니다.
이렇게 두 뷰를 받아 초기화를 시켰어요.
이제 makeUIViewController 메서드를 통해 SwiftUI 뷰를 UIKit 뷰 컨트롤러로 변환하는 과정을 거칩니다.
1️⃣ 현재 화면의 SwiftUI 뷰를 UIHostingController로 래핑
2️⃣ 커스텀 네비게이션 컨트롤러를 생성하고 루트 뷰 컨트롤러로 설정
3️⃣ 목적지 SwiftUI 뷰도 UIHostingController로 래핑
4️⃣ 네비게이션 컨트롤러에 인터랙티브 푸시 설정
그리고 updateUIVIewController 메서드도 필수 구현이라 넣었지만, 특별히 업데이트가 필요없기에 빈 구현체로 둡니다.
이제 해당 구조체는 UIKit의 커스텀 인터랙티브 푸시 네비게시연을 SwiftUI에서 사용할 수 있게 해주는 브릿지 역할을 하게 됩니다.
해당 구조체를 바로 SwiftUI 뷰에서 사용할 수도 있겠지만, 조금 더 쓰기 쉽게 모디파이어로 제공해볼까요?
ViewModifier
extension View { func interactivePushDestination<D: View>(destination: D) -> some View { InteractivePushNavigationView(content: self, destination: destination) .ignoresSafeArea() } }
단순하게 구현해줄 수 있겠죠?
이제 SwiftUI 세상에서 사용해볼께요!
SwiftUI View
import SwiftUI struct ContentView: View { var body: some View { ZStack { Color.blue.opacity(0.3) Text("First View") .font(.largeTitle) } .ignoresSafeArea() .interactivePushDestination(destination: DestinationView()) } } struct DestinationView: View { var body: some View { ZStack { Color.green.opacity(0.3) Text("Second View") .font(.largeTitle) } .ignoresSafeArea() } }
첫번째 뷰인 ContentView에서 단순히 interactivePushDestination 모디파이어를 붙여주고 destination에 전환시킬 뷰를 넣어주기만 하면 끝입니다.
아주 간단하고 편하게 적용할 수 있겠죠?ㅎㅎ
마무리
필요에 의해서 사부작 만들어봤는데 다양하게 쓰일 수 있을것 같아요!
레퍼런스
'SwiftUI' 카테고리의 다른 글
SwiftUI - 키보드 노출 여부에 따른 뷰 오프셋 조정 (30) 2024.11.29 SwiftUI - refreshable ScrollView (26) 2024.11.22 Pagination (feat. SwiftUI & MVVM) (18) 2024.11.15 SwiftUI로 테두리 오버레이 효과 만들기 (feat. ZStack) (5) 2024.11.14 SwiftUI의 Text에 stroke 적용하기 (2) 2024.11.07