- Coordinator Pattern - UIKit2024년 05월 14일
- 2료일
- 작성자
- 2024.05.14.:52
SwiftUI가 너무 재밌어 매일 swiftui로 개발을 하고 싶었다.... 하지만 4학년인 현재 취업을 해야한다는 압박감에 uikit로 다시 회귀하여 개발을 시작하고 있다....
우선 Uikit에서 뷰전환에는 viewcontroller를 Present해주는 방법과 UINavigationController를 이용한 화면 이동 두가지방법이 잇다. 근데 이렇게 해주면 각 뷰에서 다른 뷰로 이동할때마다 뷰를 생성하고 뷰모델을 주입해주어야 하기에 추적하기에 굉장히 힘들다.
이전에 SwiftUI에서 navigationStack에서 Path관리를 통해 navigationDestination을 이용하여 뷰와 뷰모델을 생성해주고 관리를 해주는 작업을 진행하였다. NavigationStack은 navigationView에서 파생된 것으로 UIkit은 UINavigatinoController를 사용하여 위의 방식은 사용하지 못한다. 그렇다면 어떻게 uikit에서는 관리를 해줄 수 있을까?
그에 대한 해답은 Coordinator이다. 먼저! 이게 몬데?
- swiftui의 navigationPath를 커스텀하는 경우와 비슷한 것으로 앱의 네비게이션 흐름을 관리하는 객체. 여기서 viewModel로부터 이벤트를 받아 다른 뷰컨트롤러로 이동을 하는 작업을 한다.
- 뷰 컨트롤러를 네비게이션 로직에서 분리시킬 수 있다.
- 코디네이터를 통해 뷰컨트롤러, 뷰모델에 의존성을 주입할 수 있어 느슨한 결합과 높은 응집력 유지 가능
import UIKit // MARK: Coordinator protocol Coordinator: AnyObject { // 자신을 완료했다고 부모 코디네이터에게 알리기 위한 델리게이트 var finishDelegate: CoordinatorFinishDelegate? { get set } // 각 코디네이터에게 할당된 하나의 navigation controller var navigationController: UINavigationController { get set } // 모든 자식 코디네이터를 추적하기 위한 배열, 대부분의 경우 이 배열은 하나의 자식 코디네이터만 포함 var childCoordinators: [Coordinator] { get set } var type: CoordinatorType { get } // 플로우를 시작하기 위한 로직을 넣는 곳 func start() // 플로우를 마치기 위한 로직을 넣는 곳, 모든 자식 코디네이터를 정리하고, 자신이 deallocate 될 준비가 되었다는 것을 부모에게 알리는 곳 func finish() init(_ navigationController: UINavigationController) } extension Coordinator { func finish() { // 모든 자식 코디네이터를 제거 childCoordinators.removeAll() // 부모 코디네이터에게 자신이 완료되었음을 알림 finishDelegate?.coordinatorDidFinish(childCoordinator: self) } } // 자식 코디네이터가 완료되어 제거될 준비가 되었음을 부모 코디네이터에게 알리기 위한 델리게이트 프로토콜 protocol CoordinatorFinishDelegate { func coordinatorDidFinish(childCoordinator: Coordinator) }
이게 전형적으로 사용하는 Coordinator base.
여러개의 navigationController가 아닌 하나의 navigatationController로 사용한다.
CoordinatorType이 어떻게 보면 App의 Flow를 명시한다. ex) 탭 플로우 일수도 있고 로그인 플로우 등 그외의 플로우들에 각각의 코디네이터를 만들껀데 여기서 그 타입을정의해준다.
그 후 하나의 메인 코디네이터를 만들어야 하는데 이를 AppCoordinator라고 부른다.
enum CoordinatorType { case app case login case tab } protocol AppCoordinatorProtocol: Coordinator { func showLoginFlow() func showMainFlow() }
어떠한 플로우가 있는지를 명시한 수 그에 맞는 flow들을 프로토콜로 선언한다. 그 후 AppCoordinator를 만들어보자
// // AppCoordinator.swift // GND // // Created by 235 on 5/12/24. // import Foundation import UIKit protocol AppCoordinatorProtocol: Coordinator { func showLoginFlow() func showMainFlow() } final class AppCoordinator: AppCoordinatorProtocol { weak var finishDeleagate: CoordinatorFinishDelegate? = nil var navigationController: UINavigationController var type: CoordinatorType {.app} var childCoordinators = [Coordinator]() required init(_ navigationController: UINavigationController) { self.navigationController = navigationController navigationController.setNavigationBarHidden(true, animated: true) } func start() { if let _ = KeychainManager.shared.readToken(key: "accessToken") { showMainFlow() } else { showLoginFlow() } } func showLoginFlow() { //로그인에 관련한 코디네이터 생성하고 child에 추가 } func showMainFlow() { //메인에 관련한 코디네이터 생성후 child추가 } } extension AppCoordinator: CoordinatorFinishDelegate { // 해당 coordinator에서 finish할때 동일한 child coordinator제거, navigationController초기화 하고 새로운 coordinator 설정. func coordinatorDidFinish(childCoordinator: Coordinator) { self.childCoordinators = self.childCoordinators.filter({ $0.type != childCoordinator.type }) self.navigationController.view.backgroundColor = .systemBackground self.navigationController.viewControllers.removeAll() switch childCoordinator.type { case .login: self.showMainFlow() case .tab: self.showLoginFlow() default: break } } }
여기서 delegate Protocol 사이 인스턴스 강한참조순환을 막기위해 we나는 keychain에 토큰을 저장해두었으므로 토큰이 있으면 showmain이고 없으면 main으로 가게 라우팅을 하였다. AppCoordinator는 최상위 coordinator로 finishDelegate가 nil이다. 자 그러면 이것을 이제 SceneDelegate에 적용을 하여야 한다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var coordinator: AppCoordinator! 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 coordinator = AppCoordinator(navigationController) coordinator.start() window?.makeKeyAndVisible() }
SceneDelegate에서 AppCoordinator를 생성해주고 start해준다. 그러면 위에서 해준대로 accessToken을 가지고 있는지에 따라 하위 코디네이터의 flow를 연결해준다.
자 그러면 이제 로그인 Coordinator를 만들어보자.
// // LoginCoordinator.swift // GND // // Created by 235 on 5/12/24. // import UIKit protocol LoginCoordinatorProtocol: Coordinator { func showLoginViewController() func showProfileSettingViewController() func showNicknameViewController(gender: String, age: String) } class LoginCoordinator: LoginCoordinatorProtocol { weak var finishDelegate: CoordinatorFinishDelegate? var navigationController: UINavigationController var childCoordinators = [ Coordinator]() var type: CoordinatorType {.login} var userusecase = UserUsecase(userReposiotry: UserRepository()) func showLoginViewController() { let loginVC = LoginViewController() loginVC.viewModel = LoginViewModel(coordinator: self, userUseCase: userusecase) self.navigationController.viewControllers = [loginVC ] } func showProfileSettingViewController() { let profileSettingView = ProfileViewController() profileSettingView.viewmodel = ProfileViewModel(coordinator: self) self.navigationController.pushViewController(profileSettingView, animated: false) } func showNicknameViewController(gender: String, age: String) { let nicknameViewController = NicknameViewController() nicknameViewController.viewModel = NickNameViewModel(coordinator: self, userUseCase: userusecase, gender: gender, age: age) self.navigationController.pushViewController(nicknameViewController, animated: true) } func start() { showLoginViewController() } required init(_ navigationController: UINavigationController) { self.navigationController = navigationController } func finish() { self.finishDelegate?.coordinatorDidFinish(childCoordinator: self) } }
이전 showLoginFlow에서 Logincoordinator를 생성하고 start를 해준다. 그러면 loginCoordinator에서 loginVC를 생성하고 navigationControllers안에 본인을 제일 먼저 추가한다.
나의 플로우에서는 로그인 -> 프로필세팅1 -> 프로필세팅 2 -> 메인뷰이기에
첫번째 로그인의 viewModel에서 로그인을 하고 나서 회원가입이 되어있지 않다면 coordinator.showProfileSettingViewController()를 호출하여 profilesettingview로 push한다. 마찬가지로 프로필세팅뷰에서 다음을 누르면 showNicknameViewController를 호출하여 nicknameViewController로 이동을 하고 거기서는 그동안의 LoginCoordinator를 벗어나 tabCoordinator로 가야한다. 그래서 finish()를 호출해준다. 그러면? appCoordinator에서의 coordinatorDidFinish를 통해 loginFlow가 아닌 MainFlow로 전환하고
탭코디네이터를 생성해서 보여준다.!!
TabCoordinator
자 이제 그러면 탭코디네이터는 어떻게 작성해야할까? 탭별로 NavigationController의 path관리가 필요했다. 각각의 뎁스로 인해
// // TabCoordinator.swift // GND // // Created by 235 on 5/12/24. // import UIKit enum TabbarPages: CaseIterable { case main case together case analyze var defaultIcon: String { switch self { case .main: "shoeprints.fill" case .together: "person.3" case .analyze: "chart.bar" } } var onIcon: String { switch self { case .main: "shoeprints.fill" case .together: "person.3.fill" case .analyze: "chart.bar.fill" } } var title: String { switch self { case .main: "산책" case .together: "함께하기" case .analyze: "분석" } } } protocol TabCoordinatorProtocol: Coordinator { var tabbarController: UITabBarController {get set} } class TabCoordinator: TabCoordinatorProtocol { weak var finishDelegate: CoordinatorFinishDelegate? var navigationController: UINavigationController var childCoordinators: [ Coordinator] = [] var tabbarController: UITabBarController var type: CoordinatorType {.tab} func start() { let pages: [TabbarPages] = TabbarPages.allCases let controllers: [UINavigationController] = pages.map ({ self.createTabbarNavigation($0) }) configureTabBar(with: controllers) } func createTabbarNavigation(_ page: TabbarPages) -> UINavigationController { let tabNavigationController = UINavigationController() tabNavigationController.setNavigationBarHidden(false, animated: false) tabNavigationController.tabBarItem = UITabBarItem(title: page.title, image: UIImage(systemName: page.defaultIcon), selectedImage: UIImage(systemName: page.onIcon)) self.startTabCoordinator(of: page, to: tabNavigationController) return tabNavigationController } private func configureTabBar(with tabViewControllers: [UIViewController]) { self.tabbarController.setViewControllers(tabViewControllers, animated: true) self.tabbarController.view.backgroundColor = .systemBackground self.tabbarController.tabBar.backgroundColor = .systemBackground self.tabbarController.tabBar.tintColor = UIColor.black self.navigationController.pushViewController(self.tabbarController, animated: true) } required init(_ navigationController: UINavigationController) { self.navigationController = navigationController self.tabbarController = UITabBarController() } private func startTabCoordinator(of page: TabbarPages, to tabNavigationController: UINavigationController) { switch page { case .main: let strideCoordinator = StrideCoordinator(tabNavigationController) // strideCoordinator.finishDelegate = self childCoordinators.append(strideCoordinator) strideCoordinator.start() // navigationController.pushViewController(strideCoordinator, animated: true) case .together: let togetherCoordinator = TogetherCoordinator(tabNavigationController) childCoordinators.append(togetherCoordinator) togetherCoordinator.start() case .analyze: let anlayzeVc = AnalyzeViewController() self.navigationController.pushViewController(anlayzeVc, animated: false) } } // func coordinatorDidFinish(childCoordinator: Coordinator) { // self.childCoordinators = childCoordinators.filter({ $0.type != childCoordinator.type }) // if childCoordinator.type == .home { // navigationController.viewControllers.removeAll() // } else if childCoordinator.type == .mypage { // self.navigationController.viewControllers.removeAll() // self.finishDelegate?.coordinatorDidFinish(childCoordinator: self) // } // } }
나의 경우 탭바는 3개의 아이템으로 구성되어야했고 main, togethrer에서는 뎁스가 있지만 analyze에서는 앱의 뎁스가 없어 그냥 푸쉬해주는 형식으로 작성을 하였다.각각의 enum case별로 네비게이션컨트롤러를 생성해주고 configureTabbar에서는 탭바에 대한 설정을 해주었다.
처음 하는 coordinator 패턴이였기에 프로토콜도 많아지고 복잡해져 확실히 혼자하는 앱의 규모에서는 too much라고 느꼈다. 하지만 각 뷰모델에서는 코디네이터를 사용하여 함수를 호출하여 뷰를 넘기는게 가능하여서 어떠한 flow가 바뀔때는 coordinator에서만 수정하면 되므로 캡슐화, 유연성등을 올렸다는 것을 느낄수 있었다. 특히 협업할때 좋을것 같음!
'UIkit' 카테고리의 다른 글
Diffable DataSource (0) 2025.03.09 다음글이전글이전 글이 없습니다.댓글