UIViewController Coordinator pattern

quokka·2024년 5월 24일
0

iOS

목록 보기
27/27
post-custom-banner

프로젝트 Github Repository: quokkaKyu/UIViewControllerCoordinator

iOS앱의 화면이 많아져서 화면을 전환하는 코드를 유지보수하는데 어려움과 불편함을 느꼈었습니다. 그래서 화면전환을 하는 책임을 다른 객체에 양도하고 싶어서 찾아본 결과 Coordinator패턴을 찾았습니다. 해당 글에 Coordinator 패턴을 적용한 내용을 기록하려고합니다.

What is Coordinator?

Coordinator는 하나 이상의 뷰 컨트롤러를 관리하는 객체입니다. 뷰컨트롤러에서 모든 구동 로직을 제거하고, 해당 항목을 한 레이어 위로 이동하게하여 코드 유지보수에 도움을 줍니다.

App Coordinator

모든것은 App Coordinator로부터 시작됩니다. App Coordinator는 앱의 기본 ViewControler를 설정합니다. App Coordinator는 ViewController를 생성 및 구성하거나 하위작업을 수행하기 위해 새로운 하위 코디네이터를 생성할 수 있습니다.

Coordinator는 ViewController로부터 어떤 책임을 맡나요?

주로 탐색 및 모델 돌연변이입니다. (모델 돌연변이란 사용자의 변경 사항을 데이터베이스에 저장하거나 API에 PUT 또는 POST 요청을 하는 등 사용자의 데이터를 파괴적으로 수정할 수 있는 모든것을 의미합니다.

Coordinator에 대한 간략한 설명은 끝났습니다. Coordinator패턴에 대해 더 많은 설명이 필요하시다면 Coordinator Redux를 참고해주세요. 이제 코드에 적용을 해보겠습니다. 프로젝트의 미니멈 타겟은 iOS14입니다.

Coordinator.swift

UIViewController의 화면전환에 대한 책임을 맡을 Coordinator protocol을 정의해줍니다.

protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set } // 하위 Coordinator를 저장할 Coordinator 배열입니다.
    var navigationController: UINavigationController { get set } // Coordinator에서 UIViewController의 화면전환에 대한 관리를 위해 선언해줍니다.
    var rootVC: UIViewController? { get } // navigationController.pop을 실행할때 해당 Coordinator의 rootViewController까지만 pop을 진행하기 위해서 선언했습니다.
    func start() // Coordinator를 시작합니다.
}

extension Coordinator {
    func pop(animated: Bool = true) {
        if navigationController.viewControllers.count > 1 {
            navigationController.popViewController(animated: animated)
        }
    }
    
    func popToRootVC(animated: Bool = true) {
        guard let rootVC = rootVC else {
            return
        }
        navigationController.popToViewController(rootVC, animated: animated)
    }
}

AppCoordinator.swift

AppCoordinator의 자식인 LoginCoordinator, MainCoordinator의 생명주기를 관리하고, isLoggedIn값에 따라서 로그인화면, 메인화면 둘중 어떤것을 보여줄지 결정합니다.

import Foundation
import UIKit

final class AppCoordinator: Coordinator, LoginCoordinatorDelegate, MainCoordinatorDelegate {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    var rootVC: UIViewController?
    
    var isLoggedIn: Bool = false
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        if isLoggedIn {
            showMainViewController()
        } else {
            showLoginViewController()
        }
    }
    
    private func showLoginViewController() {
        let coordinator = LoginCoordinator(navigationController: navigationController)
        coordinator.delegate = self 
        coordinator.start() 
        childCoordinators.append(coordinator)
    }

    private func showMainViewController() {
        let coordinator = MainCoordinator(navigationController: navigationController)
        coordinator.delegate = self
        coordinator.start()
        childCoordinators.append(coordinator)
    }

    // LoginCoordinatorDelegate
    func didLoggedIn(_ coordinator: LoginCoordinator) {
        childCoordinators = childCoordinators.filter{ $0 !== coordinator}
        showMainViewController()
    }
    
    // MainCoordinatorDelegate
    func didLoggedOut(_ coordinator: MainCoordinator) {
        childCoordinators = childCoordinators.filter { $0 !== coordinator }
        showLoginViewController()
    }
}

LoginCoordinator.swift

Login관련 뷰의 화면전환을 관리합니다.

import Foundation
import UIKit

protocol LoginCoordinatorDelegate: AnyObject {
    func didLoggedIn(_ coordinator: LoginCoordinator)
}

final class LoginCoordinator: Coordinator {
    var rootVC: UIViewController?
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    var delegate: LoginCoordinatorDelegate?
    
    init(navigationController: UINavigationController!) {
        self.navigationController = navigationController
    }
    
