2025. 1. 20. 15:08ㆍArchitecture, Design Pattern
[Swift] Coordinator & Router & Factory Pattern을 사용한 리팩토링 (1) : 초기 세팅부터 로그인 화면까지
💡 해당 글은 Coordinator Pattern, Router, Factory Method Pattern을 사용한 리팩토링 코드를 설명하는 글입니다!글이 너무 길어져 총 3개로 나누어 글을 올릴 예정이구요, 만약 잘못된 개념이나 개선할 부분
mini-min-dev.tistory.com
💡 해당 글은 Coordinator Pattern, Router, Factory Method Pattern을 사용한 리팩토링 코드를 설명하는 글입니다!
글이 너무 길어져 총 3개로 나누어 글을 올릴 예정이고요, 만약 잘못된 개념이나 개선할 부분, 질문 등이 있다면 댓글로 지적해 주시면 빠르게 남겨드리도록 하겠습니다 ^__^
1️⃣ Coordinator & Router & Factory Pattern을 사용한 리팩토링 (1) : 초기 세팅부터 로그인 화면까지
2️⃣ Coordinator & Router & Factory Pattern을 사용한 리팩토링 (2) : 코디네이터로 탭바 만들기
3️⃣ Coordinator & Router & Factory Pattern을 사용한 리팩토링 (3) : 앱 전체 흐름 구성, 화면 로직 분리에 대한 깊은 고찰
1. 탭바를 구성하기 위한 사전 이해
지난 글에 이어서 글을 시작하겠습니다.
간단하게 현재 상황을 리마인드하자면, AppCoordinator에서 로그인 여부에 따라 LoginCoordinator와 TabBarCoordinator로 분기처리가 되던 상황이었고 / AppCoordinator와 LoginCoordinator의 흐름까지 코드로 살펴봤습니다.
그럼 지난 글에서 구현하지 않았던 TabBarCoordinator부터 설명을 해야겠죠?
일단, 로그인 성공 시에 전환되는 화면인탭바는 아래 보이는 것처럼 4개의 기본 탭과, 가운데에 위치한 특수탭으로 구성되어 있었습니다.
*기본 탭 : 홈 (Home), 카테고리 (Clip), 검색 (Search), 알림 (Timer)으로 구성 -> 일반적인 탭 간 화면 전환에 사용
**특수 탭 : 가운데 위치한 + 버튼 -> 탭 간 화면 전환이 아니라 새로운 화면 (링크 입력 뷰)을 표시하기 위한 용도로 사용 (탭바 표출 x)
탭바를 Coordinator 패턴과 함께 사용하기 위해서는 세 가지 파일을 사용하게 될 겁니다.
화면 전환을 관리하는 코디네이터, 상호작용과 UI를 담당하는 VC, 그리고 탭바 아이템 항목을 정의해 둔 enum형 등이 있죠.
- TabBarCoordinator : 탭바의 첫 화면을 초기화하며, 각 탭 선택 시 연결되는 화면 전환 Coordinator를 관리하는 역할
- TabBarController : UITabBarController를 상속, 각 탭 선택에 대한 동작(화면 연결)과 탭바 UI를 구성하는 역할
- TabBarItem : 탭의 항목을 정의한 열거형, 각 탭 별로 아이템 (아이콘 + 타이틀)에 대한 상태를 포함하는 역할
이 3개의 파일은 아래와 같은 상호작용을 거치며 TabBar의 동작을 돕게 됩니다.
일단 지금은 큰 설계만 소개해보도록 할게요! 만약, 지금 이해가 되지 않더라도 아래 글에서 차근차근 설명을 이어나갈거니 걱정하지 않으셔도 됩니다 :)
1️⃣ TabBarCoordinator 시작 (Entry Point, start() 메서드 부분)
- TabBarController를 팩토리를 통해 생성하고, 초기화합니다.
- 각 탭의 Coordinator를 연결하고, 루트 VC를 TabBarController로 지정합니다.
2️⃣ TabBarController 초기화 (TabBarController, ViewDidLoad 부분)
- 초기 탭바 UI와 탭 아이템을 설정합니다. + 각 탭에 대응하는 NavigationController도 생성, 연결합니다.
- 탭 아이템은 TabBarItem으로부터 데이터를 받아와 화면에 그립니다.
3️⃣ 각 탭별 Coordinator 실행 (delegate, Closure 부분)
- 사용자가 특정 탭을 선택하면, TabCoordinator에서 정의한 클로저가 호출되어 이에 맞는 하위 Coordinator가 시작됩니다.
- 사용자의 탭 선택은 TabBarDelegate의 메서드를 통해 값을 받아올 수 있습니다.
4️⃣ 추가적으로, 특수 탭 (plus)에 대한 화면 전환 구현
2. 4개의 기본 탭 (하위 Coordinator)을 TabBarCoordinator로 연결하기
자 그럼 TabBarCoordinator 코드부터 작성해보도록 하겠습니다!
위에서 봤다시피 <토스터 TOASTER> 앱에서의 기본 탭은 홈 (Home)-클립 (Clip)-검색 (Search)- 타이머 (Timer)로 총 4개로 구성되어 있었습니다.
탭바 코디네이터 (TabBarCoordinator)에서는 이 4개 탭에 대한 전환을 담당하게 될 겁니다.
이후, 각 탭에서 이루어지는 화면 전환은 각 탭 별 하위 코디네이터 (HomeCoordinator, ClipCoordinator, SearchCoordinator, TimerCoordinator)에서 관리하도록 한다는 것이죠!
*여기서 말하는 각 탭에서 이루어지는 화면전환이란? 클립 탭 기준, 클립에서 세부클립으로 > 세부클립에서 웹뷰로 넘어가는 등의 이후 이루어지는 앱 전반적인 흐름 (flow)을 의미합니다.
💡 즉, TabBarCoordinator는 각 탭에 대응하는 하위 Coordinator들을 초기화하고 시작하는 코드만 담기게 된다는 의미로 이해하면 됩니다!
TabBarCoordinator도 기본적으로 BaseCoordinator와 끝을 정의하는 CoordinatorFinishOutput을 모두 채택하고 있습니다.
LoginCoordinator 때와 마찬가지로 Router, VCFactory, CoordinatorFactory를 외부에서 주입받으며, 앱의 메인 인터페이스를 담당하는 TabBarController도 프로퍼티로 담고 있군요.
AppCoordinator에서 TabBarCoordinator로 처음 넘어올 때 호출되는 부분은 start() 입니다. 코드를 살펴보죠!
- setupTabBarController() 메서드 호출 : 프로퍼티로 갖고 있는 tabBarController가 nil일 경우, TabBarController를 초기화하는 setupTabBarController() 메서드를 호출해 지정합니다.
- tabBarController.selectTab(0) : 항상 TabBar 첫 진입 시에는 첫 번째 탭에 대한 선택을 보장합니다.
- Router를 사용해 TabBarController를 앱의 루트 VC로 지정해 줍니다.
final class TabBarCoordinator: BaseCoordinator, CoordinatorFinishOutput {
var onFinish: (() -> Void)?
private let router: RouterProtocol
private let viewControllerFactory: ViewControllerFactoryProtocol
private let coordinatorFactory: CoordinatorFactoryProtocol
private var tabBarController: TabBarController?
init(
router: RouterProtocol,
viewControllerFactory: ViewControllerFactoryProtocol,
coordinatorFactory: CoordinatorFactoryProtocol
) {
self.router = router
self.viewControllerFactory = viewControllerFactory
self.coordinatorFactory = coordinatorFactory
}
override func start() {
if tabBarController == nil { setupTabBarController() }
guard let tabBarController else { return }
tabBarController.selectTab(0)
router.setRoot(tabBarController, animated: false)
}
}
탭바를 초기화하는 메서드는 TabBarVC를 생성하고, 각 탭에 대응하는 클로저 부분을 정의하도록 작성했습니다.
이때 각 탭에 대응하는 클로저 부분은 "사용자가 특정 탭을 선택할 때 호출되는 부분"으로,
"사용자가 특정 탭을 클릭하면, 그에 대응되는 하위 코디네이터를 호출/시작한다"는 위의 TabBarCoordinator의 역할을 충실하게 구현하고 있습니다.
*(홈 탭 기준) 사용자가 특정 탭을 클릭하면 = vc.onHomeScene 클로저가 해당 / 그에 대응되는 하위 코디네이터를 호출/시작한다 = self?.startHomeCoordinator(:) 부분이 해당
하위 코디네이터를 호출/시작하는 (start~Coordinator 이름) 메서드에는
Coordinator를 Factory로 생성하고 / 의존성 추가 + start() 메서드 호출 / 종료 시 호출되는 클로저 (onFinish로 정의) 부분을 나타내는 코드가 동일하게 작성될 거예요!
private extension TabBarCoordinator {
func setupTabBarController() {
let vc = viewControllerFactory.makeTabBarVC()
vc.onHomeScene = { [weak self] navController in
self?.startHomeCoordinator(navController: navController)
}
vc.onClipScene = { [weak self] navController in
self?.startClipCoordinator(navController: navController)
}
vc.onSearchScene = { [weak self] navController in
self?.startSearchCoordinator(navController: navController)
}
vc.onTimerScene = { [weak self] navController in
self?.startTimerCoordinator(navController: navController)
}
self.tabBarController = vc
}
func startHomeCoordinator(navController: UINavigationController) {
let coordinator = coordinatorFactory.makeHomeCoordinator(
router: Router(rootViewController: navController),
viewControllerFactory: self.viewControllerFactory,
coordinatorFactory: coordinatorFactory
)
coordinator.onFinish = { [weak self, weak coordinator] in
self?.removeDependency(coordinator)
self?.onFinish?()
}
self.addDependency(coordinator)
coordinator.start()
}
// Similar to startHomeCoordinator
func startClipCoordinator(navController: UINavigationController) { ... }
func startSearchCoordinator(navController: UINavigationController) { ... }
func startTimerCoordinator(navController: UINavigationController) { ... }
}
그럼 하위에 해당하는 각 탭별 Coordinator들은 start() 호출 부분에 있어,
하위 VC를 생성하고 - 이를 표출하기 위한 setRoot 설정을 router에다 해주는 코드가 포함되겠네요!
*이어지는 하위 Coordinator에서의 화면 전환에 대한 구현은 다음 글에서 더 자세하게 다룰테니, 이번 글에서는 소개하지 않겠습니다!
final class HomeCoordinator: BaseCoordinator, CoordinatorFinishOutput {
var onFinish: (() -> Void)?
private let router: RouterProtocol
private let viewControllerFactory: ViewControllerFactoryProtocol
private let coordinatorFactory: CoordinatorFactoryProtocol
init(
router: RouterProtocol,
viewControllerFactory: ViewControllerFactoryProtocol,
coordinatorFactory: CoordinatorFactoryProtocol
) {
self.router = router
self.viewControllerFactory = viewControllerFactory
self.coordinatorFactory = coordinatorFactory
}
override func start() {
showHomeVC()
}
}
private extension HomeCoordinator {
func showHomeVC() {
let vc = viewControllerFactory.makeHomeVC()
router.setRoot(vc, animated: false)
}
그럼 이제는 TabBarController가 각 탭과 연결된 화면을 어떤 식으로 관리하여, 클로저를 호출하게 되는지 더 자세하게 알아봐야겠군요!
다음으로, TabBarController 코드를 이해하러 가봅시다.
3. TabBarController와 TabBarItem
TabBarController를 Coordinator와 TabBarItem을 활용하기 위해서는 아무래도 UITabBarController의 구조를 정확하게 알아야 할 것 같아요.
예전 블로그 글을 찾아보려고 했지만, TabBarController를 다룬 글은 없어 이참에 같이 다뤄보고자 합니다.
기본적으로 탭바는 UITabBarController라는 객체로 만듭니다.
UITabBarController에는 viewControllers라고 불리는 Array를 통해 각 탭에 대응하는 VC를 관리하게 되는데요. 이 Array의 index가 전체 탭의 순서를 결정합니다.
*그래서 각 탭에 맞는 값을 지정하거나, 탭에 대한 화면 전환 등을 위한 코드에도 이 viewControllers의 index 값을 가지고 설정하게 될 거에요!
⭐️ 이때, 탭 내부에서 계층 화면 전환을 위해서는 viewControllers Array에 담기는 VC를 일반 UIViewController 객체가 아닌, UINavigationController로 래핑해서 VC를 담아줘야 합니다. ⭐️
UITabBar는 UITabBarController 하단에 위치한 화면으로, 각 탭의 항목을 그릴 때 사용되는 객체입니다. -> 그냥 탭바 자체라고 이해!
이 내부에는 UITabBarItem이라는 객체가 있어 탭 하나하나에 대한 값들을 지정하고, viewControllers와 연결짓게 되는 것이구요.
결론적으로 TabBarController에서 해야할 것은 아래와 같은 것들입니다.
- UITabBar UI 지정 : 배경색, 탭 선택 (tintColor) / 미선택 시 (unselectedItemTintColor)의 색상 / 특수 탭 색상 및 형태 등
- 각 탭에 해당하는 UINavigationController를 생성해 viewControllers Array에 담기 -> 해당하는 세부 VC는 그에 대응되는 하위 Coordinator에서 생성 책임을 담당
- TabBarItems UI 지정 및 VC 연결 : 이미지, 타이틀 / 선택, 미선택 시에 대응되는 UI의 변화 / VC와 Items 간의 연결 등
그리고 그 내용이 아래 코드에서 작성되어 있는 것이죠.
탭바에 대한 속성 (배경색 backgroundColor, 아이템 색상 등)을 설정하고, 그 아이템들을 VC와 연결하는 코드가 담겨 있습니다. (createNavigation 메서드 부분)
final class TabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
setupTabBar()
}
}
// MARK: - Private Extensions
private extension TabBarController {
func setupTabBar() {
tabBar.backgroundColor = .toasterWhite
tabBar.unselectedItemTintColor = .gray150
tabBar.tintColor = .black900
self.viewControllers = [
createNavigation(for: .home),
createNavigation(for: .clip),
createNavigation(for: .plus),
createNavigation(for: .search),
createNavigation(for: .timer)
]
}
func createNavigation(for item: TabBarItem) -> UINavigationController {
let navController = ToasterNavigationController()
navController.tabBarItem = createTabBarItem(for: item)
return navController
}
func createTabBarItem(for item: TabBarItem) -> UITabBarItem {
let tabBarItem = UITabBarItem(
title: item.itemTitle,
image: item.normalItem?.withRenderingMode(.alwaysOriginal),
selectedImage: item.selectedItem?.withRenderingMode(.alwaysOriginal)
)
if item == .plus {
tabBarItem.imageInsets = UIEdgeInsets(top: -20, left: 0, bottom: 0, right: 0)
}
let normalAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.suitBold(size: 12),
.foregroundColor: UIColor.gray150
]
let selectedAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.suitBold(size: 12),
.foregroundColor: UIColor.black900
]
tabBarItem.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: -5)
tabBarItem.setTitleTextAttributes(normalAttributes, for: .normal)
tabBarItem.setTitleTextAttributes(selectedAttributes, for: .selected)
return tabBarItem
}
}
// MARK: - Extensions
extension TabBarController {
func selectTab(_ index: Int) {
self.selectedIndex = index
if let controller = self.viewControllers?[index] {
self.tabBarController(self, didSelect: controller)
}
}
}
이때, 세부 UITabBarItem에 들어가야 할 이미지와 타이틀은
별도의 CaseIterable을 채택한 enum형 TabBarItem에 정의하여 쉽게 꺼내서 사용하도록 구성한 것을 확인할 수 있습니다.
enum TabBarItem: CaseIterable {
case home, clip, search, timer
// 선택되지 않은 탭
var normalItem: UIImage? {
switch self {
case .home: return .icHome24.withTintColor(.gray150)
case .clip: return .icClipFull24.withTintColor(.gray150)
case .search: return .icSearch24.withTintColor(.gray150)
case .timer: return .icTimer24.withTintColor(.gray150)
}
}
// 선택된 탭
var selectedItem: UIImage? {
switch self {
case .home: return .icHome24.withTintColor(.black900)
case .clip: return .icClipFull24.withTintColor(.black900)
case .plus: return .fabPlus
case .search: return .icSearch24.withTintColor(.black900)
case .timer: return .icTimer24.withTintColor(.black900)
}
}
// 탭 별 제목
var itemTitle: String? {
switch self {
case .home: return StringLiterals.Tabbar.home
case .clip: return StringLiterals.Tabbar.clip
case .plus: return nil
case .search: return StringLiterals.Tabbar.search
case .timer: return StringLiterals.Tabbar.timer
}
}
}
이제 기본적인 탭바의 구성은 완료되었습니다!
하지만 아직 Coordinator와의 연결이 구현되지 않았으니 해줘야겠죠?
여기서 코디네이터와의 연결이라고 하면, 사용자의 탭 선택에 따라 클로저를 호출하도록 하는 것.
= 다시 말해 사용자가 다른 탭을 클릭하면, 그 탭에 해당하는 하위 Coordinator를 계층에 추가하고 시작해서 - 그 하위 Coordinator의 흐름이 동작할 수 있도록 하는 것을 의미한다고 볼 수 있겠습니다.
이는 UITabBarController의 delegate인 didSelect를 통해 호출해 주는 것으로 구현합니다.
추가로, currentIndex는 탭을 중복으로 선택해도 반복적으로 화면에 로드되는 문제를 해결하기 위해 사용한 방법이라고 이해하면 됩니다.
*didSelect는 사용자가 특정 탭을 선택했을 때 호출되는 메서드로, delegate = self를 지정해준 이후 사용할 수 있습니다.
💡 사용자가 새로운 탭을 선택한다 (didSelect) -> Scene 클로저 동작 -> Coordinator에서는 탭에 해당하는 하위 Coordinator를 초기화하고 시작 (start)한다. -> 새로운 화면 전환 시작
typealias Scene = ((UINavigationController) -> Void)
final class TabBarController: UITabBarController {
private var currentIndex: Int?
var onHomeScene: Scene?
var onClipScene: Scene?
var onSearchScene: Scene?
var onTimerScene: Scene?
...
}
// MARK: - UITabBarControllerDelegate
extension TabBarController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard let controller = viewController as? UINavigationController else { return }
if currentIndex == selectedIndex { return } // 중복 탭 선택을 방지
self.currentIndex = selectedIndex
switch selectedIndex {
case 0: onHomeScene?(controller)
case 1: onClipScene?(controller)
case 2: // plus 특수 탭 처리
case 3: onSearchScene?(controller)
case 4: onTimerScene?(controller)
default: return
}
}
}
4. plus 특수 탭을 Coordinator와 연결해 화면 전환하기
지금까지 4개의 기본 탭을 VC와 Coordinator를 통해 화면 전환이 가능하도록 구현했습니다.
이제 마지막으로 가운데에 위치한 주황색 모양의 plus 특수 탭을 구현해 보죠!
해당 버튼은 탭에 위치하고 있기는 하지만,
눌렀을 때 다른 기본 탭처럼 하위 Coordinator가 동작하여 탭이 전환되는 흐름이 아니라,
탭이 사라지고 새로운 화면 (AddLinkVC)이 표출되어 전환되고 - 해당 화면을 pop 시킬 경우에는 다시 탭의 첫 번째 항목으로 돌아오도록 구현해야 하기 때문에 특수탭이라고 네이밍 했습니다.
특수탭에 대한 연결을 해주기 위해, 클로저를 연결했던 TabBarController의 didSelect delegate 메서드 부분으로 돌아와보죠.
해당 부분에서는 Scene이 아니라, (() -> Void)? 타입으로 클로저를 전달해 줄 겁니다. (NavController를 사용하지 않으니!)
해당 클로저에 이름은 onPlusScene으로 지어줬구요.
final class TabBarController: UITabBarController {
...
var onPlusScene: (() -> Void)?
}
extension TabBarController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard let controller = viewController as? UINavigationController else { return }
if currentIndex == selectedIndex { return }
self.currentIndex = selectedIndex
switch selectedIndex {
case 0: onHomeScene?(controller)
case 1: onClipScene?(controller)
case 2: onPlusScene?() // 특수탭 코드 추가
case 3: onSearchScene?(controller)
case 4: onTimerScene?(controller)
default: return
}
}
}
이제 Coordinator에서는 하위 Coordinator를 생성, 시작하는 것이 아니라 / VC로의 이동 코드를 작성해 줍니다.
기존 해오던 방법과 동일하게 클로저로 화면 전달을 구현 (popToRoot, push 등)하는 것을 확인할 수 있고,
VC에서 Root로 돌아오는 상황에서는 처음 start에서 호출되던 것처럼 탭을 0번으로 지정해주는 점 (selectTab(0))만 주의해주면 되겠네요!
private extension TabBarCoordinator {
func setupTabBarController() {
let vc = viewControllerFactory.makeTabBarVC()
...
vc.onPlusScene = { [weak self] in
self?.handlePlusTabSelection()
}
...
}
...
func handlePlusTabSelection() {
let vc = viewControllerFactory.makeAddLinkVC(isNavigationBarHidden: false)
vc.onLinkInputCompleted = { [weak self] linkURL in
self?.showSelectClipVC(linkURL: linkURL)
}
vc.onPopToRoot = { [weak self] in
self?.tabBarController?.selectTab(0)
self?.router.popToRoot(animated: false)
}
router.push(vc, animated: false)
}
func showSelectClipVC(linkURL: String) {
let vc = ViewControllerFactory.shared.makeSelectClipVC(isNavigationBarHidden: false)
vc.linkURL = linkURL
vc.onPopToRoot = { [weak self] in
self?.tabBarController?.selectTab(0)
self?.router.popToRoot(animated: false)
}
router.push(vc, animated: true)
}
}
이제 4개의 기본 탭과, 1개의 특수탭을 연결하는 화면 전환을 Coordinator Pattern을 사용해 구현하는 작업이 끝났습니다!
모두 잘 작동하는군요👏🏻👏🏻👏🏻 3탄 글에서 뵙겠습니다!