Search
Duplicate
🛠️

ViewController는 죄가 없다 - 3

Created
2019/05/24
Tags
Architecture
Programming

ViewController에게 Navigation을 맡기지 마세요

“버튼이 눌렸을 때 다음 화면을 보여준다”는 기능을 구현해봅시다. 이렇게 하면 되겠죠?
@IBAction func buttonClicked(_ sender: Any?) { let nextViewController = NextViewController() nextViewController.importantInfo = self.importantInfo nextViewController.anotherInfo = self.anotherInfo navigationController?.push(nextViewController) }
Swift
복사
복잡 할 것이 없는 코드입니다. 여기까진 말이죠. 하지만 현실에서, 이런 종류의 접근방식은 ViewController의 크기를 순식간에 늘려 버립니다.
만약 버튼을 눌렀을 때, 로그인이 안 되어있다면 NextViewController가 LoginViewController를 보여줘야 한다면 어떨까요?
class MyViewController: UIViewController { @IBAction func buttonClicked(_ sender: Any?) { if user.isLoggedIn { let nextViewController = NextViewController() nextViewController.importantInfo = self.importantInfo nextViewController.anotherInfo = self.anotherInfo navigationController?.push(nextViewController) } else { let loginVC = LoginViewController() loginVC.someContext = someContext navigationControler?.push(loginVC) } } }
Swift
복사
여기에 로직이 더 추가되면 어떻게 될까요? 앞선 ViewController에 따라 NextViewController가 달라져야 한다면? 혹은 시간대나 위치에 따라 달라져야한다면? 등등…
로직이 추가되면 될 수록, 이 네비게이션 코드는 아주 많이 길어 질 수 있습니다. 아마 이 글을 읽고 있는 많은 분들의 ViewController에도 비슷하게 짧지 않은 네비게이션 관련 코드들이 있을테지요.
하지만 ViewController가 아니라면, 누가 Navigation코드를 담당 할 수 있을까요?
이에 대해 Coordinator패턴등 의 디자인패턴도 좋은 대안이 되겠지만, 제게는 더 Obvious한 대안이 떠오릅니다. 바로 UINavigationController지요!

Navigation은 NavigationController에게!