    func start() {
        let viewController = LoginViewController()
        viewController.coordinator = self
        
        rootVC = viewController
        navigationController.viewControllers = [viewController]
    }
  	// LoginDestination의 case에 있는 뷰로 이동할 수 있습니다.
    func push(destination: LoginDestination) {
        switch destination {
        case .signup:
            let signupViewController = SignupViewController()
            signupViewController.coordinator = self
            navigationController.pushViewController(signupViewController, animated: false)
        case .findID:
            let findIDViewController = FindIDViewController()
            findIDViewController.coordinator = self
            navigationController.pushViewController(findIDViewController, animated: false)
        }
    }
    // 해당 함수를 이용하여 LoginCoordinatorDelegate에 현재 Coordinator 인스턴스를 전달해줍니다.
    func login() {
        delegate?.didLoggedIn(self)
    }
}

LoginDestination.swift

로그인 관련 이동할 화면을 정의합니다.

import Foundation

// 이동할 뷰
enum LoginDestination {
    case signup
    case findID
}

MainCoordinator.swift

Main관련 뷰의 화면전환을 관리합니다.

import Foundation
import UIKit

protocol MainCoordinatorDelegate: AnyObject {
    func didLoggedOut(_ coordinator: MainCoordinator)
}

final class MainCoordinator: Coordinator {
    var rootVC: UIViewController?
    var childCoordinators: [Coordinator] = []
    var delegate: MainCoordinatorDelegate?
    var navigationController: UINavigationController
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let viewController = MainViewController()
        viewController.coordinator = self
        rootVC = viewController
        navigationController.viewControllers = [viewController]
    }
    // 해당 함수를 이용하여 MainCoordinatorDelegate 현재 Coordinator 인스턴스를 전달해줍니다.
    func logout() {
        delegate?.didLoggedOut(self)
    }
}

SceneDelegate.swift

앱화면의 rootViewController를 navigationController로 설정하고 설정한 navigationController를 AppCoordinator에 초기화 해주고 AppCoordinator를 시작합니다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        window = UIWindow(windowScene: windowScene)
        let navigationController = UINavigationController()
        window?.rootViewController = navigationController
        let coordinator = AppCoordinator(navigationController: navigationController)
        coordinator.start()
        
        window?.makeKeyAndVisible()
    }
}

MainViewController.swift

MainViewController에 MainCoordinator를 이용한 화면전환 코드를 작성합니다.

final class MainViewController: UIViewController {
    weak var coordinator: MainCoordinator?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    private func setupView() {
        view.backgroundColor = .gray
        setupNavigationItem()
        setupLabel()
    }
    
    private func setupNavigationItem() {
        let item = UIBarButtonItem(title: "로그아웃", style: .plain, target: self, action: #selector(logoutButtonDidTap))
        navigationItem.rightBarButtonItem = item
    }
    
    private func setupLabel() {
        let label = UILabel()
        label.text = "메인"
        label.font = .systemFont(ofSize: 20, weight: .bold)
        label.textAlignment = .center
        label.textColor = .black
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    @objc private func logoutButtonDidTap() {
        coordinator?.logout()
    }

LoginViewController.swift

LoginViewController에 LoginCoordinator를 이용한 화면전환 코드를 작성합니다.

import Foundation
import UIKit

final class LoginViewController: UIViewController {
    weak var coordinator: LoginCoordinator?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    private func setupView() {
        view.backgroundColor = .white
        setupNavigationItem()
        setupLabel()
    }
    
    private func setupNavigationItem() {
        let loginButtonitem = UIBarButtonItem(title: "로그인", style: .plain, target: self, action: #selector(loginButtonDidTap))
        let findIDButtonItem = UIBarButtonItem(title: "아이디찾기", style: .plain, target: self, action: #selector(findIDButtonDidTap))
        let signupButtonItem = UIBarButtonItem(title: "회원가입", style: .plain, target: self, action: #selector(signupButtonDidTap))
        navigationItem.rightBarButtonItems = [loginButtonitem, findIDButtonItem, signupButtonItem]
    }
    
    private func setupLabel() {
        let label = UILabel()
        label.text = "로그인"
        label.font = .systemFont(ofSize: 20, weight: .bold)
        label.textAlignment = .center
        label.textColor = .black
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    @objc private func loginButtonDidTap() {
        coordinator?.login()
    }
    
    @objc private func findIDButtonDidTap() {
        coordinator?.push(destination: .findID)
    }
    
    @objc private func signupButtonDidTap() {
        coordinator?.push(destination: .signup)
    }
}

SignupViewController, FindIDViewController는 위에 뷰컨트롤러 코드를 이용하여 응용하시면 될거같고, 전체 소스코드는 맨위에 남긴 github주소에서 참고하시면 됩니다. 피드백은 언제든지 환영입니다! 읽어주셔서 감사합니다!

Reference

profile
iOS를 공부하는 개발자입니다~ㅎㅎ
post-custom-banner

0개의 댓글