1) 화면 전환에 필요한 인스턴스 생성 (UIViewController, ViewModel 등)
2) 생성한 인스턴스의 종속성 주입 (DI)
3) 생성된 UIViewController의 화면 전환 (push or present)
import UIKit
protocol Coordinator: AnyObject {
var navigationController: UINavigationController { get set }
var childCoordinators: [Coordinator] { get set }
func start()
}
: 화면 전환에 필요한 UINavigationController
navigationController.pushViewController(vc, animated: true) // push
navigationController.present(vc, animated: true, completion: nil) //present
: 화면 전환시 생성될 하위 Coordinator를 저장할 때 사용합니다.
coordinator 생성 후 저장하지 않으면, 메모리에서 제거되기 때문에 꼭 저장해야합니다.
: 컨트롤러 생성, 화면 전환 및 종속성 주입의 역할을 합니다.
extension Coordinator {
public func addChildCoordinator(_ childCoordinator: Coordinator) { // 코디네이터 추가
self.childCoordinators.append(childCoordinator)
}
public func removeChildCoordinator(_ childCoordinator: Coordinator) { // 코디네이터 삭제, 네비게이션 스택에 코디네이터가 필요하지 않은 경우에만 삭제합니다
self.childCoordinators = self.childCoordinators.filter { $0 !== childCoordinator }
}
public func removeChildCoordinators() { // 코디네이터 전체 삭제
childCoordinators.forEach { $0.removeChildCoordinators() }
childCoordinators.removeAll()
}
}
중복되는 프로퍼티들이 많기 때문에 BaseCoordinator를 만들어 상속받아 사용합니다.
import UIKit
class BaseCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
fatalError("Start method must be implemented")
}
}
import UIKit
public enum AppFlow {
case login
case main
}
final class AppCoordinator: Coordinator {
var navigationController: UINavigationController
var childCoordinators: [Coordinator]
let window: UIWindow
let flow: AppFlow
init(window: UIWindow) {
navigationController = UINavigationController()
self.window = window
self.window.backgroundColor = .white
self.window.rootViewController = navigationController
childCoordinators = []
flow = .login
}
func start() {
switch flow {
case .login:
let loginCoordinator = LoginCoordinator(navigationController: self.navigationController, dependencies: self)
loginCoordinator.start()
addChildCoordinator(loginCoordinator)
case .main:
let mainCoordinator = MainCoordinator(navigationController: self.navigationController)
mainCoordinator.start()
addChildCoordinator(mainCoordinator)
}
window.makeKeyAndVisible()
}
}
extension AppCoordinator: LoginCoordinatorDependencies {
func makeMainTabBarViewController(_ loginCoordinator: LoginCoordinator) {
window.rootViewController = navigationController
removeChildCoordinator(loginCoordinator)
let mainCoordinator = MainCoordinator(navigationController: self.navigationController)
mainCoordinator.start()
addChildCoordinator(mainCoordinator)
window.makeKeyAndVisible()
}
}
AppCoordinator는 앱 시작시 초기 컨트롤러를 연결하기 위한 coordinator입니다.
AppDelegate에서 전달받은 window의 rootViewController와 Main또는 Login을 연결하기 위한 내용입니다.
LoginCoordinator
에 생성자 파라미터로 전달하여, LoginViewController
와 연결하고,LoginCoordinator
에 자신을 전달( LoginCoordinator
의 부모 코디네이터가 AppCoordinator임을 알리기 위함 → Delegate 대신, appCoordinator(부모 코디네이터, parentCoordinator)를 자식 코디네이터 생성시에 전달하는 경우도 있습니다.import UIKi
enum LoginFlow {
case main
case yellow
}
protocol LoginCoordinatorDependencies: AnyObject {
func makeMainTabBarViewController(_ loginCoordinator: LoginCoordinator)
}
final class LoginCoordinator: BaseCoordinator {
weak var dependencies: LoginCoordinatorDependencies?
init(navigationController: UINavigationController, dependencies: LoginCoordinatorDependencies) {
super.init(navigationController: navigationController)
self.dependencies = dependencies
}
override func start() {
let viewModel = LoginViewModel(loginControllable: self) // 코디네이터에서 ViewModel을 생성후
let login = LoginViewController(loginViewModel: viewModel) // LoginVC에 VM을 주입해줍니다.
login.title = "로그인"
self.navigationController.pushViewController(login, animated: true)
}
}
extension LoginCoordinator: LoginViewControllable {
func performTransition(_ loginViewModel: LoginViewModel, to transition: LoginFlow) {
switch transition {
case .main:
dependencies?.makeMainTabBarViewController(self)
case .yellow:
let yellow = YellowCoordinator(navigationController: navigationController)
yellow.start()
yellow.dependencies = self
addChildCoordinator(yellow)
}
}
}
extension LoginCoordinator: YellowCoordinatorDependencies {
func performTransition(_ yellowCoordinator: YellowCoordinator, to transition: YellowFlow) {
switch transition {
case .main:
dependencies?.makeMainTabBarViewController(self)
case .pop:
removeChildCoordinator(yellowCoordinator)
navigationController.popViewController(animated: true)
case .red:
let red = RedCoordinator(navigationController: navigationController)
red.start()
addChildCoordinator(red)
}
}
}
import UIKit
import RxCocoa
import RxSwift
final class LoginViewController: UIViewController {
private let viewModel: LoginViewModel
private let button: UIButton = {
let btn = UIButton()
btn.backgroundColor = .black
btn.setTitle("메인으로 이동", for: .normal)
btn.translatesAutoresizingMaskIntoConstraints = false
return btn
}()
private let button2: UIButton = {
let btn = UIButton()
btn.backgroundColor = .black
btn.setTitle("노랑으로 이동", for: .normal)
btn.translatesAutoresizingMaskIntoConstraints = false
return btn
}()
init(loginViewModel: LoginViewModel) {
self.viewModel = loginViewModel
super.init(nibName: nil, bundle: nil)
bind()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setUpButton()
}
private func bind() {
let input = LoginViewModel.Input(
buttonDidTapped: button.rx.controlEvent(.touchUpInside).asSignal(), // tap 이벤트를 ViewModel로 전달합니다
button2DidTapped: button2.rx.controlEvent(.touchUpInside).asSignal()
)
_ = viewModel.transform(input: input)
}
private func setUpButton() {
view.addSubview(button)
button.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: 0).isActive = true
button.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: 0).isActive = true
button.widthAnchor.constraint(equalToConstant: 117).isActive = true
button.heightAnchor.constraint(equalToConstant: 40).isActive = true
view.addSubview(button2)
button2.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: -30).isActive = true
button2.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: 0).isActive = true
button2.widthAnchor.constraint(equalToConstant: 117).isActive = true
button2.heightAnchor.constraint(equalToConstant: 40).isActive = true
}
}
import RxSwift
import RxCocoa
protocol LoginViewControllable: AnyObject {
func performTransition(_ loginViewModel: LoginViewModel, to transition: LoginFlow)
}
final class LoginViewModel {
private let disposeBag = DisposeBag()
weak var controllable: LoginViewControllable?
struct Input {
let buttonDidTapped: Signal<Void>
let button2DidTapped: Signal<Void>
}
struct Output {
}
init(loginControllable: LoginViewControllable) {
self.controllable = loginControllable
}
func transform(input: Input) -> Output {
input.buttonDidTapped
.withUnretained(self)
.emit{ owner, _ in
owner.controllable?.performTransition(owner, to: .main)
}
.disposed(by: disposeBag)
input.button2DidTapped
.withUnretained(self)
.emit{ owner, _ in
owner.controllable?.performTransition(self, to: .yellow)
}
.disposed(by: disposeBag)
return Output()
}
}
코디네이터와 VC 는 무조건 1:1 인가?
: 자료들을 찾다보면 1:N인 경우도, 1:1인 경우도 있습니다.
코디네이터와 VC를 1:1로 하는 이유?
: 코디네이터는 VC를 생성해주고 ViewModel을 만들어 주입하기 때문입니다.
코디네이터와 VC는 무조건 1:1로 하되 부모 코디네이터와 자식 코디네이터를 명확하게 구분해주세요!
코디네이터의 장점
코디네이터의 단점
구글링하며 찾은 레퍼런스들은 모두 버튼에 대한 동작에 대해서만 코디네이터를 작동시키고 있었다.
하지만 “숨쉴때” (숭실대학교 커뮤니티 앱)에서는 기본 네비바를 사용하고 있었기 때문에 제스쳐에 대한 dimsiss나 pop과 네비게이션 컨트롤러에서 만들어주는 백버튼을 눌렀을때 pop되는 부분은 처리하지 못했다.
viewController는 자체적으로 dismiss 되는지 또는 pop되는지에 대해 알 수 있다.
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if navigationController?.isBeingDismissed ?? false {
print("Red: navigationController isBeingDismissed")
}
if navigationController?.isMovingFromParent ?? false {
print("Red: navigationController isMovingFromParent")
}
if isBeingDismissed {
print("isBeingDismissed")
}
if isMovingFromParent {
print("isMovingFromParent")
}
}
isBeingDismissed
isMovingFromParent
두 가지를 사용하면 pop과 dismiss 되는 경우를 알아낼 수 있다.
네비게이션 자체를 present , push 해주는 경우도 있으므로 경우의 수는 4가지이다.
이를 토대로 Rx+UIViewController extension을 작성해보자
var isDismissing: Observable<Void> {
return base.rx
.methodInvoked(#selector(Base.viewDidDisappear(_:)))
.map { _ in }
.filter { [weak base] in
guard let base = base else { return false}
return base.isBeingDismissed
}
}
var isPopping: Observable<Void> {
return base.rx
.methodInvoked(#selector(Base.viewDidDisappear(_:)))
.map { _ in }
.filter { [weak base] in
guard let base = base else { return false}
return base.isMovingFromParent
}
}
var isDismissingWithNavigationController: Observable<Void> {
return base.rx
.methodInvoked(#selector(Base.viewDidDisappear(_:)))
.map { _ in }
.filter { [weak base] in
guard let base = base else { return false}
guard let navigationController = base.navigationController else { return false }
return navigationController.isBeingDismissed
}
}
var isPoppingWithNavigationController: Observable<Void> {
return base.rx
.methodInvoked(#selector(Base.viewDidDisappear(_:)))
.map { _ in }
.filter { [weak base] in
guard let base = base else { return false}
guard let navigationController = base.navigationController else { return false }
return navigationController.isMovingFromParent
}
}
ViewController에서
rx.isPopping
.map { _ in (print("finish")) }
.bind(to: viewModel.finish)
.disposed(by: disposeBag)
이런식으로 바인딩 해준뒤에
ViewModel에서
finish
.withUnretained(self)
.subscribe { owner, _ in
owner.controllable?.finish()
}
.disposed(by: disposeBag)
이런식으로 viewController가 종료되었다는 것을 알려주면 된다.
사실 화면전환을 하기 위해 이렇게나 많은 클래스와 파일을 생성해야한다는데 가장 큰 단점인 것 같다. 또한 생명주기에 바인딩하는 것도 비효율적이라 생각하기도하고... 경우에 따라서는 코디네이터를 사용하지 않는 것이 더 좋을 수도 있다고 생각한다.. 더 좋은 구현방법이 있으면... 알려주세용..
그래도 ViewController가 아닌 ViewModel에서 Coordinator에게 알려 화면전환을 한다는 점은 큰 장점으로 작용하는 것 같다..! 코디네이터를 통해서 역할분리가 완전 되는 느낌이랄까..
잘 보고 갑니당~