위의 코드를 리팩토링해 보겠습니다.
class MyNavigationController: UINavigationController { func showLoginViewController(with context:Context) { let loginVC = LoginViewController.storyboardInstance loginVC.context = context push(loginVC) } func showNextViewController(with foo:Foo, bar: Bar) { let nextVC = NextViewController.storyboardInstance nextVC.foo = foo nextVC.bar = bar push(nextVC) } } class MyViewController: UIViewController { var myNavVC: MyNavigationController { return navigationController as! MyNavigationController } @IBAction func buttonClicked(_ sender: Any?) { if user.isLoggedIn { myNavVC.showLoginViewController(with: context) } else { myNavVC.showNextViewController(with: foo, bar: bar) } } }
Swift
복사
@IBAction 의 코드가 확연히 줄어든 것을 볼 수 있습니다. 그래도 전체적인 코드의 양 자체는 비슷한 거 아니냐구요? 그렇게 생각할 수도 있습니다. 하지만 이렇게 리팩토링한 결과, 우리는 여러가지 효과를 누릴 수 있습니다.
1.
MyViewController는 Navigation관련 코드를 콜하긴 하지만, 그 구체적인 방법에 대해서는 알 필요가 없게 됩니다. 위의 예시에서는 NextViewController() 와 같은 방법으로 NextViewController를 초기화 했지만, 사실 xib로 초기화 할 수도 있는 거고, 경우에 따라 navigationStack에 이미 있었던 인스턴스를 재활용 할 수도 있죠. 그 모든 책임이 이제 UINavigationController에게 넘어갑니다.
2.
가독성이 훨씬 좋아졌습니다. 코드리뷰하는 입장에서, “이 부분의 Navigation로직은 어디쯤에 있는거지?”라는 생각이 들 때, 기존에는 ‘MyViewController’를 쭉 훑어야 했지만, 이제는 “Navigation 로직은 NavigationController에 있겠지”라는 생각으로 관련 코드를 훨씬 빠르게 발견하고 읽을 수 있습니다.
특히, Push Notification의 userInfo를 바탕으로 네비게이션을 하는 경우에, 이런 접근은 진가를 발휘하게 됩니다. 먼저 저희 팀에서 기존에 Push Noti정보로 네비게이션을 하는 코드는 다음과 같았습니다.
class AppDelegate: UIApplicationDelegate { func application(_ app: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler _: @escaping (UIBackgroundFetchResult) -> Void) { let pageNavigator = PageNavigator.shared pageNavigator.targetPageInfo = userInfo rootNavigationController.viewControllers = [rootNavigationController.viewControllers[0]] } } class FirstViewController: UIViewController { override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated:animated) if PageNavigator.shared.targetPageInfo["destination"] != FirstViewController.className { let nextVC = SecondVC.storyboardInstance navigationController?.push(nextVC) } } } class SecondViewController: UIViewController { override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated:animated) if PageNavigator.shared.targetPageInfo["destination"] != SecondViewController.className { let nextVC = ThirdViewController.storyboardInstance navigationController?.push(nextVC) } } }
Swift
복사
즉, 모든 ViewController의 ViewWillAppear에서 Singleton인 PageNavigator 에 저장된 userInfo를 확인하고, 다음 목적지를 결정하는 방식이었죠.
하지만 이 모든 로직을 UINavigationContrller에 넣게 되면 이렇게 쓸 수 있게 됩니다.
class AppDelegate: UIApplicationDelegate { func application(_ app: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler _: @escaping (UIBackgroundFetchResult) -> Void) { rootNavigationController.navigate(with: userInfo) } } class MyNavigationController: UINavigationController { func navigate(with userInfo: [String: AnyObject?] { let destination = userInfo["destination"] if destination == ThirdViewController.className { viewControllers = [FirstViewController.storyboardInstance, SecondViewController.storyboardInstance, ThirdViewController.storyboardInstance] } else if /// 등등... } }
Swift
복사
이렇게 되면 PageNavigator라는 싱글톤을 쓸 필요도, 각각의 ViewController들이 userInfo로 네비게이션을 하는 로직에 대해 알 필요도 없게 됩니다.
또, 더 상세한 컨트롤이 필요한 경우에는 UINavigationControllerDelegate 를 채택한 객체를 만들 수도 있죠. 그렇게 되면
func navigationController(UINavigationController, willShow: UIViewController, animated: Bool) func navigationController(UINavigationController, didShow: UIViewController, animated: Bool)
Swift
복사
같은 이벤트를 들을 수 있게 되므로, 각각의 ViewController의 viewWillAppear 등에 넣어 놓았던 네비게이션 로직들을 담당시키게 할 수 도 있습니다.
한 편 navigationBar의 setNavigationBarHidden(_:animated:) 메소드를 쓸 때는 가급적 func navigationController(UINavigationController, willShow: UIViewController, animated: Bool)함수와 쓰는게 좋습니다. 그렇지 않으면 navigationBar의 애니메이션이 따로 놀거나 깜빡이는 현상을 발견 할 수도 있습니다.

맺으며

물론 여러 탭바 사이의 네비게이션이나, 모달 위에서 별도로 진행되는 navigation까지 하나의 NavigationController가 모두 담당할 수는 없습니다. 하지만 적어도 각각의 Linear한 네비게이션 플로우들은 UINavigationController에 맡겨보세요. 예컨대 Login 플로우의 네비게이션은 LoginNavigationController가 맡고, 회원탈퇴 플로우의 네비게이션은 UnRegisterNavigationController가 맡는 식으로 말이죠. 이 정도만 해도 ViewController의 크기는 몰라보게 줄어들 겁니다.